diff --git a/.claude/rules/data-grid.md b/.claude/rules/data-grid.md index 272876fd8b..e86d304b26 100644 --- a/.claude/rules/data-grid.md +++ b/.claude/rules/data-grid.md @@ -30,6 +30,7 @@ Three layers, knowledge pointing one way: an owner's adapter, then `DataGridMode - **Row height, row count, reloads, inserts and removes reach both panes in one call**, so their document heights never differ. - **Vertical scroll stays in step through bounds-change notifications**: each pane's clip moves the other with `clipView.scroll(to:)` and `reflectScrolledClipView`, only when its y differs. Anywhere else scroll with `NSView.scroll(_:)`: moving a clip's x that way leaves the header behind. - **The frozen pane forwards every wheel event to the main scroll view**, so one momentum engine moves both. +- **A `fitsRows` grid is as tall as its rows and scrolls only sideways.** It publishes `model.fittingHeight` for its host to size it, hands a vertical gesture to the enclosing scroll view (the axis is decided once per gesture), and reveals a row by scrolling that page. The Structure tab's sections use it. - **Frozen columns take at most two thirds of the grid** (`DataGridMetrics.maximumFrozenShare`). Columns past that scroll, and Freeze Up to This Column is refused in the menu. - **`DataGridTableView.scrollToVisible(_:)` widens the rect leftward by the frozen width**, so a revealed column never parks under the frozen pane. - **A column moves only within its side of the freeze line.** A freeze or unfreeze rebuilds both panes' column lists from the set, writing width and position explicitly: remove-then-add alone strands a column at the end and resets its width. @@ -65,4 +66,4 @@ Three layers, knowledge pointing one way: an owner's adapter, then `DataGridMode - **AppKit builds the AXCell tree from cell views only**, so while `DataGridAccessibility.isActive` the main table mounts a `DataGridAccessibilityCellView` (identifier `data-grid-cell`) for each on-screen cell. It draws nothing and takes no clicks. Keep that path working whenever row drawing changes. - **A label reads "Row N, column M: value"**: N is the row number on screen, page offset included, and M the display position. UI tests find cells by it (`.claude/rules/ui-tests.md`, The grid). - **The frozen pane is hidden from accessibility.** A frozen column's stand-in in the main table reports the frame the frozen pane shows it at. -- **Only the viewport mounts.** Mounting for every row starved the main thread behind a 1,000-row page (#2392); rows scrolled into view remount. +- **Only the viewport mounts.** Mounting for every row starved the main thread behind a 1,000-row page (#2392). A client's read prepares the rows it walks, off-screen ones without a stand-in, and AppKit never asks for a prepared row again. So each on-screen row still without one remounts after a scroll, a layout of the grid and a read of the table: a fitted grid grows to its rows, and a page moves a grid, with no scroll. diff --git a/.claude/rules/data-sync-storage.md b/.claude/rules/data-sync-storage.md index 24a2d160ab..daafa2f767 100644 --- a/.claude/rules/data-sync-storage.md +++ b/.claude/rules/data-sync-storage.md @@ -39,6 +39,9 @@ This path holds user data. A silent wrong answer here looks exactly like an empt | Sidebar database and schema filters, recent tables | UserDefaults, device-local | `DatabaseTreeFilterStorage`, `RecentTablesStore` | | History drawer state | UserDefaults, device-local | `HistoryPanelPreferencesStorage` | | Server Dashboard tiles, per connection | UserDefaults, device-local | `ServerDashboardTileStorage` | +| Module on/off (Settings > Extensions) | UserDefaults, device-local, never synced | `AppModuleSettings` | +| Routine debugger start options, per connection and field | UserDefaults, device-local | `RoutineDebugOptionStore` | +| Whether the toolbar arrangement already carries the debug items | UserDefaults, device-local | `MainWindowToolbar` | | Trusted external links (loopback only) | UserDefaults | `ExternalConnectionTrustStore` | | Table load timings (no identifying data, never uploaded) | JSON lines | `TableLoadHistoryStore` | | Reopen Last Session list (`LastSession.json`, plus `LastOpenConnections.json` holding only the ids that reconnect, which older builds read) | JSON | `LastOpenConnectionsStorage` | diff --git a/.claude/rules/plugin-system.md b/.claude/rules/plugin-system.md index 1c254d78b3..3fe45cefce 100644 --- a/.claude/rules/plugin-system.md +++ b/.claude/rules/plugin-system.md @@ -24,11 +24,11 @@ TableProPluginKit is built with Library Evolution, so an already-built plugin ke - Add a requirement only with a default implementation, then bridge it in `PluginDriverAdapter`. - Additive: a new requirement with a default, a new field on a non-`@frozen` struct, a new case on a non-`@frozen` enum, a reordering. - Breaking: a changed, removed or renamed requirement or signature, a requirement without a default, a new case on a `@frozen` enum, a change to a frozen type's layout. -- Every ABI change sets `TABLEPRO_PLUGINKIT_VERSION` in `apple/Configs/PluginKit.xcconfig` to one past the kit of the newest `v*` tag reachable from HEAD; later changes before the next tag reuse it. A `release/*` line never bumps it. +- Every ABI change sets `TABLEPRO_PLUGINKIT_VERSION` in `apple/Configs/PluginKit.xcconfig` to one past the highest kit a `v*` tag shipped, including a tag on a `release/*` branch HEAD cannot reach; later changes before the next tag reuse it. A `release/*` line never bumps it. - The build stamps that value into `TableProPluginKitVersion` of the app and of every plugin. A plugin's `Info.plist` holds `TABLEPRO_PLUGINKIT_VERSION` and is preprocessed, because every shipped app reads the key with `as? Int`; the source file therefore fails `plutil -lint`. - After an additive change, built plugins keep loading, and the bump makes an older app refuse a plugin rebuilt against the new API instead of failing its load. - A breaking change also raises `TABLEPRO_PLUGINKIT_MINIMUM_VERSION`. Every tag ships every registry plugin built against its own kit, so the release that carries the change carries plugins in its range. -- `apple/scripts/check-pluginkit-abi.sh [--frozen] [base]` (`verify.sh abi [base]`) builds PluginKit at the base, by default the newest `v*` tag reachable from HEAD, and at HEAD with Xcode's ABI checker (`RUN_SWIFT_ABI_CHECKER_TOOL`). CI runs it in `.github/workflows/macos-tests.yml` when the `plugins` suite is true, `--frozen` on a PR into `release/*`. +- `apple/scripts/check-pluginkit-abi.sh [--frozen] [base]` (`verify.sh abi [base]`) builds PluginKit at the base, by default the `v*` tag that shipped the highest kit (with `--frozen`, the newest one reachable from HEAD), and at HEAD with Xcode's ABI checker (`RUN_SWIFT_ABI_CHECKER_TOOL`). CI runs it in `.github/workflows/macos-tests.yml` when the `plugins` suite is true, `--frozen` on a PR into `release/*`. - A checker error is a breaking change: the kit and the minimum must both be one past the base's kit, so the host refuses every plugin built before it. A baseline that changed with no error needs the kit one past the base's and the base's minimum; an identical one needs the base's kit and minimum. `--frozen` fails on any ABI change. - The checker reports breakage only; an additive change shows as a changed baseline. Its errors are in the log `verify.sh abi` prints. - Mark a public enum `@frozen` only when its case set is closed. `PluginCapability` and the transfer structs stay non-frozen so they can grow. diff --git a/.github/assets/sponsors/coderabbit.svg b/.github/assets/sponsors/coderabbit.svg new file mode 100644 index 0000000000..9e7f8ebfa9 --- /dev/null +++ b/.github/assets/sponsors/coderabbit.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/.github/assets/sponsors/dwarves-foundation.svg b/.github/assets/sponsors/dwarves-foundation.svg new file mode 100644 index 0000000000..9c93ee259d --- /dev/null +++ b/.github/assets/sponsors/dwarves-foundation.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.github/assets/sponsors/nimbus.svg b/.github/assets/sponsors/nimbus.svg new file mode 100644 index 0000000000..f16fc82d7c --- /dev/null +++ b/.github/assets/sponsors/nimbus.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/.github/assets/sponsors/pyto.svg b/.github/assets/sponsors/pyto.svg new file mode 100644 index 0000000000..4a41bfed18 --- /dev/null +++ b/.github/assets/sponsors/pyto.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/.github/assets/sponsors/simplelocalize.svg b/.github/assets/sponsors/simplelocalize.svg new file mode 100644 index 0000000000..68fd60cf9e --- /dev/null +++ b/.github/assets/sponsors/simplelocalize.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.github/plugin-registry.json b/.github/plugin-registry.json index a9eb89b33c..f6bf29a547 100644 --- a/.github/plugin-registry.json +++ b/.github/plugin-registry.json @@ -43,6 +43,9 @@ "KafkaDriverPlugin": { "name": "Kafka Driver" }, + "KingbaseESDriverPlugin": { + "name": "KingbaseES Driver" + }, "LibSQLDriverPlugin": { "name": "libSQL / Turso Driver" }, diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 8df98e9ec5..77ded5c235 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -82,7 +82,7 @@ jobs: # install, then the meson toolchain, the -dev packages the # workspace links, the runtime pieces the test suites need (xvfb, # dbus, gnome-keyring for the Secret Service step, openssh-client - # for the SSH transport). + # for the SSH transport, glycin-loaders for a value's picture). run: | apt-get install -y --no-install-recommends \ build-essential \ @@ -100,9 +100,12 @@ jobs: libgtksourceview-5-dev \ libsqlite3-dev \ libkrb5-dev \ + liblcms2-dev \ + libseccomp-dev \ clang \ libclang-dev \ adwaita-icon-theme \ + glycin-loaders \ locales \ gnome-keyring \ openssh-client \ @@ -115,7 +118,7 @@ jobs: components: rustfmt, clippy # Meson points Cargo at _build/cargo-home and _build/cargo-target, # so those are what the cache keeps. - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: linux -> _build/cargo-target cache-directories: linux/_build/cargo-home @@ -268,7 +271,7 @@ jobs: - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: toolchain: "1.98" - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + - uses: taiki-e/install-action@5488ac5a951645ce95062214902ac4b6bc1a1037 # v2.87.32 with: tool: cargo-deny@${{ env.CARGO_DENY_VERSION }},cargo-shear@${{ env.CARGO_SHEAR_VERSION }},cargo-about@${{ env.CARGO_ABOUT_VERSION }},zizmor@${{ env.ZIZMOR_VERSION }} - name: Advisories, bans, licenses and sources @@ -334,7 +337,7 @@ jobs: build-essential pkgconf meson ninja-build gettext \ desktop-file-utils appstream libglib2.0-bin libglib2.0-dev libxml2-utils \ libgtk-4-dev libadwaita-1-dev libgtksourceview-5-dev \ - libsqlite3-dev libkrb5-dev clang libclang-dev cargo rustc + libsqlite3-dev libkrb5-dev liblcms2-dev libseccomp-dev clang libclang-dev cargo rustc - name: POTFILES.in is current run: | git ls-files 'crates/app/src/*.rs' 'data/resources/*.ui' 'data/*.desktop.in.in' \ @@ -383,6 +386,9 @@ jobs: persist-credentials: false # Every Meson suite runs inside the sandbox, from a source tree with # no .git. The action gives the tests a display and a session bus. + # On aarch64 the manifest links with lld: rustc links with GNU ld + # there, and its parallel links of the gtk suite ran the runner out + # of memory. - uses: flatpak/flatpak-github-actions/flatpak-builder@79327416609af08178ad73b352877e51450790b3 # v6.8 with: manifest-path: linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json @@ -486,11 +492,11 @@ jobs: - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: toolchain: "1.98" - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: linux key: ${{ matrix.package }} - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + - uses: taiki-e/install-action@5488ac5a951645ce95062214902ac4b6bc1a1037 # v2.87.32 with: tool: cargo-nextest@${{ env.CARGO_NEXTEST_VERSION }} - name: Verify docker is available @@ -561,11 +567,11 @@ jobs: - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: toolchain: "1.98" - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: linux key: tablepro-session - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + - uses: taiki-e/install-action@5488ac5a951645ce95062214902ac4b6bc1a1037 # v2.87.32 with: tool: cargo-nextest@${{ env.CARGO_NEXTEST_VERSION }} - name: Verify docker is available @@ -623,6 +629,8 @@ jobs: libgtksourceview-5-dev \ libsqlite3-dev \ libkrb5-dev \ + liblcms2-dev \ + libseccomp-dev \ clang \ libclang-dev \ xvfb \ @@ -631,11 +639,11 @@ jobs: - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: toolchain: "1.98" - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: linux key: tablepro-app - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + - uses: taiki-e/install-action@5488ac5a951645ce95062214902ac4b6bc1a1037 # v2.87.32 with: tool: cargo-nextest@${{ env.CARGO_NEXTEST_VERSION }} - name: Verify docker is available diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 7fd9db9477..5d850cbc26 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -16,7 +16,7 @@ jobs: build: name: Release Build runs-on: xcode-27 - timeout-minutes: 150 + timeout-minutes: 240 # The tag's version settings run here first; 9999999 is the largest build at MAJOR 0 (apple/RELEASING.md, Versions). env: VERSION: 0.0.0-dev @@ -62,7 +62,8 @@ jobs: run: scripts/release/package.sh plugins --unsigned - name: Upload the build logs - if: ${{ failure() }} + # A timeout cancels the job; the log shows where xcodebuild was when it stopped. + if: ${{ failure() || cancelled() }} continue-on-error: true uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 416e184d6f..83a6ed42d8 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -115,7 +115,7 @@ jobs: build-essential pkgconf meson ninja-build gettext \ desktop-file-utils appstream libglib2.0-bin libglib2.0-dev libxml2-utils \ libgtk-4-dev libadwaita-1-dev libgtksourceview-5-dev \ - libsqlite3-dev libkrb5-dev clang libclang-dev cargo rustc + libsqlite3-dev libkrb5-dev liblcms2-dev libseccomp-dev clang libclang-dev cargo rustc working-directory: / - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df93d41ed5..bbd31b8db5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: - name: Set up Python 3.9 id: python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.9" update-environment: false @@ -87,8 +87,8 @@ jobs: needs: verify runs-on: xcode-27 environment: signing - # The unsigned archive alone has taken up to 123 minutes in Release Build; signing and two notarizations come on top. - timeout-minutes: 180 + # The archive alone took 138 minutes for v0.84.0-beta.1; signing and two notarizations come on top. + timeout-minutes: 240 permissions: contents: read env: diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index 2ecf1f6edf..4b633d147c 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -55,7 +55,7 @@ jobs: # runner's Python, which has PyYAML. - name: Set up Python 3.9 for the release tooling id: python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.9" update-environment: false diff --git a/.oss-scanner/Dockerfile b/.oss-scanner/Dockerfile index 88b83578d0..09a6864eef 100644 --- a/.oss-scanner/Dockerfile +++ b/.oss-scanner/Dockerfile @@ -12,7 +12,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl git \ build-essential pkgconf meson ninja-build gettext desktop-file-utils appstream libglib2.0-bin \ libxml2-utils libgtk-4-dev libadwaita-1-dev libgtksourceview-5-dev libsqlite3-dev libkrb5-dev \ - clang libclang-dev adwaita-icon-theme locales gnome-keyring openssh-client xvfb xauth dbus-daemon \ + liblcms2-dev libseccomp-dev clang libclang-dev adwaita-icon-theme glycin-loaders locales gnome-keyring \ + openssh-client xvfb xauth dbus-daemon \ && rm -rf /var/lib/apt/lists/* \ && localedef -i en_US -f UTF-8 en_US.UTF-8 diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md index 9e67d73ebe..4fe2a19276 100644 --- a/.oss-scanner/threat_model.md +++ b/.oss-scanner/threat_model.md @@ -32,7 +32,7 @@ The user is trusted: the SQL they type, the connections they create, the plugins 7. Secrets: `apple/TablePro/Core/Storage/KeychainHelper.swift`, `apple/TablePro/Core/Sync/SyncRecordMapper.swift` (passwords must never reach CloudKit), `linux/crates/storage/src/secrets`, `TableProLogRedaction`, the MCP error redactor. 8. SQL the app builds from data: `apple/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift`, `apple/TablePro/Core/Database/FilterSQLGenerator.swift`, `SQLEscaping.swift`, `DialectQuoteHelper.swift`, each driver's `quoteIdentifier`; on Linux `linux/crates/core/src/dml`, `linux/crates/core/src/filter/validate_raw_predicate.rs` and each driver's `dialect.rs`. A crafted table or column name that escapes quoting runs SQL with the user's privileges. 9. SSH and TLS: `apple/TablePro/Core/SSH/` (libssh2, host key prompts), `apple/Packages/TableProMacCore/Sources/TableProSSHConfig`, `linux/crates/ssh` (drives the system `ssh`, so watch for option injection from connection fields), `linux/crates/net/src/tls`. -10. Rendering of cell values: the out-of-process image renderer (`apple/TablePro/CLI/ImageRenderMain.swift`), `apple/TablePro/Core/Services/Formatting/PhpSerializeParser.swift`, the MongoDB shell's JavaScriptCore runtime. +10. Rendering of cell values: the out-of-process image renderer (`apple/TablePro/CLI/ImageRenderMain.swift`), the Linux Row Details picture, which glycin decodes in a sandbox (`linux/crates/app/src/ui/data_grid/row_detail/picture.rs`), `apple/TablePro/Core/Services/Formatting/PhpSerializeParser.swift`, the MongoDB shell's JavaScriptCore runtime. Matter least: layout, theming and localization code, `linux/crates/test-fixtures`, `linux/crates/test-support`, `apple/TableProUITests`, `scripts/`, `.github/`, docs. diff --git a/README.ko.md b/README.ko.md index d066b55520..a976414762 100644 --- a/README.ko.md +++ b/README.ko.md @@ -171,4 +171,4 @@ TablePro에 기여해 주신 모든 분께 감사드립니다. ## 라이선스 -이 프로젝트는 [GNU Affero General Public License v3.0(AGPLv3)](LICENSE)에 따라 라이선스가 부여됩니다. +이 프로젝트는 [GNU Affero General Public License v3.0(AGPLv3)](LICENSE)에 따라 라이선스가 부여됩니다. TablePro 이름과 아이콘은 이 라이선스에 포함되지 않으며, 사용 방법은 [TRADEMARKS.md](TRADEMARKS.md)에 있습니다. diff --git a/README.md b/README.md index 63b8a174f7..c2ed042266 100644 --- a/README.md +++ b/README.md @@ -148,9 +148,17 @@ The app is free under AGPLv3. If you use TablePro at work, please buy a [license ## Sponsors -Thanks to these amazing people for supporting TablePro: +TablePro is funded by its users and these sponsors. [Become a sponsor](https://tablepro.app/sponsors). + +

+ CodeRabbit + SimpleLocalize + Nimbus + Dwarves Foundation + Pyto +

-**[SimpleLocalize](https://simplelocalize.io?ref=tablepro)** · **[CodeRabbit](https://coderabbit.ai?ref=tablepro)** · **[Nimbus](https://getnimbus.io?ref=tablepro)** · **[Dwarves Foundation](https://dwarves.foundation/?ref=tablepro)** +Individual sponsors: [朱文锦](https://github.com/zhuwjin) ## Contributors @@ -174,5 +182,5 @@ Want to join them? Read [CONTRIBUTING.md](CONTRIBUTING.md). ## License -This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](LICENSE). +This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](LICENSE). The TablePro name and icon are not part of that license: [TRADEMARKS.md](TRADEMARKS.md) says how you can use them. diff --git a/README.vi.md b/README.vi.md index 0bc23e2e6f..3886e7a2c8 100644 --- a/README.vi.md +++ b/README.vi.md @@ -112,9 +112,17 @@ App miễn phí theo AGPLv3. Nếu bạn dùng TablePro cho công việc, hãy m ## Nhà tài trợ -Cảm ơn những người tuyệt vời đã ủng hộ TablePro: +TablePro được duy trì nhờ người dùng và các nhà tài trợ dưới đây. [Trở thành nhà tài trợ](https://tablepro.app/vi/sponsors). + +

+ CodeRabbit + SimpleLocalize + Nimbus + Dwarves Foundation + Pyto +

-**[SimpleLocalize](https://simplelocalize.io?ref=tablepro)** · **[CodeRabbit](https://coderabbit.ai?ref=tablepro)** · **[Nimbus](https://getnimbus.io?ref=tablepro)** · **[Dwarves Foundation](https://dwarves.foundation/?ref=tablepro)** +Nhà tài trợ cá nhân: [朱文锦](https://github.com/zhuwjin) ## Người đóng góp @@ -138,5 +146,5 @@ Muốn tham gia? Đọc [CONTRIBUTING.md](CONTRIBUTING.md). ## Bản quyền -Dự án này cấp phép theo [GNU Affero General Public License v3.0 (AGPLv3)](LICENSE). +Dự án này cấp phép theo [GNU Affero General Public License v3.0 (AGPLv3)](LICENSE). Tên và biểu tượng TablePro không thuộc giấy phép đó: cách dùng được ghi trong [TRADEMARKS.md](TRADEMARKS.md). diff --git a/README.zh.md b/README.zh.md index 19e3c4d5b7..fd8d3c0fdd 100644 --- a/README.zh.md +++ b/README.zh.md @@ -112,9 +112,17 @@ brew install --cask tablepro ## 赞助者 -感谢这些为 TablePro 提供支持的朋友们: +TablePro 由用户和以下赞助者支持。[成为赞助者](https://tablepro.app/zh-Hans/sponsors)。 + +

+ CodeRabbit + SimpleLocalize + Nimbus + Dwarves Foundation + Pyto +

-**[SimpleLocalize](https://simplelocalize.io?ref=tablepro)** · **[CodeRabbit](https://coderabbit.ai?ref=tablepro)** · **[Nimbus](https://getnimbus.io?ref=tablepro)** · **[Dwarves Foundation](https://dwarves.foundation/?ref=tablepro)** +个人赞助者: [朱文锦](https://github.com/zhuwjin) ## 贡献者 @@ -138,5 +146,5 @@ brew install --cask tablepro ## 许可证 -本项目采用 [GNU Affero General Public License v3.0 (AGPLv3)](LICENSE) 许可。 +本项目采用 [GNU Affero General Public License v3.0 (AGPLv3)](LICENSE) 许可。TablePro 名称和图标不在该许可范围内,使用方式见 [TRADEMARKS.md](TRADEMARKS.md)。 diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 0000000000..35e46b4862 --- /dev/null +++ b/TRADEMARKS.md @@ -0,0 +1,24 @@ +# Trademark policy + +The code in this repository is licensed under the [AGPLv3](LICENSE). That license covers the code. It does not cover the name **TablePro** or the TablePro icon and logo, which identify the builds this project publishes. Section 7(e) of the AGPL lets a project keep those out of the license, and this file says what you can do with them. + +The aim is simple: anyone who downloads something called TablePro gets the app this project ships, not a build someone else changed. + +## What you can do + +- Build TablePro from this source for your own use, changed or not. The AGPL allows that, including a build with the license check removed. +- Redistribute an official release unchanged, for example through a package manager, under the name TablePro. +- Package the unchanged source for a Linux distribution or a package manager under the name TablePro, with only the changes packaging needs. +- Say that your work is based on TablePro, works with TablePro, or is a fork of TablePro, in plain words that do not suggest it is TablePro. +- Use the name and icon to write about TablePro, review it, teach it, or link to it. + +## What needs a different name and icon + +- A build with any other change, such as new or removed features, a removed license check, or other branding. Rename it and replace the icon before you share it. +- A product, company, domain, app store listing, or package name that people could take for TablePro or for something made by this project. +- Anything that suggests this project endorses, sponsors, or supports your work when it does not. +- Selling copies, or builds with the paid features unlocked, under the TablePro name. + +## Questions + +For anything this file does not cover, or to ask for permission, write to [hello@tablepro.app](mailto:hello@tablepro.app). diff --git a/apple/.swiftlint.yml b/apple/.swiftlint.yml index c31e1bc9cd..698a5f7bcd 100644 --- a/apple/.swiftlint.yml +++ b/apple/.swiftlint.yml @@ -214,7 +214,7 @@ custom_rules: severity: error appstorage_key_namespace: name: "AppStorage Key Namespace" - regex: '@AppStorage\(\s*"(?!com\.TablePro)(?!(?:hideExportSuccessDialog|skipSchemaPreview|structureCodeFontSize)")[^"\\]+"' + regex: '@AppStorage\(\s*"(?!com\.TablePro)(?!(?:hideExportSuccessDialog|structureCodeFontSize)")[^"\\]+"' excluded: ['.*/(Packages|Plugins)/'] message: "Route an @AppStorage key through the preferences layer, under com.TablePro." severity: error diff --git a/apple/Configs/PluginKit.xcconfig b/apple/Configs/PluginKit.xcconfig index 8ed03963ae..4e2f48a60f 100644 --- a/apple/Configs/PluginKit.xcconfig +++ b/apple/Configs/PluginKit.xcconfig @@ -1,3 +1,3 @@ // When to raise each, and how the build stamps them: .claude/rules/plugin-system.md, ABI. -TABLEPRO_PLUGINKIT_VERSION = 40 +TABLEPRO_PLUGINKIT_VERSION = 41 TABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19 diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift index a190774b8c..4c7af0ac3c 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift @@ -150,22 +150,25 @@ public final class SQLCompletionProvider { // A dot prefix is a table or alias first, then a schema (its tables) or a database (its schemas, or its // tables on an engine with none). The namespace fallback also covers an alias that resolves to a schema - // name parsed out of the FROM clause. + // name parsed out of the FROM clause. In a table slot it can only qualify the table being named, so a table + // that shares the name is never read for columns. if let dotPrefix = context.dotPrefix { guard let catalog else { return [] } - if let derived = context.tableReferences.first(where: { - $0.isDerived && $0.identifier.caseInsensitiveCompare(dotPrefix) == .orderedSame - }) { - let columns = await derivedColumns(of: derived) - if !columns.isEmpty { - return [columns.map { SQLCompletionItem.column($0, dataType: nil, tableName: derived.identifier) }] + if !context.expectsObjectName { + if let derived = context.tableReferences.first(where: { + $0.isDerived && $0.identifier.caseInsensitiveCompare(dotPrefix) == .orderedSame + }) { + let columns = await derivedColumns(of: derived) + if !columns.isEmpty { + return [columns.map { SQLCompletionItem.column($0, dataType: nil, tableName: derived.identifier) }] + } + } + if let tableName = await catalog.resolveAlias(dotPrefix, in: context.tableReferences) { + let schema = context.tableReferences.first { + $0.tableName.caseInsensitiveCompare(tableName) == .orderedSame + }?.schema + items = await catalog.columnCompletionItems(for: tableName, schema: schema) } - } - if let tableName = await catalog.resolveAlias(dotPrefix, in: context.tableReferences) { - let schema = context.tableReferences.first { - $0.tableName.caseInsensitiveCompare(tableName) == .orderedSame - }?.schema - items = await catalog.columnCompletionItems(for: tableName, schema: schema) } if items.isEmpty { if await catalog.isKnownSchema(dotPrefix) { diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift index aadeb59b74..841ac697c2 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift @@ -209,6 +209,7 @@ public final class SQLContextAnalyzer { private static let doubleQuote = UInt16(UnicodeScalar("\"").value) private static let backtick = UInt16(UnicodeScalar("`").value) private static let openBracket = UInt16(UnicodeScalar("[").value) + private static let closeBracket = UInt16(UnicodeScalar("]").value) private static let dollar = UInt16(UnicodeScalar("$").value) private static let hash = UInt16(UnicodeScalar("#").value) @@ -260,8 +261,9 @@ public final class SQLContextAnalyzer { // and hold any character. private static let quotedSegment = "[`\"'\\[]?\\w+[`\"'\\]]?" + // A trailing dot stays in the path, so `JOIN sales.` reads as unfinished instead of naming a table `sales`. private static let tableRefRegexes: [NSRegularExpression] = { - let path = "(\(quotedSegment)(?:\\.\(quotedSegment))*)" + let path = "(\(quotedSegment)(?:\\.\(quotedSegment))*\\.?)" let alias = "(?:\\s+(?:AS\\s+)?(\(quotedSegment)))?" let patterns = [ // MySQL's STRAIGHT_JOIN has no word boundary before JOIN. @@ -282,7 +284,7 @@ public final class SQLContextAnalyzer { ) private static let alterTableRegex = compileRegex( - "(?i)\\bALTER\\s+TABLE\\s+(\(quotedSegment)(?:\\.\(quotedSegment))*)" + "(?i)\\bALTER\\s+TABLE\\s+(\(quotedSegment)(?:\\.\(quotedSegment))*\\.?)" ) private static let derivedTableParser = DerivedTableParser() @@ -855,7 +857,13 @@ public final class SQLContextAnalyzer { append(upTo: index) start = index + 1 } - append(upTo: end) + // The name after a trailing dot is still being typed: an empty last segment, so no caller reads `sales.` as + // a table named `sales`. + if start == end, end > range.location { + segments.append(PathSegment(masked: "", name: "", isQuoted: false)) + } else { + append(upTo: end) + } return segments } @@ -1048,10 +1056,6 @@ public final class SQLContextAnalyzer { dotPrefix: String?, currentFunction: String? = nil ) -> ClauseResolution { - if dotPrefix != nil { - return ClauseResolution(clause: .select, expectsObjectName: false) - } - // Bounds the regex work on a long statement. SQLContextAnalyzerWindowingTests pins the size. let windowSize = 5_000 let nsText = textBeforeCursor as NSString @@ -1059,6 +1063,10 @@ public final class SQLContextAnalyzer { ? nsText.substring(from: nsText.length - windowSize) : textBeforeCursor + if dotPrefix != nil { + return ClauseResolution(clause: .select, expectsObjectName: qualifiesTableName(after: windowedText)) + } + // Structural contexts first: `CREATE TABLE test (id ` looks like a call to `test(` but is a column definition. let range = NSRange(location: 0, length: (windowedText as NSString).length) for (regex, clause) in Self.structuralClauseRegexes { @@ -1079,6 +1087,42 @@ public final class SQLContextAnalyzer { return ClauseResolution(clause: .unknown, expectsObjectName: false) } + // `FROM sales.` qualifies the table being named. A FROM after `IS DISTINCT` or inside a call such as + // `EXTRACT(YEAR FROM o.`, and any qualifier this cannot place, stays a table or alias. + private func qualifiesTableName(after textBeforePrefix: String) -> Bool { + let tokens = tokenize(Self.removingTrailingPath(textBeforePrefix)) + guard let keyword = tokens.last, keyword.kind == .word, + ["FROM", "JOIN", "INTO"].contains(keyword.upper) else { return false } + guard keyword.upper == "FROM" else { return true } + if tokens.count >= 2, tokens[tokens.count - 2].upper == "DISTINCT" { return false } + var open: [Int] = [] + for (index, token) in tokens.enumerated() { + if token.kind == .openParen { + open.append(index) + } else if token.kind == .closeParen, !open.isEmpty { + open.removeLast() + } + } + guard let paren = open.last, paren > 0, tokens[paren - 1].kind == .word else { return true } + return Self.groupingKeywords.contains(tokens[paren - 1].upper) + } + + // A parenthesis after one of these opens a subquery or a join group, so a FROM inside it names a table. + private static let groupingKeywords: Set = [ + "FROM", "JOIN", "LATERAL", "AS", "IN", "EXISTS", "ANY", "ALL", "SOME", "UNION", "INTERSECT", "EXCEPT" + ] + + private static func removingTrailingPath(_ text: String) -> String { + let ns = text as NSString + var start = ns.length + while start > 0 { + let ch = ns.character(at: start - 1) + guard SQLTokenBoundary.isTokenChar(ch) || ch == dot || ch == openBracket || ch == closeBracket else { break } + start -= 1 + } + return ns.substring(to: start) + } + // MARK: - Linear Clause Scan private enum ScanTokenKind { diff --git a/apple/Packages/TableProCore/Sources/TableProMySQLCore/MySQLCreateTableScanner.swift b/apple/Packages/TableProCore/Sources/TableProMySQLCore/MySQLCreateTableScanner.swift index 51be4f0135..24948db572 100644 --- a/apple/Packages/TableProCore/Sources/TableProMySQLCore/MySQLCreateTableScanner.swift +++ b/apple/Packages/TableProCore/Sources/TableProMySQLCore/MySQLCreateTableScanner.swift @@ -30,6 +30,28 @@ nonisolated public enum MySQLCreateTableScanner { return clauses } + /// The `NAME=value` options on the line that closes the column list, keyed by the upper-cased name, + /// or nil when the statement does not create a table. `DEFAULT CHARSET=x` is keyed `CHARSET`. + /// + /// The column list is skipped as one quote-aware group, so a column comment holding + /// `) AUTO_INCREMENT=7` is never read, and a quoted value such as the table comment is one token. + /// MariaDB under `NO_TABLE_OPTIONS` prints the line as a bare `)`, which reads as no options. + public static func tableOptions(fromCreateTable sql: String) -> [String: String]? { + let text = Substring(sql) + guard let header = text.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false).first, + declaresTable(header), + let body = firstGroup(in: text), + body.endIndex < text.endIndex + else { return nil } + let line = text[text.index(after: body.endIndex)...].prefix { $0 != "\n" } + var options: [String: String] = [:] + for token in topLevelTokens(of: line) { + guard let equals = token.firstIndex(of: "="), equals > token.startIndex else { continue } + options[token[.. Substring? { var depth = 0 var start: Substring.Index? diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift b/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift index 93db218345..a3f252e54b 100644 --- a/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift +++ b/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift @@ -87,13 +87,13 @@ public struct FilterSQLGenerator: Sendable { let escapedSecond = escapeValue(filter.secondValue, kind: kind) return "\(quotedColumn) BETWEEN \(escapedValue) AND \(escapedSecond)" case .contains: - let pattern = escapeLikePattern(filter.value) + let pattern = likePatternBody(filter.value) return likeCondition(operand, "'%\(pattern)%'", negated: false, folding: folding) case .startsWith: - let pattern = escapeLikePattern(filter.value) + let pattern = likePatternBody(filter.value) return likeCondition(operand, "'\(pattern)%'", negated: false, folding: folding) case .endsWith: - let pattern = escapeLikePattern(filter.value) + let pattern = likePatternBody(filter.value) return likeCondition(operand, "'%\(pattern)'", negated: false, folding: folding) } } @@ -159,7 +159,7 @@ public struct FilterSQLGenerator: Sendable { _ column: String, _ pattern: String, negated: Bool, folding: PluginSQLCaseFolding ) -> String { let keyword = negated ? folding.notLikeKeyword : folding.likeKeyword - return "\(folding.foldingLikeOperand(column)) \(keyword) \(folding.foldingLikeOperand(pattern))\(likeEscape)" + return "\(folding.foldingLikeOperand(column)) \(keyword) \(folding.foldingLikeOperand(pattern))\(likeEscapeClause)" } private func listCondition( @@ -178,7 +178,7 @@ public struct FilterSQLGenerator: Sendable { return "\(subject) \(keyword) (\(rendered.joined(separator: ", ")))" } - private var likeEscape: String { + public var likeEscapeClause: String { switch dialect.likeEscapeStyle { case .explicit: return " ESCAPE '!'" @@ -187,6 +187,17 @@ public struct FilterSQLGenerator: Sendable { } } + // The LIKE layer first, with the escape `likeEscapeClause` names, then the literal rule over the whole pattern. + public func likePatternBody(_ value: String) -> String { + let escape = dialect.likeEscapeStyle == .implicit ? "\\" : "!" + // `[` opens a character class in a SQL Server LIKE pattern. + let metacharacters = [escape, "%", "_"] + (databaseType == .mssql ? ["["] : []) + let pattern = metacharacters.reduce(value) { pattern, metacharacter in + pattern.replacingOccurrences(of: metacharacter, with: escape + metacharacter, options: .literal) + } + return literalBody(pattern) + } + private func quoteIdentifier(_ name: String) -> String { if let identifierQuoter { return identifierQuoter(name) } let open = dialect.identifierQuote @@ -199,32 +210,15 @@ public struct FilterSQLGenerator: Sendable { if FilterColumnKind.isBareNumber(value, kind: kind) { return value } - var escaped = value.replacingOccurrences(of: "\0", with: "") - if dialect.requiresBackslashEscaping { - escaped = escaped.replacingOccurrences(of: "\\", with: "\\\\") - } - escaped = escaped.replacingOccurrences(of: "'", with: "''") - return "'\(escaped)'" + return "'\(literalBody(value))'" } - private func escapeLikePattern(_ value: String) -> String { - var result = value - .replacingOccurrences(of: "'", with: "''") - .replacingOccurrences(of: "\0", with: "") - switch dialect.likeEscapeStyle { - case .explicit: - result = result - .replacingOccurrences(of: "!", with: "!!") - .replacingOccurrences(of: "%", with: "!%") - .replacingOccurrences(of: "_", with: "!_") - case .implicit: - if dialect.requiresBackslashEscaping { - result = result.replacingOccurrences(of: "\\", with: "\\\\") - } - result = result - .replacingOccurrences(of: "%", with: "\\%") - .replacingOccurrences(of: "_", with: "\\_") + // `.literal` compares code units: the default compares grapheme clusters and skips a quote a combining mark follows. + private func literalBody(_ value: String) -> String { + var body = value.replacingOccurrences(of: "\0", with: "", options: .literal) + if dialect.requiresBackslashEscaping { + body = body.replacingOccurrences(of: "\\", with: "\\\\", options: .literal) } - return result + return body.replacingOccurrences(of: "'", with: "''", options: .literal) } } diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift index deebfc6220..f0c5161f62 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift @@ -184,6 +184,17 @@ public struct PLSQLUnitTracker: SQLStatementBoundaryTracking { settlePendingBeforeNonWord() } + /// KingbaseES in Oracle mode also takes PostgreSQL's quoted routine body, `AS 'SELECT 1'` or `AS $$ ... $$`, which + /// opens no block. Oracle never puts a literal after a subprogram's `IS` or `AS`; a quoted name there still starts + /// its declarations. + public mutating func observeQuotedToken(opener: UInt16) { + if pendingBodyKeyword, opener != SqlLexer.doubleQuote { + pendingBodyKeyword = false + header = nil + } + observeOpaqueToken() + } + /// PL/SQL reads `a . b` as `a.b`, so whitespace leaves a member name a member name. public mutating func observeGap() {} diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift index 83977932c7..b1d7a9ee5e 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift @@ -99,6 +99,10 @@ public struct SQLLexicalGrammar: OptionSet, Hashable, Sendable { /// ``CQLBatchTracker``. Only the curated CQL grammar carries it, so the kit has no feature for it. public static let cqlBatches = SQLLexicalGrammar(rawValue: 1 << 26) + /// `#` and `@` continue a name after its first character, so T-SQL's `Part#` and `c@d` are one name each. Only the + /// curated T-SQL grammar carries it, so the kit has no feature for it. + public static let hashAndAtInIdentifiers = SQLLexicalGrammar(rawValue: 1 << 27) + /// Standard SQL: quotes close only on a doubled quote, `--` and flat `/* */` are the only comments. public static let ansi: SQLLexicalGrammar = [] diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift index 49f7582a25..fd905b464c 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift @@ -136,6 +136,7 @@ public struct SQLLexicalProfile: Sendable, Hashable { public static let sqlServer: SQLLexicalGrammar = [ .bracketQuotedIdentifiers, .doubledClosingBracketEscapes, .nestedBlockComments, .carriageReturnEndsLineComments, .batchSeparatorLines, .unterminatedStatements, .terminatedMergeStatements, + .hashAndAtInIdentifiers, ] /// Spanner's GoogleSQL dialect on the emulator, measured, and BigQuery by the ZetaSQL grammar the two share: a @@ -218,6 +219,13 @@ public struct SQLLexicalProfile: Sendable, Hashable { "AlloyDB": postgreSQLFamily, "Citus": postgreSQLFamily, "PGlite": postgreSQLFamily, + "KingbaseES": SQLLexicalProfile( + grammar: postgreSQL, + undetermined: [ + .backslashEscapesInSingleQuotes, .alternativeQuoting, .plsqlBlocks, + .slashLineTerminators, .dollarAndHashInIdentifiers, + ] + ), "Redshift": SQLLexicalProfile( grammar: postgreSQL, undetermined: [.backslashEscapesInSingleQuotes, .nestedBlockComments, .escapeStringPrefix] diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift index 42ba5aff14..c839318837 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift @@ -153,8 +153,12 @@ enum SQLLexicalRelevance { .parenthesizedParameterNames, ]) case SqlLexer.hash: - facts.formUnion([.hashLineComments, .dollarAndHashInIdentifiers, .parenthesizedParameterNames]) - case at, colon: + facts.formUnion([ + .hashLineComments, .dollarAndHashInIdentifiers, .parenthesizedParameterNames, .hashAndAtInIdentifiers, + ]) + case at: + facts.formUnion([.parenthesizedParameterNames, .hashAndAtInIdentifiers]) + case colon: facts.insert(.parenthesizedParameterNames) case SqlLexer.slash: facts.insert(.slashLineTerminators) diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift index 90d6374010..a3c39e0a67 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift @@ -20,7 +20,8 @@ public enum SQLStatementTerminator: Equatable, Sendable { /// boundaries without looking ahead. /// /// Words arrive uppercased. Whitespace and comments arrive as ``observeGap()``; a string literal, a quoted identifier -/// or a dollar-quoted body arrives as ``observeOpaqueToken()``. +/// or a dollar-quoted body arrives as ``observeQuotedToken(opener:)``, and any other token no word or symbol describes +/// as ``observeOpaqueToken()``. public protocol SQLStatementBoundaryTracking { /// Whether words still bear on where this statement ends. A reader that has to assemble words itself can stop /// doing so once this is false, which keeps a plain `INSERT` dump free of the cost. @@ -36,6 +37,10 @@ public protocol SQLStatementBoundaryTracking { mutating func observeSymbol(_ symbol: UInt16) mutating func observeOpaqueToken() + /// A quoted token, named by its first character: `'`, `$`, or a prefix such as `E` or `q` for a literal, and `"`, + /// a backtick or `[` for a quoted name. + mutating func observeQuotedToken(opener: UInt16) + /// Whitespace or a comment between two tokens, which keeps a word from continuing the name before it: `x$MERGE` /// is one name in T-SQL, while `x$` and `MERGE` on the next line are a name and a statement. mutating func observeGap() @@ -46,6 +51,12 @@ public protocol SQLStatementBoundaryTracking { mutating func reset() } +public extension SQLStatementBoundaryTracking { + mutating func observeQuotedToken(opener: UInt16) { + observeOpaqueToken() + } +} + public enum SQLStatementBoundaries { /// The one place a grammar is matched to its statement boundaries, so no reader can pick a different one. public static func makeTracker(for grammar: SQLLexicalGrammar) -> any SQLStatementBoundaryTracking { diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift index b6d089d98b..0356e206bd 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift @@ -333,7 +333,10 @@ public enum SQLStatementScanner { tracker.observeGap() case .executableComment: hasStatementContent = true - case .quoted, .parameter: + case .quoted: + hasStatementContent = true + tracker.observeQuotedToken(opener: nsQuery.character(at: span.start)) + case .parameter: hasStatementContent = true tracker.observeOpaqueToken() } diff --git a/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift b/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift index be6c736643..958fc6f6a8 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift @@ -67,6 +67,7 @@ public struct SQLEngineVocabulary: Sendable, Hashable { "PostgreSQL": postgreSQL, "CockroachDB": postgreSQL, "PGlite": postgreSQL, + "KingbaseES": postgreSQL, "Redshift": redshift, "SQLite": sqlite, "SQL Server": sqlServer, diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift index bf73b9a139..9fb3812a87 100644 --- a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift @@ -132,6 +132,28 @@ struct SQLClauseDetectionTests { #expect(ctx.expectsObjectName == testCase.expects) } + @Test("A qualifier's dot sits in the table slot only right after a table-naming keyword", arguments: [ + (query: "SELECT * FROM sales.", expects: true), + (query: "SELECT * FROM a JOIN sales.", expects: true), + (query: "INSERT INTO sales.", expects: true), + (query: "SELECT * FROM t WHERE id IN (SELECT id FROM sales.", expects: true), + (query: "SELECT * FROM (WITH c AS (SELECT 1 AS id) SELECT * FROM sales.", expects: true), + (query: "SELECT * FROM (a JOIN sales.", expects: true), + (query: "SELECT o. FROM orders o", expects: false), + (query: "SELECT * FROM orders o WHERE o.", expects: false), + (query: "SELECT * FROM a, sales.", expects: false), + (query: "SELECT EXTRACT(YEAR FROM o.", expects: false), + (query: "SELECT SUBSTR(o.status FROM o.", expects: false), + (query: "SELECT * FROM t WHERE a IS DISTINCT FROM o.", expects: false), + (query: "SELECT * FROM orders o, generate_series(1, o.", expects: false), + ]) + func dotInOperandSlot(_ testCase: (query: String, expects: Bool)) { + let cursor = testCase.query.hasPrefix("SELECT o.") ? 9 : (testCase.query as NSString).length + let ctx = analyzer.analyze(query: testCase.query, cursorPosition: cursor) + #expect(ctx.dotPrefix != nil) + #expect(ctx.expectsObjectName == testCase.expects) + } + // MARK: - String and Comment Guards @Test("Clause keywords inside a string literal are ignored") diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift index cb4c923171..b2d62173dd 100644 --- a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift @@ -102,6 +102,35 @@ struct SQLCompletionProviderCatalogTests { #expect(Set(session.items.map(\.label)) == ["orders", "customers"]) } + // On Oracle the column read for a table that was never there ran first and queued the schema's table list + // behind it on one connection. + @Test("A qualifier in a table slot offers its schema's tables, even beside a table of that name") + func tableSlotQualifierIsASchema() async { + let catalog = StubCatalog( + tables: ["audit"], columns: ["audit": ["id", "action"]], schemas: ["audit"], + schemaTables: ["audit": ["events"]] + ) + let provider = SQLCompletionProvider(catalog: catalog, grammar: .engine("PostgreSQL"), vocabulary: .generic) + for text in ["SELECT * FROM audit.", "SELECT * FROM orders o JOIN audit.", "INSERT INTO audit."] { + let session = await provider.completionSession(text: text, cursorPosition: (text as NSString).length) + #expect(session.items.map(\.label) == ["events"], "\(text)") + } + let columns = await provider.completionSession(text: "SELECT audit. FROM audit", cursorPosition: 13) + #expect(Set(columns.items.map(\.label)) == ["id", "action"]) + } + + @Test("A FROM inside an expression keeps an alias's columns", arguments: [ + "SELECT EXTRACT(YEAR FROM o.|) FROM orders o", + "SELECT SUBSTRING(o.status FROM o.|) FROM orders o", + "SELECT SUBSTR(o.status FROM o.|) FROM orders o", + "SELECT * FROM orders o WHERE o.total IS DISTINCT FROM o.|", + "SELECT * FROM orders o WHERE o.total IS NOT DISTINCT FROM o.|", + "SELECT * FROM orders o, generate_series(1, o.|)" + ]) + func expressionFromKeepsAliasColumns(_ marked: String) async { + #expect(Set(await labels(marked)) == ["id", "total", "status"]) + } + @Test("UPDATE offers tables") func updateOffersTables() async { let session = await provider.completionSession(text: "UPDATE or", cursorPosition: 9) diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTableReferenceTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTableReferenceTests.swift index f45cfea954..3db7400b72 100644 --- a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTableReferenceTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTableReferenceTests.swift @@ -38,6 +38,17 @@ struct SQLContextAnalyzerTableReferenceTests { #expect(tables("ALTER TABLE invoices ADD COLUMN due DATE") == ["invoices"]) } + @Test("A path that ends in a dot names no table yet") + func unfinishedPath() { + #expect(tables("SELECT * FROM sales.").isEmpty) + #expect(tables("SELECT * FROM orders o JOIN sales.") == ["orders"]) + #expect(tables("UPDATE sales.").isEmpty) + #expect(tables("INSERT INTO sales.").isEmpty) + #expect(tables("ALTER TABLE sales.").isEmpty) + #expect(analyzer.analyze(query: "SELECT * FROM sales.", cursorPosition: 20).tableReferences.isEmpty) + #expect(tables("SELECT * FROM sales.orders") == ["orders"]) + } + @Test("A schema-qualified ALTER target keeps its schema") func qualifiedAlterTarget() { let references = analyzer.tableReferences(in: "ALTER TABLE archive.orders ADD COLUMN due DATE", grammar: .ansi) diff --git a/apple/Packages/TableProCore/Tests/TableProMySQLCoreTests/MySQLCreateTableOptionsTests.swift b/apple/Packages/TableProCore/Tests/TableProMySQLCoreTests/MySQLCreateTableOptionsTests.swift new file mode 100644 index 0000000000..f208614448 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProMySQLCoreTests/MySQLCreateTableOptionsTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing + +@testable import TableProMySQLCore + +struct MySQLCreateTableOptionsTests { + @Test("The options line reads the engine, counter, character set and collation") + func readsTheOptionsLine() throws { + let sql = """ + CREATE TABLE `orders` ( + `id` int NOT NULL AUTO_INCREMENT, + `note` varchar(20) DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB AUTO_INCREMENT=42 DEFAULT CHARSET=latin1 COLLATE=latin1_bin + """ + let options = try #require(MySQLCreateTableScanner.tableOptions(fromCreateTable: sql)) + #expect(options["ENGINE"] == "InnoDB") + #expect(options["AUTO_INCREMENT"] == "42") + #expect(options["CHARSET"] == "latin1") + #expect(options["COLLATE"] == "latin1_bin") + } + + @Test("A column comment or table comment holding an option is not read as one") + func commentsAreNotOptions() throws { + let sql = """ + CREATE TABLE `t` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT ') AUTO_INCREMENT=7', + PRIMARY KEY (`id`) + ) ENGINE=InnoDB AUTO_INCREMENT=42 DEFAULT CHARSET=utf8mb4 COMMENT='x AUTO_INCREMENT=99' + """ + let options = try #require(MySQLCreateTableScanner.tableOptions(fromCreateTable: sql)) + #expect(options["AUTO_INCREMENT"] == "42") + #expect(options["COMMENT"] == "'x AUTO_INCREMENT=99'") + } + + @Test("No AUTO_INCREMENT clause reads as no counter option") + func clauseAbsentWhenNextValueIsOne() throws { + let sql = """ + CREATE TABLE `t` ( + `id` int NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + let options = try #require(MySQLCreateTableScanner.tableOptions(fromCreateTable: sql)) + #expect(options["AUTO_INCREMENT"] == nil) + #expect(options["ENGINE"] == "InnoDB") + } + + @Test("Partition clauses on the following lines are not options") + func partitionLinesAreSkipped() throws { + let sql = """ + CREATE TABLE `p` ( + `id` int NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 + /*!50100 PARTITION BY RANGE (`id`) + (PARTITION p0 VALUES LESS THAN (10) ENGINE = InnoDB) */ + """ + let options = try #require(MySQLCreateTableScanner.tableOptions(fromCreateTable: sql)) + #expect(options["AUTO_INCREMENT"] == "100") + #expect(options.count == 3) + } + + @Test("MariaDB under NO_TABLE_OPTIONS prints a bare closing line, which holds no options") + func noTableOptionsLine() throws { + let sql = "CREATE TABLE \"t\" (\n \"id\" int(11) NOT NULL,\n PRIMARY KEY (\"id\")\n)" + let options = try #require(MySQLCreateTableScanner.tableOptions(fromCreateTable: sql)) + #expect(options.isEmpty) + } + + @Test("A view's statement is not a table's", arguments: [ + "CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `v` AS select 1 AS `1`", + "" + ]) + func viewHasNoOptions(sql: String) { + #expect(MySQLCreateTableScanner.tableOptions(fromCreateTable: sql) == nil) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/KingbaseESDialectTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/KingbaseESDialectTests.swift new file mode 100644 index 0000000000..cc491ae296 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/KingbaseESDialectTests.swift @@ -0,0 +1,13 @@ +import TableProPluginKit +import Testing + +struct KingbaseESDialectTests { + @Test("KingbaseES keeps PostgreSQL dollar quotes and escape strings") + func postgresLexing() { + let dialect = SqlDialect.from(databaseTypeId: "KingbaseES") + #expect(dialect == .postgres) + #expect(dialect.supportsDollarQuotes) + #expect(dialect.supportsEscapeStringPrefix) + #expect(!dialect.requiresBackslashEscapesInSingleQuotes) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginColumnCheckTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginColumnCheckTests.swift new file mode 100644 index 0000000000..bb2929d09a --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginColumnCheckTests.swift @@ -0,0 +1,86 @@ +import Foundation +import TableProPluginKit +import Testing + +struct PluginColumnCheckTests { + @Test("A column's own CHECK round-trips through JSON encoding") + func columnCheckRoundTrip() throws { + let original = PluginColumnInfo( + name: "payload", + dataType: "LONGTEXT", + generationExpression: nil, + generationKind: nil, + ddlSpelling: nil, + ddlDefault: nil, + ddlGenerationExpression: nil, + ddlCollation: nil, + classificationTypeName: nil, + columnCheck: "json_valid(`payload`)" + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PluginColumnInfo.self, from: data) + #expect(decoded.columnCheck == "json_valid(`payload`)") + } + + @Test("A payload written before the column CHECK existed decodes with none") + func legacyPayloadDecodesToNil() throws { + let legacyJson = Data(""" + { + "name": "payload", + "dataType": "LONGTEXT", + "isNullable": true, + "isPrimaryKey": false, + "isGenerated": false, + "classificationTypeName": "JSON" + } + """.utf8) + let decoded = try JSONDecoder().decode(PluginColumnInfo.self, from: legacyJson) + #expect(decoded.columnCheck == nil) + #expect(decoded.classificationTypeName == "JSON") + } + + /// The signatures every plugin built against kit 40 carries. They have to keep resolving, and keep + /// meaning "no check", or a driver that never read one would restate one. + @Test("The initializers published before the column CHECK still resolve, and set none") + func olderInitializersLeaveItNil() { + let column = PluginColumnInfo( + name: "n", + dataType: "INT", + generationExpression: nil, + generationKind: nil, + ddlSpelling: nil, + ddlDefault: nil, + ddlGenerationExpression: nil, + ddlCollation: nil, + classificationTypeName: nil + ) + #expect(column.columnCheck == nil) + let definition = PluginColumnDefinition( + name: "n", + dataType: "INT", + generationExpression: nil, + generationKind: nil, + ddlSpelling: nil, + ddlDefault: nil, + ddlGenerationExpression: nil, + ddlCollation: nil + ) + #expect(definition.columnCheck == nil) + } + + @Test("A column definition carries the CHECK it was given") + func definitionCarriesTheCheck() { + let definition = PluginColumnDefinition( + name: "n", + dataType: "INT", + generationExpression: nil, + generationKind: nil, + ddlSpelling: nil, + ddlDefault: nil, + ddlGenerationExpression: nil, + ddlCollation: nil, + columnCheck: "`n` > 0" + ) + #expect(definition.columnCheck == "`n` > 0") + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginTableOptionsDefaultsTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginTableOptionsDefaultsTests.swift new file mode 100644 index 0000000000..cc2efad021 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginTableOptionsDefaultsTests.swift @@ -0,0 +1,53 @@ +import Foundation +import TableProPluginKit +import Testing + +private final class StubTableDriver: PluginDatabaseDriver, @unchecked Sendable { + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + func connect() async throws {} + func disconnect() {} + func ping() async throws {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +struct PluginTableOptionsDefaultsTests { + @Test("A driver that implements no table options reads none and writes none") + func defaultsAreNil() async throws { + let driver = StubTableDriver() + #expect(try await driver.fetchTableOptions(table: "t", schema: nil) == nil) + #expect(try await driver.fetchTableOptionsCatalog() == nil) + #expect(try await driver.fetchDefaultTableOptions(schema: "app") == nil) + let change = PluginTableOptionsChange(characterSet: "utf8mb4", collation: "utf8mb4_bin", autoIncrement: 5) + #expect(driver.tableOptionsStatement(table: "t", schema: nil, change: change) == nil) + } + + @Test("A change leaves every field it is not given as it is") + func changeDefaultsLeaveFieldsAlone() { + let change = PluginTableOptionsChange() + #expect(change.characterSet == nil) + #expect(change.collation == nil) + #expect(!change.convertsColumns) + #expect(change.autoIncrement == nil) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/SQLiteSessionScopeTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/SQLiteSessionScopeTests.swift new file mode 100644 index 0000000000..c404506a45 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/SQLiteSessionScopeTests.swift @@ -0,0 +1,62 @@ +import Foundation +import TableProPluginKit +import Testing + +private struct ReadFailure: Error {} + +// The connection's foreign_keys as each statement leaves it, and the read-back that follows the assignment. +private final class ScriptedConnection: @unchecked Sendable { + private let lock = NSLock() + private var value = "1" + private var reads = 0 + private let secondRead: Result + private var log: [String] = [] + + init(secondRead: Result) { + self.secondRead = secondRead + } + + var foreignKeys: String { lock.withLock { value } } + var statements: [String] { lock.withLock { log } } + + func execute(_ query: String) throws -> PluginQueryResult { + try lock.withLock { + log.append(query) + switch query { + case "PRAGMA foreign_keys = ON": value = "1" + case "PRAGMA foreign_keys = OFF": value = "0" + default: + reads += 1 + let answer = reads == 2 ? try secondRead.get() : value + return PluginQueryResult( + columns: ["foreign_keys"], columnTypeNames: [], rows: [[.text(answer)]], rowsAffected: 0, executionTime: 0 + ) + } + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + } +} + +struct SQLiteSessionScopeTests { + @Test("A relax whose read-back fails turns foreign keys back on before it throws") + func failedReadBackRestores() async { + let connection = ScriptedConnection(secondRead: .failure(ReadFailure())) + + await #expect(throws: ReadFailure.self) { + _ = try await SQLiteSessionScope.open(for: .relaxedForeignKeys) { try connection.execute($0) } + } + + #expect(connection.foreignKeys == "1") + #expect(connection.statements.last == "PRAGMA foreign_keys = ON") + } + + @Test("A relax whose read-back is unreadable turns foreign keys back on and opens no scope") + func unreadableReadBackRestores() async throws { + let connection = ScriptedConnection(secondRead: .success("unknown")) + + let scope = try await SQLiteSessionScope.open(for: .relaxedForeignKeys) { try connection.execute($0) } + + #expect(scope == nil) + #expect(connection.foreignKeys == "1") + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift index 0e64785a6e..1e693abbaa 100644 --- a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift @@ -198,6 +198,42 @@ struct FilterSQLGeneratorTests { #expect(clause("code", "a\\' OR 1=1 -- ", dialect: mysql) == "WHERE `code` = 'a\\\\'' OR 1=1 -- '") } + // Snowflake takes an ESCAPE clause for LIKE and still reads `\'` inside a literal as a quote. + @Test("A LIKE pattern doubles a backslash where the engine reads it as an escape, whatever the LIKE style") + func likeDoublesBackslashForTheLiteral() { + let explicitBackslash = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + likeEscapeStyle: .explicit, requiresBackslashEscaping: true + ) + #expect( + clause("code", #"\' OR 1=1 -- "#, op: .contains, dialect: explicitBackslash) + == #"WHERE "code" LIKE '%\\'' OR 1=1 -- %' ESCAPE '!'"# + ) + } + + @Test("A MySQL LIKE keeps a backslash literal through the string and the pattern") + func mysqlLikeBackslashIsLiteral() { + let mysql = makeDialect(quote: "`", backslashEscaping: true) + #expect(clause("code", #"a\b%"#, op: .contains, dialect: mysql) == #"WHERE `code` LIKE '%a\\\\b\\%%'"#) + } + + @Test("A SQL Server LIKE escapes an opening bracket") + func mssqlLikeEscapesBracket() { + let generator = FilterSQLGenerator(dialect: makeDialect(quote: "["), databaseType: .mssql) + let filter = TableFilter(columnName: "code", filterOperator: .contains, value: "[a]") + #expect(generator.generateWhereClause(from: [filter], logicMode: .and) == "WHERE [code] LIKE '%![a]%' ESCAPE '!'") + } + + // A combining mark joins the quote into one grapheme cluster. + @Test("A quote fused with a combining mark is still escaped") + func fusedQuoteIsEscaped() { + #expect(clause("code", "x'\u{0301} OR 1=1 --") == "WHERE \"code\" = 'x''\u{0301} OR 1=1 --'") + #expect( + clause("code", "x'\u{0301})", op: .contains) + == "WHERE \"code\" LIKE '%x''\u{0301})%' ESCAPE '!'" + ) + } + @Test("A backslash stays single where the engine reads it as a character") func backslashStaysSingle() { #expect(clause("path", "C:\\temp") == "WHERE \"path\" = 'C:\\temp'") diff --git a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift index 59794c57ca..a4da4e263f 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift @@ -7,7 +7,7 @@ struct SQLLexicalReadingsTests { @Test("Every SQL engine TablePro ships has a curated grammar") func curatedTableCoversShippedEngines() { let shipped = [ - "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", "PostgreSQL", "Redshift", "CockroachDB", "PGlite", + "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", "PostgreSQL", "Redshift", "CockroachDB", "PGlite", "KingbaseES", "SQLite", "libSQL", "Turso", "Cloudflare D1", "DuckDB", "Oracle", "Dameng", "SQL Server", "ClickHouse", "Snowflake", "BigQuery", "Spanner", "Trino", "Presto", "Teradata", "SAP HANA", "Cassandra", "ScyllaDB", "DynamoDB", "SurrealDB", "Redis", "MongoDB", "etcd", "Elasticsearch", "Typesense", "Weaviate", "Kafka", @@ -17,6 +17,14 @@ struct SQLLexicalReadingsTests { #expect(missing.isEmpty) } + @Test("Only T-SQL names continue with # and @") + func hashAndAtContinueOnlyTSQLNames() { + #expect(SQLLexicalProfile.curated(forDatabaseTypeId: "SQL Server")?.grammar.contains(.hashAndAtInIdentifiers) == true) + for typeId in ["MySQL", "PostgreSQL", "SQLite", "Oracle", "ClickHouse", "DuckDB"] { + #expect(SQLLexicalProfile.curated(forDatabaseTypeId: typeId)?.grammar.contains(.hashAndAtInIdentifiers) == false) + } + } + @Test("A curated engine ignores what a plugin declares, because a registry plugin may be older than the app") func curatedGrammarWinsOverDeclaration() { let readings = SQLLexicalReadings.resolve( @@ -122,4 +130,35 @@ struct SQLLexicalReadingsTests { ]) #expect(profile.readings.first == .ansi) } + @Test("KingbaseES retains both compatibility-mode readings before a session is known") + func kingbaseCompatibilityReadings() throws { + let profile = try #require(SQLLexicalProfile.curated(forDatabaseTypeId: "KingbaseES")) + #expect(profile.grammar.contains(.taggedDollarQuotes)) + #expect(profile.readings.contains { $0.contains(.alternativeQuoting) && $0.contains(.plsqlBlocks) }) + let oracle = SQLSessionLexicalFacts( + determined: [.alternativeQuoting, .plsqlBlocks], enabled: [.alternativeQuoting, .plsqlBlocks] + ) + let readings = SQLLexicalReadings.resolve(databaseTypeId: "KingbaseES", declared: nil, session: oracle) + #expect(readings.execution.contains(.alternativeQuoting)) + #expect(readings.execution.contains(.plsqlBlocks)) + #expect(readings.all.contains { !$0.contains(.alternativeQuoting) }) + } + + // Measured on KingbaseES V9R1C10 in Oracle mode: each of these creates its function, beside PL/SQL bodies. + @Test("Oracle-mode KingbaseES ends a quoted routine body at its semicolon and keeps a PL/SQL body whole") + func kingbaseOracleModeRoutineBodies() { + let oracle = SQLSessionLexicalFacts( + determined: [.alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers], + enabled: [.alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers] + ) + let grammar = SQLLexicalReadings.resolve(databaseTypeId: "KingbaseES", declared: nil, session: oracle).execution + func sent(_ sql: String) -> [String] { + SQLStatementScanner.executableStatements(in: sql, grammar: grammar).map(\.sql) + } + #expect(sent("CREATE FUNCTION f() RETURNS integer LANGUAGE SQL AS 'SELECT 42'; SELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f() RETURNS integer AS $$ SELECT 1; $$ LANGUAGE sql; SELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f() RETURNS integer AS $b$ BEGIN RETURN 1; END; $b$ LANGUAGE plpgsql; SELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f RETURN NUMBER AS BEGIN RETURN 1; END;\n/\nSELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f RETURN NUMBER AS \"v\" NUMBER := 1; BEGIN RETURN \"v\"; END;\n/\nSELECT 2;").count == 2) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift index 42ef86641d..8cf6396385 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift @@ -9,7 +9,7 @@ struct SQLEngineVocabularyTests { let typeIds = SQLEngineVocabulary.curatedDatabaseTypeIds #expect(typeIds == [ "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", - "PostgreSQL", "CockroachDB", "PGlite", "Redshift", + "PostgreSQL", "CockroachDB", "PGlite", "KingbaseES", "Redshift", "SQLite", "SQL Server", "Oracle", "DuckDB", ]) for typeId in typeIds { @@ -32,6 +32,7 @@ struct SQLEngineVocabularyTests { ("OceanBase", "MySQL"), ("CockroachDB", "PostgreSQL"), ("PGlite", "PostgreSQL"), + ("KingbaseES", "PostgreSQL"), ]) func variantResolvesLikeItsBase(variant: String, base: String) { let resolved = SQLEngineVocabulary.curated(forDatabaseTypeId: variant) diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Accessibility.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Accessibility.swift new file mode 100644 index 0000000000..0d934548a1 --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Accessibility.swift @@ -0,0 +1,116 @@ +// +// BreakpointRibbonView+Accessibility.swift +// TableProEditorKit +// + +import AppKit +import TableProTextEngine + +final class BreakpointElement: NSAccessibilityElement { + let line: Int + private let label: String + private let onPress: (Int) -> Void + + init(line: Int, label: String, onPress: @escaping (Int) -> Void) { + self.line = line + self.label = label + self.onPress = onPress + super.init() + } + + override func accessibilityRole() -> NSAccessibility.Role? { + .button + } + + override func accessibilityLabel() -> String? { + label + } + + override func isAccessibilityEnabled() -> Bool { + true + } + + override func accessibilityPerformPress() -> Bool { + onPress(line) + return true + } +} + +extension BreakpointRibbonView { + override func isAccessibilityElement() -> Bool { + true + } + + override func accessibilityRole() -> NSAccessibility.Role? { + .group + } + + override func accessibilityLabel() -> String? { + String(localized: "Breakpoints") + } + + override func accessibilityValue() -> Any? { + guard let executionLine = controller?.executionLine else { return nil } + return String(format: String(localized: "Paused at line %d"), executionLine) + } + + // One element per breakpoint on screen. + override func accessibilityChildren() -> [Any]? { + let breakpoints = controller?.breakpointLines ?? [] + let key = PublishedElementsKey( + breakpointLines: breakpoints, + visibleRect: visibleRect, + windowFrame: window?.frame, + documentHeight: controller?.textView?.frame.height + ) + if key == publishedElementsKey { + return publishedElements + } + publishedElementsKey = key + + guard let layoutManager = controller?.textView?.layoutManager else { + publishedElements = [] + return [] + } + + let format = String(localized: "Breakpoint, line %d") + let toggle: (Int) -> Void = { [weak self] line in + self?.controller?.onBreakpointToggle?(line) + } + let visible = visibleRect + + publishedElements = breakpoints.sorted().compactMap { lineNumber -> BreakpointElement? in + guard let line = position(ofLine: lineNumber, in: layoutManager), + line.yPos < visible.maxY, line.yPos + line.height > visible.minY else { + return nil + } + let element = BreakpointElement( + line: lineNumber, + label: String(format: format, lineNumber), + onPress: toggle + ) + element.setAccessibilityParent(self) + element.setAccessibilityFrame( + screenRect(CGRect(x: 0, y: line.yPos, width: bounds.width, height: line.height)) + ) + return element + } + + return publishedElements + } + + override func accessibilityHitTest(_ point: NSPoint) -> Any? { + accessibilityElement(atScreenPoint: point) ?? super.accessibilityHitTest(point) + } + + // The gutter floats over the text view, so a positional lookup lands on the gutter, which asks here. + func accessibilityElement(atScreenPoint point: NSPoint) -> BreakpointElement? { + let children = accessibilityChildren() as? [BreakpointElement] ?? [] + return children.first { $0.accessibilityFrame().contains(point) } + } + + private func screenRect(_ rect: CGRect) -> CGRect { + guard let window else { return .zero } + return window.convertToScreen(convert(rect, to: nil)) + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Draw.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Draw.swift new file mode 100644 index 0000000000..b2565340b9 --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Draw.swift @@ -0,0 +1,54 @@ +// +// BreakpointRibbonView+Draw.swift +// TableProEditorKit +// + +import AppKit +import TableProTextEngine + +extension BreakpointRibbonView { + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + guard let controller, let layoutManager = controller.textView?.layoutManager else { return } + let breakpoints = controller.breakpointLines + let executionLine = controller.executionLine + + for lineNumber in breakpoints { + let marker: Marker = lineNumber == executionLine ? .breakpointAtExecution : .breakpoint + draw(marker, color: breakpointColor, onLine: lineNumber, in: dirtyRect, layoutManager: layoutManager) + } + + if let executionLine, !breakpoints.contains(executionLine) { + draw(.execution, color: executionColor, onLine: executionLine, in: dirtyRect, layoutManager: layoutManager) + } + + if let hoveredLine, hoveredLine != executionLine, !breakpoints.contains(hoveredLine) { + let hint = breakpointColor.withAlphaComponent(breakpointColor.alphaComponent * Self.hintOpacity) + draw(.breakpoint, color: hint, onLine: hoveredLine, in: dirtyRect, layoutManager: layoutManager) + } + } + + private func draw( + _ marker: Marker, + color: NSColor, + onLine lineNumber: Int, + in dirtyRect: NSRect, + layoutManager: TextLayoutManager + ) { + guard let line = position(ofLine: lineNumber, in: layoutManager), + line.yPos < dirtyRect.maxY, line.yPos + line.height > dirtyRect.minY, + let image = glyph(marker, color: color) else { + return + } + + let size = image.size + let rect = NSRect( + x: (bounds.width - size.width) / 2, + y: line.yPos + (line.height - size.height) / 2, + width: size.width, + height: size.height + ) + image.draw(in: rect.pixelAligned) + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView.swift new file mode 100644 index 0000000000..ed972d4268 --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView.swift @@ -0,0 +1,172 @@ +// +// BreakpointRibbonView.swift +// TableProEditorKit +// + +import AppKit +import TableProTextEngine + +// The breakpoints and execution line live on the controller, so a host can set them before this view exists. +class BreakpointRibbonView: NSView { + static let width: CGFloat = 14.0 + static let glyphPointSize: CGFloat = 9.0 + static let hintOpacity: CGFloat = 0.4 + + enum Marker: Hashable { + case breakpoint + case execution + case breakpointAtExecution + } + + private struct GlyphKey: Hashable { + let marker: Marker + let color: NSColor + } + + // Window frame and document height catch a window move, fold or rewrap that moves an element. + struct PublishedElementsKey: Equatable { + let breakpointLines: Set + let visibleRect: CGRect + let windowFrame: CGRect? + let documentHeight: CGFloat? + } + + @Invalidating(.display) + var breakpointColor: NSColor = .systemRed + + @Invalidating(.display) + var executionColor: NSColor = .systemGreen + + // NSAccessibilityElement requires its vendor to keep ownership; fresh objects per query make VoiceOver focus jump. + var publishedElements: [BreakpointElement] = [] + var publishedElementsKey: PublishedElementsKey? + + private(set) var hoveredLine: Int? { + didSet { + guard hoveredLine != oldValue else { return } + needsDisplay = true + } + } + + private var glyphCache: [GlyphKey: NSImage] = [:] + private var pressedLine: Int? + + weak var controller: TextViewController? + + override var isFlipped: Bool { + true + } + + init(controller: TextViewController) { + self.controller = controller + super.init(frame: .zero) + layerContentsRedrawPolicy = .onSetNeedsDisplay + clipsToBounds = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Hover + + func pointerMoved(to point: CGPoint) { + hoveredLine = bounds.contains(point) ? line(at: point) : nil + } + + func pointerExitedGutter() { + hoveredLine = nil + } + + override func scrollWheel(with event: NSEvent) { + super.scrollWheel(with: event) + pointerMoved(to: convert(event.locationInWindow, from: nil)) + } + + // MARK: - Mouse Events + + // Fires on mouse up over the line the press started on, so dragging off takes the press back, as with a button. + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + guard event.type == .leftMouseDown, let line = line(at: point) else { + super.mouseDown(with: event) + return + } + pressedLine = line + } + + override func mouseDragged(with event: NSEvent) { + guard pressedLine != nil else { + super.mouseDragged(with: event) + return + } + pointerMoved(to: convert(event.locationInWindow, from: nil)) + } + + override func mouseUp(with event: NSEvent) { + guard let pressed = pressedLine else { + super.mouseUp(with: event) + return + } + pressedLine = nil + + let point = convert(event.locationInWindow, from: nil) + if bounds.contains(point), line(at: point) == pressed { + controller?.onBreakpointToggle?(pressed) + } + pointerMoved(to: point) + } + + // MARK: - Line Lookup + + func line(at point: CGPoint) -> Int? { + guard let layoutManager = controller?.textView?.layoutManager, + let line = layoutManager.textLineForPosition(point.y), + point.y >= line.yPos, point.y < line.yPos + line.height else { + return nil + } + return line.index + 1 + } + + // Nil for a line a collapsed fold hides, which the layout manager would otherwise answer with the fold's own line. + func position( + ofLine lineNumber: Int, + in layoutManager: TextLayoutManager + ) -> TextLineStorage.TextLinePosition? { + guard let line = layoutManager.textLineForIndex(lineNumber - 1), line.index == lineNumber - 1 else { + return nil + } + return line + } + + // Resolved while drawing, so the cache key is the color for the appearance AppKit made current. + func glyph(_ marker: Marker, color: NSColor) -> NSImage? { + let resolved = color.usingColorSpace(.sRGB) ?? color + let key = GlyphKey(marker: marker, color: resolved) + if let cached = glyphCache[key] { + return cached + } + + let symbolName: String + let palette: [NSColor] + switch marker { + case .breakpoint: + symbolName = "circle.fill" + palette = [resolved] + case .execution: + symbolName = "arrowshape.right.fill" + palette = [resolved] + case .breakpointAtExecution: + symbolName = "arrowtriangle.right.circle.fill" + palette = [.white, resolved] + } + + let configuration = NSImage.SymbolConfiguration(pointSize: Self.glyphPointSize, weight: .semibold) + .applying(NSImage.SymbolConfiguration(paletteColors: palette)) + let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) + + glyphCache[key] = image + return image + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Breakpoints.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Breakpoints.swift new file mode 100644 index 0000000000..8f04d5efe5 --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Breakpoints.swift @@ -0,0 +1,38 @@ +// +// TextViewController+Breakpoints.swift +// TableProEditorKit +// + +import AppKit + +extension TextViewController { + func breakpointLinesDidChange() { + gutterView?.breakpointRibbon.needsDisplay = true + } + + func executionLineDidChange() { + (textView as? SourceEditorTextView)?.executionLine = executionLine + if let ribbon = gutterView?.breakpointRibbon { + ribbon.needsDisplay = true + NSAccessibility.post(element: ribbon, notification: .valueChanged) + } + executionLineNeedsReveal = executionLine != nil + revealExecutionLineIfNeeded() + } + + // Before the view has a size or the source has arrived it stays pending; `viewDidAppear` and `setText` retry. + func revealExecutionLineIfNeeded() { + guard executionLineNeedsReveal, + let executionLine, + let textView, + isViewLoaded, scrollView != nil, view.window != nil, + let line = textView.layoutManager.lineStorage.getLine(atIndex: executionLine - 1) else { + return + } + executionLineNeedsReveal = false + revealFold(containing: line.range.location) + // A just-replaced document has not grown the view to its height yet, which would clamp the scroll. + textView.updateFrameIfNeeded() + textView.scrollToRange(NSRange(location: line.range.location, length: 0), center: true) + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift index 564db216ca..5005b1f278 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift @@ -18,6 +18,7 @@ extension TextViewController { override public func viewDidAppear() { super.viewDidAppear() + revealExecutionLineIfNeeded() textCoordinators.forEach { $0.val?.controllerDidAppear(controller: self) } } @@ -187,6 +188,7 @@ extension TextViewController { gutterView?.needsDisplay = true gutterView?.foldingRibbon.needsDisplay = true gutterView?.statementRunRibbon.needsDisplay = true + gutterView?.breakpointRibbon.needsDisplay = true } func setUpOberservers() { diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift index 0af0e1bf2a..9944635022 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift @@ -89,6 +89,22 @@ public class TextViewController: NSViewController { /// strong reference to the delegate is kept *outside* of this variable. public weak var completionDelegate: CodeSuggestionDelegate? + public var breakpointLines: Set = [] { + didSet { + guard breakpointLines != oldValue else { return } + breakpointLinesDidChange() + } + } + + public var executionLine: Int? { + didSet { + guard executionLine != oldValue else { return } + executionLineDidChange() + } + } + + public var onBreakpointToggle: ((Int) -> Void)? + // MARK: - Config Helpers /// The font to use in the `textView` @@ -180,6 +196,8 @@ public class TextViewController: NSViewController { var snippetChoicePresenter: SnippetChoicePresenter = SnippetChoiceMenu.present var snippetChoiceGeneration = 0 + var executionLineNeedsReveal = false + /// The width of the view floating along the editor's leading edge: the gutter. /// ``SourceEditorScrollView`` reserves it so the text scrolls clear of it. var floatingSubviewInsets: HorizontalEdgeInsets { @@ -274,6 +292,7 @@ public class TextViewController: NSViewController { for coordinator in textCoordinators.values() { coordinator.textViewDidReplaceDocument(controller: self) } + revealExecutionLineIfNeeded() // Deferred because the binding replaces the document from inside a SwiftUI update, where the find panel's // model must not publish. DispatchQueue.main.async { [weak findViewController] in diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift index 2a3e087f1f..9157c23761 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift @@ -109,15 +109,22 @@ public class GutterView: NSView { } } - /// Toggle the visibility of the per-statement run controls. - /// - /// The column they sit in is reserved for as long as this is on, whether or not the pointer is revealing a - /// control, so a document never shifts sideways under the reader. + // The column is reserved while this is on, revealed or not, so the document never shifts under the reader. @Invalidating(.display) public var showStatementRunControls: Bool = false { didSet { statementRunRibbon.isHidden = !showStatementRunControls updateWidthIfNeeded() + layoutRibbons() + } + } + + @Invalidating(.display) + public var showBreakpoints: Bool = false { + didSet { + breakpointRibbon.isHidden = !showBreakpoints + updateWidthIfNeeded() + layoutRibbons() } } @@ -142,6 +149,8 @@ public class GutterView: NSView { private var fontLineHeight = 1.0 + private(set) var selectionObserver: NSObjectProtocol? + private func updateFontLineHeight() { let string = NSAttributedString(string: "0", attributes: [.font: font]) let typesetter = CTTypesetterCreateWithAttributedString(string) @@ -168,14 +177,23 @@ public class GutterView: NSView { statementRunRibbon.isHidden ? 0 : StatementRunRibbonView.width + statementRunRibbonPadding } - /// Where the line numbers start. - /// - /// The run controls live in the margin the gutter already keeps from the window edge rather than being charged - /// for a column of their own, which is where Xcode puts its gutter controls too. The margin only grows when the - /// controls need more room than it already had. + var breakpointRibbon: BreakpointRibbonView + + @Invalidating(.display) + var breakpointRibbonPadding: CGFloat = 4 + + var breakpointRibbonWidth: CGFloat { + breakpointRibbon.isHidden ? 0 : BreakpointRibbonView.width + breakpointRibbonPadding + } + + private var leadingControlsWidth: CGFloat { + breakpointRibbonWidth + statementRunRibbonWidth + } + + // The leading controls share the window-edge margin, as Xcode's do; it grows only when they need more. private var lineNumberLeading: CGFloat { - guard showLineNumbers else { return statementRunRibbonWidth } - return max(edgeInsets.leading, statementRunRibbonWidth) + guard showLineNumbers else { return leadingControlsWidth } + return max(edgeInsets.leading, leadingControlsWidth) } /// The leading inset only exists to keep line numbers off the edge, so a gutter without them drops it rather @@ -202,28 +220,39 @@ public class GutterView: NSView { true } - /// We override this variable so we can update the two ribbons' frames to match the gutter. override public var frame: NSRect { get { super.frame } set { super.frame = newValue - foldingRibbon.frame = NSRect( - x: newValue.width - edgeInsets.trailing - foldingRibbonWidth + foldingRibbonPadding, - y: 0.0, - width: foldingRibbonWidth, - height: newValue.height - ) - statementRunRibbon.frame = NSRect( - x: 0.0, - y: 0.0, - width: max(0, statementRunRibbonWidth - statementRunRibbonPadding), - height: newValue.height - ) + layoutRibbons() } } + // Also called when a ribbon shows or hides, since that can leave the gutter's width, and so its frame, unchanged. + private func layoutRibbons() { + let size = frame.size + foldingRibbon.frame = NSRect( + x: size.width - edgeInsets.trailing - foldingRibbonWidth + foldingRibbonPadding, + y: 0.0, + width: foldingRibbonWidth, + height: size.height + ) + breakpointRibbon.frame = NSRect( + x: 0.0, + y: 0.0, + width: max(0, breakpointRibbonWidth - breakpointRibbonPadding), + height: size.height + ) + statementRunRibbon.frame = NSRect( + x: breakpointRibbonWidth, + y: 0.0, + width: max(0, statementRunRibbonWidth - statementRunRibbonPadding), + height: size.height + ) + } + public convenience init( configuration: borrowing SourceEditorConfiguration, controller: TextViewController, @@ -253,6 +282,7 @@ public class GutterView: NSView { foldingRibbon = LineFoldRibbonView(controller: controller) statementRunRibbon = StatementRunRibbonView(controller: controller) + breakpointRibbon = BreakpointRibbonView(controller: controller) super.init(frame: .zero) clipsToBounds = true @@ -262,12 +292,15 @@ public class GutterView: NSView { layer?.masksToBounds = true statementRunRibbon.isHidden = !showStatementRunControls + breakpointRibbon.isHidden = !showBreakpoints addSubview(foldingRibbon) addSubview(statementRunRibbon) + addSubview(breakpointRibbon) - NotificationCenter.default.addObserver( + // A block observer is only removed through its token, and only this editor's selection moves its line band. + selectionObserver = NotificationCenter.default.addObserver( forName: TextSelectionManager.selectionChangedNotification, - object: nil, + object: controller.textView.selectionManager, queue: .main ) { [weak self] _ in self?.needsDisplay = true @@ -284,9 +317,12 @@ public class GutterView: NSView { textView?.menu(for: event) } - /// A positional accessibility lookup lands on the gutter for the same reason a right-click does, so it answers for - /// the controls its ribbons draw rather than letting the search fall through to the text view underneath. + // A positional lookup lands on the gutter, not the ribbons, for the same reason a right-click does. override public func accessibilityHitTest(_ point: NSPoint) -> Any? { + if !breakpointRibbon.isHidden, + let hit = breakpointRibbon.accessibilityElement(atScreenPoint: point) { + return hit + } if !statementRunRibbon.isHidden, let hit = statementRunRibbon.accessibilityElement(atScreenPoint: point) { return hit @@ -332,6 +368,7 @@ public class GutterView: NSView { override public func mouseExited(with event: NSEvent) { foldingRibbon.pointerExitedGutter() statementRunRibbon.pointerExitedGutter() + breakpointRibbon.pointerExitedGutter() } private func forwardPointer(_ event: NSEvent) { @@ -341,6 +378,9 @@ public class GutterView: NSView { if !statementRunRibbon.isHidden { statementRunRibbon.pointerMoved(to: statementRunRibbon.convert(event.locationInWindow, from: nil)) } + if !breakpointRibbon.isHidden { + breakpointRibbon.pointerMoved(to: breakpointRibbon.convert(event.locationInWindow, from: nil)) + } } /// Updates the width of the gutter if needed to match the maximum line number found as well as the folding ribbon. @@ -504,7 +544,9 @@ public class GutterView: NSView { } deinit { - NotificationCenter.default.removeObserver(self) + if let selectionObserver { + NotificationCenter.default.removeObserver(selectionObserver) + } delegate = nil textView = nil } diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift index 3b01170b37..b359516461 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift @@ -46,6 +46,8 @@ public extension SourceEditorConfiguration { public var showSpecialCharacters: Bool + public var showBreakpoints: Bool = false + public init( showGutter: Bool = true, showLineNumbers: Bool = true, @@ -55,7 +57,8 @@ public extension SourceEditorConfiguration { foldingSizeLimit: Int = EditorHighlighting.maxHighlightableCharacters, invisibleCharactersConfiguration: InvisibleCharactersConfiguration = .empty, warningCharacters: Set = [], - showSpecialCharacters: Bool = false + showSpecialCharacters: Bool = false, + showBreakpoints: Bool = false ) { self.showGutter = showGutter self.showLineNumbers = showLineNumbers @@ -66,6 +69,7 @@ public extension SourceEditorConfiguration { self.invisibleCharactersConfiguration = invisibleCharactersConfiguration self.warningCharacters = warningCharacters self.showSpecialCharacters = showSpecialCharacters + self.showBreakpoints = showBreakpoints } @MainActor @@ -91,6 +95,11 @@ public extension SourceEditorConfiguration { shouldUpdateInsets = true } + if oldConfig?.showBreakpoints != showBreakpoints { + controller.gutterView.showBreakpoints = showBreakpoints + shouldUpdateInsets = true + } + if oldConfig?.gutterFitsContent != gutterFitsContent { controller.gutterView.fitsContent = gutterFitsContent } diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift index 9b2f1fa8b1..3d1549cdfd 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift @@ -37,6 +37,16 @@ final class SourceEditorTextView: TextView { /// view, the trigger editor, the SQL preview and the AI chat code blocks all use this class. var statementBoundaryProvider: ((_ offset: Int, _ forward: Bool) -> Int?)? + var executionLine: Int? { + didSet { + guard executionLine != oldValue else { return } + needsDisplay = true + } + } + + // Xcode's green for a stopped line, translucent so the text keeps its theme color on any background. + let executionLineColor = NSColor.systemGreen.withDynamicAlpha(0.22) + /// Painted by the text view itself, the way `NSTextView.drawsBackground` does. Left to the enclosing scroll view, /// a semantic color such as `textBackgroundColor` is drawn as the window's background inside a window with a /// toolbar, so the text area no longer matched the gutter in Dark. @@ -54,6 +64,8 @@ final class SourceEditorTextView: TextView { } drawStatementHighlight(in: dirtyRect) super.draw(dirtyRect) + // After `super`, so the caret line's opaque fill cannot hide it; the text is drawn by subviews above both. + drawExecutionLine(in: dirtyRect) } /// `Option+Shift+Up`, per AppKit's own standard key bindings. @@ -118,4 +130,21 @@ final class SourceEditorTextView: TextView { } context.restoreGState() } + + private func drawExecutionLine(in dirtyRect: NSRect) { + guard let executionLine, + let line = layoutManager.textLineForIndex(executionLine - 1), + line.index == executionLine - 1, + let context = NSGraphicsContext.current?.cgContext else { + return + } + + let band = CGRect(x: 0, y: line.yPos, width: bounds.width, height: line.height).pixelAligned + guard band.intersects(dirtyRect) else { return } + + context.saveGState() + context.setFillColor(executionLineColor.safeCGColor) + context.fill(band.intersection(dirtyRect)) + context.restoreGState() + } } diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/BreakpointRibbonTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/BreakpointRibbonTests.swift new file mode 100644 index 0000000000..31b01e1dea --- /dev/null +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/BreakpointRibbonTests.swift @@ -0,0 +1,371 @@ +// +// BreakpointRibbonTests.swift +// TableProEditorKitTests +// + +import AppKit +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +@MainActor +struct BreakpointRibbonTests { + @MainActor + private final class Harness { + let window: NSWindow + let controller: TextViewController + private(set) var toggled: [Int] = [] + + init(lineCount: Int = 20, windowHeight: CGFloat = 800, setUp: (TextViewController) -> Void = { _ in }) { + let text = (1...lineCount).map { "SELECT \($0) FROM orders;" }.joined(separator: "\n") + controller = TextViewController( + string: text, + language: .default, + configuration: SourceEditorConfiguration( + appearance: .init( + theme: Mock.theme(), + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + wrapLines: false + ), + behavior: .init(isEditable: false), + peripherals: .init(showFoldingRibbon: false, showBreakpoints: true) + ), + cursorPositions: [], + highlightProviders: [] + ) + setUp(controller) + + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: windowHeight), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = controller.view + controller.view.layoutSubtreeIfNeeded() + controller.textView.updateFrameIfNeeded() + controller.textView.layoutManager.layoutLines() + controller.gutterView.updateWidthIfNeeded() + controller.viewDidAppear() + + controller.onBreakpointToggle = { [weak self] line in + self?.toggled.append(line) + } + } + + var ribbon: BreakpointRibbonView { + controller.gutterView.breakpointRibbon + } + + func line(_ number: Int) throws -> TextLineStorage.TextLinePosition { + try #require(controller.textView.layoutManager.textLineForIndex(number - 1)) + } + + func windowPoint(onLine number: Int) throws -> NSPoint { + let line = try line(number) + return ribbon.convert(NSPoint(x: ribbon.bounds.midX, y: line.yPos + line.height / 2), to: nil) + } + + func mouse(_ type: NSEvent.EventType, at point: NSPoint) throws -> NSEvent { + try #require(NSEvent.mouseEvent( + with: type, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + )) + } + + // Delivered to whatever the window hit-tests at the point, so a click that never reaches the ribbon fails. + func click(downOnLine down: Int, upOnLine up: Int? = nil, upAcross offset: CGFloat = 0) throws { + let downPoint = try windowPoint(onLine: down) + var upPoint = try windowPoint(onLine: up ?? down) + upPoint.x += offset + let target = try #require(window.contentView?.hitTest(downPoint)) + target.mouseDown(with: try mouse(.leftMouseDown, at: downPoint)) + target.mouseUp(with: try mouse(.leftMouseUp, at: upPoint)) + } + + func breakpointElements() -> [BreakpointElement] { + (ribbon.accessibilityChildren() as? [BreakpointElement]) ?? [] + } + + func ribbonColor(onLine number: Int) throws -> NSColor? { + let line = try line(number) + let rect = NSRect(x: 0, y: line.yPos, width: ribbon.bounds.width, height: line.height) + let rep = try #require(ribbon.bitmapImageRepForCachingDisplay(in: rect)) + ribbon.cacheDisplay(in: rect, to: rep) + return rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)?.usingColorSpace(.sRGB) + } + } + + private static func isRed(_ color: NSColor?) -> Bool { + guard let color, color.alphaComponent > 0.5 else { return false } + return color.redComponent > 0.7 && color.greenComponent < 0.5 && color.blueComponent < 0.5 + } + + private static func isGreen(_ color: NSColor?) -> Bool { + guard let color, color.alphaComponent > 0.5 else { return false } + return color.greenComponent > 0.6 && color.redComponent < 0.5 && color.blueComponent < 0.6 + } + + // MARK: - Clicks + + @Test("A click on a line without a breakpoint asks the host to add one there, in a read-only editor") + func clickOnEmptyLineAsksToAdd() throws { + let harness = Harness() + try #require(!harness.controller.isEditable) + #expect(harness.window.contentView?.hitTest(try harness.windowPoint(onLine: 3)) === harness.ribbon) + + try harness.click(downOnLine: 3) + + #expect(harness.toggled == [3]) + #expect(harness.controller.breakpointLines.isEmpty, "the editor leaves the set to the host") + } + + @Test("A click on a breakpoint asks the host to remove it") + func clickOnMarkerAsksToRemove() throws { + let harness = Harness() + harness.controller.breakpointLines = [7] + + try harness.click(downOnLine: 7) + + #expect(harness.toggled == [7]) + #expect(harness.controller.breakpointLines == [7]) + } + + @Test("Dragging off the line before releasing takes the click back") + func dragOffCancels() throws { + let harness = Harness() + + try harness.click(downOnLine: 3, upOnLine: 5) + + #expect(harness.toggled.isEmpty) + } + + @Test("Dragging sideways into the source before releasing takes the click back") + func dragIntoTheSourceCancels() throws { + let harness = Harness() + + try harness.click(downOnLine: 3, upAcross: harness.ribbon.bounds.width + 40) + + #expect(harness.toggled.isEmpty) + } + + // MARK: - Drawing and accessibility + + @Test("Each breakpoint is drawn and published as one accessibility element") + func eachBreakpointIsDrawnAndPublished() throws { + let harness = Harness() + harness.controller.breakpointLines = [2, 5] + + #expect(Self.isRed(try harness.ribbonColor(onLine: 2))) + #expect(Self.isRed(try harness.ribbonColor(onLine: 5))) + #expect(!Self.isRed(try harness.ribbonColor(onLine: 3))) + + let elements = harness.breakpointElements() + #expect(elements.map { $0.line } == [2, 5]) + #expect(elements.map { $0.accessibilityLabel() } == ["Breakpoint, line 2", "Breakpoint, line 5"]) + #expect(elements.allSatisfy { $0.accessibilityRole() == .button }) + + let line5 = try harness.line(5) + let frame = try #require(elements.last?.accessibilityFrame()) + let expected = harness.window.convertToScreen(harness.ribbon.convert( + NSRect(x: 0, y: line5.yPos, width: harness.ribbon.bounds.width, height: line5.height), + to: nil + )) + #expect(frame == expected) + } + + @Test("Pressing a breakpoint's element asks the host to remove it") + func pressingElementAsksToRemove() throws { + let harness = Harness() + harness.controller.breakpointLines = [2, 5] + + let element = try #require(harness.breakpointElements().last) + #expect(element.accessibilityPerformPress()) + + #expect(harness.toggled == [5]) + } + + @Test("Removing a breakpoint drops its element") + func removingBreakpointDropsElement() { + let harness = Harness() + harness.controller.breakpointLines = [2, 5] + #expect(harness.breakpointElements().count == 2) + + harness.controller.breakpointLines = [5] + + #expect(harness.breakpointElements().map { $0.line } == [5]) + } + + @Test("The ribbon groups the breakpoints and announces no execution line while none is set") + func ribbonGroup() { + let harness = Harness() + + #expect(harness.ribbon.isAccessibilityElement()) + #expect(harness.ribbon.accessibilityRole() == .group) + #expect(harness.ribbon.accessibilityLabel() == "Breakpoints") + #expect(harness.ribbon.accessibilityValue() == nil) + } + + // MARK: - Execution line + + @Test("Setting the execution line scrolls it into view, marks it and announces it") + func executionLineScrollsAndIsExposed() throws { + let harness = Harness(lineCount: 300, windowHeight: 300) + let textView = try #require(harness.controller.textView) + let start = try harness.line(250) + #expect(start.yPos > textView.visibleRect.maxY, "starts out of view") + + harness.controller.executionLine = 250 + + let line = try harness.line(250) + #expect(textView.visibleRect.contains(CGPoint(x: textView.visibleRect.midX, y: line.yPos + line.height / 2))) + #expect(harness.ribbon.accessibilityValue() as? String == "Paused at line 250") + #expect(Self.isGreen(try harness.ribbonColor(onLine: 250))) + #expect((textView as? SourceEditorTextView)?.executionLine == 250) + + harness.controller.executionLine = nil + + #expect(harness.ribbon.accessibilityValue() == nil) + #expect(!Self.isGreen(try harness.ribbonColor(onLine: 250))) + } + + @Test("The execution line is painted across the text, not on the lines beside it") + func executionLineBand() throws { + let harness = Harness(lineCount: 10) + let textView = try #require(harness.controller.textView) + + func colorAtLineEnd(_ number: Int) throws -> NSColor? { + let line = try harness.line(number) + let rect = NSRect(x: textView.bounds.maxX - 20, y: line.yPos, width: 10, height: line.height) + let rep = try #require(textView.bitmapImageRepForCachingDisplay(in: rect)) + textView.cacheDisplay(in: rect, to: rep) + return rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)?.usingColorSpace(.sRGB) + } + + let before = try #require(try colorAtLineEnd(4)) + harness.controller.executionLine = 4 + let marked = try #require(try colorAtLineEnd(4)) + let neighbour = try #require(try colorAtLineEnd(5)) + + #expect(marked != before) + #expect(marked.greenComponent > marked.redComponent && marked.greenComponent > marked.blueComponent) + #expect(neighbour == before) + } + + @Test("A line set before the editor appears is scrolled to once it does") + func executionLineSetBeforeAppearing() throws { + let harness = Harness(lineCount: 300, windowHeight: 300) { controller in + controller.executionLine = 250 + } + + let line = try harness.line(250) + let visible = harness.controller.textView.visibleRect + #expect(visible.contains(CGPoint(x: visible.midX, y: line.yPos + line.height / 2))) + #expect(!harness.controller.executionLineNeedsReveal) + } + + @Test("A line set before the source arrives is scrolled to once the text replaces the document") + func executionLineSetBeforeTheSourceArrives() throws { + let harness = Harness(lineCount: 1, windowHeight: 300) + let textView = try #require(harness.controller.textView) + + harness.controller.executionLine = 250 + #expect(harness.controller.executionLineNeedsReveal, "no line 250 to scroll to yet") + let before = textView.visibleRect + + harness.controller.setText((1...300).map { "SELECT \($0) FROM orders;" }.joined(separator: "\n")) + + let line = try harness.line(250) + let visible = textView.visibleRect + #expect(visible.minY > before.minY) + #expect(visible.contains(CGPoint(x: visible.midX, y: line.yPos + line.height / 2))) + #expect(!harness.controller.executionLineNeedsReveal) + } + + @Test("A breakpoint on the execution line keeps its own marker") + func breakpointOnExecutionLine() throws { + let harness = Harness() + harness.controller.breakpointLines = [4] + harness.controller.executionLine = 4 + + let color = try harness.ribbonColor(onLine: 4) + let edge = try harness.line(4) + let rect = NSRect(x: 0, y: edge.yPos, width: harness.ribbon.bounds.width, height: edge.height) + let rep = try #require(harness.ribbon.bitmapImageRepForCachingDisplay(in: rect)) + harness.ribbon.cacheDisplay(in: rect, to: rep) + let reds = (0.. (TextViewController, CGFloat) { + let controller = TextViewController( + string: "SELECT 1;\nSELECT 2;", + language: .default, + configuration: SourceEditorConfiguration( + appearance: .init( + theme: Mock.theme(), + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + wrapLines: false + ), + peripherals: peripherals + ), + cursorPositions: [], + highlightProviders: [] + ) + controller.loadView() + controller.gutterView.updateWidthIfNeeded() + return (controller, controller.gutterView.frame.width) + } + + @Test("A gutter without the breakpoint ribbon measures as one that never had it") + func hiddenRibbonLeavesWidthUnchanged() { + let (_, plain) = gutterWidth(.init(gutterFitsContent: true)) + let (controller, hidden) = gutterWidth(.init(gutterFitsContent: true, showBreakpoints: false)) + #expect(hidden == plain) + #expect(controller.gutterView.breakpointRibbon.isHidden) + #expect(controller.gutterView.breakpointRibbonWidth == 0) + + controller.configuration.peripherals.showBreakpoints = true + let reserved = BreakpointRibbonView.width + controller.gutterView.breakpointRibbonPadding + #expect(abs(controller.gutterView.frame.width - (plain + reserved)) < 0.001) + #expect(controller.gutterView.breakpointRibbon.frame.width == BreakpointRibbonView.width) + + controller.configuration.peripherals.showBreakpoints = false + #expect(controller.gutterView.frame.width == plain) + #expect(controller.floatingSubviewInsets.left == plain) + } + + @Test("Hidden breakpoints leave the run controls where they were") + func hiddenRibbonLeavesRunControlsInPlace() { + let (_, runOnly) = gutterWidth(.init(showStatementRunControls: true)) + let (controller, withHidden) = gutterWidth(.init(showStatementRunControls: true, showBreakpoints: false)) + #expect(withHidden == runOnly) + #expect(controller.gutterView.statementRunRibbon.frame.minX == 0) + + controller.configuration.peripherals.showBreakpoints = true + #expect(controller.gutterView.breakpointRibbon.frame.minX == 0) + #expect(controller.gutterView.statementRunRibbon.frame.minX == controller.gutterView.breakpointRibbonWidth) + } + + @Test("The ribbon fits in the margin a window-edge gutter already keeps") + func ribbonFitsInWindowMargin() { + let (_, plain) = gutterWidth(.init()) + let (_, shown) = gutterWidth(.init(showBreakpoints: true)) + #expect(shown == plain) + } +} diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/GutterSelectionObserverTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/GutterSelectionObserverTests.swift new file mode 100644 index 0000000000..935a9503bc --- /dev/null +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/GutterSelectionObserverTests.swift @@ -0,0 +1,61 @@ +// +// GutterSelectionObserverTests.swift +// TableProEditorKitTests +// + +import AppKit +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +@MainActor +struct GutterSelectionObserverTests { + // NotificationCenter holds a block observer's token until it is removed, so a surviving token is a leaked block. + @Test("A closed gutter's selection observer is removed") + func observerIsRemovedWithTheGutter() { + let controller = Mock.textViewController(theme: Mock.theme()) + weak var token: AnyObject? + + autoreleasepool { + let gutter = GutterView( + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + textColor: .labelColor, + selectedTextColor: nil, + controller: controller + ) + token = gutter.selectionObserver as AnyObject? + #expect(token != nil) + } + + #expect(token == nil) + } + + @Test("A selection change in another editor does not redraw this gutter") + func otherEditorsSelectionLeavesGutterAlone() { + let first = Mock.loadedTextViewController(string: "a\nb\nc") + let second = Mock.loadedTextViewController(string: "x\ny\nz") + let gutter = CountingGutter( + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + textColor: .labelColor, + selectedTextColor: nil, + controller: first + ) + let before = gutter.redraws + + second.textView.selectionManager.setSelectedRange(NSRange(location: 2, length: 0)) + #expect(gutter.redraws == before) + + first.textView.selectionManager.setSelectedRange(NSRange(location: 2, length: 0)) + #expect(gutter.redraws > before) + } +} + +// `needsDisplay` does not read back on a layer-backed view outside a window, but setting it goes through this. +private final class CountingGutter: GutterView { + private(set) var redraws = 0 + + override func setNeedsDisplay(_ invalidRect: NSRect) { + redraws += 1 + super.setNeedsDisplay(invalidRect) + } +} diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/GridDisplayCacheBudget.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/GridDisplayCacheBudget.swift new file mode 100644 index 0000000000..51b940fbf2 --- /dev/null +++ b/apple/Packages/TableProMacCore/Sources/TableProUtilities/GridDisplayCacheBudget.swift @@ -0,0 +1,18 @@ +import Foundation + +// Sized by RAM and never by pressure: dispatch/source.h says not to walk and discard existing caches when pressure rises. +public enum GridDisplayCacheBudget { + public static func unmountedGrids(physicalMemory: UInt64 = ProcessInfo.processInfo.physicalMemory) -> Int { + let gigabyte: UInt64 = 1_073_741_824 + switch physicalMemory { + case (32 * gigabyte)...: + return 8 + case (16 * gigabyte)...: + return 5 + case (8 * gigabyte)...: + return 3 + default: + return 2 + } + } +} diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/MemoryPressureAdvisor.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/MemoryPressureAdvisor.swift deleted file mode 100644 index cc10153b63..0000000000 --- a/apple/Packages/TableProMacCore/Sources/TableProUtilities/MemoryPressureAdvisor.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import os - -@MainActor -public enum MemoryPressureAdvisor { - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MemoryPressureAdvisor") - - /// Current memory pressure level from the OS dispatch source. - private(set) static var isUnderPressure = false - - private static let pressureSource: DispatchSourceMemoryPressure = { - let source = DispatchSource.makeMemoryPressureSource( - eventMask: [.warning, .critical, .normal], - queue: .main - ) - source.setEventHandler { @Sendable in - MainActor.assumeIsolated { - let event = source.data - let wasPressured = isUnderPressure - isUnderPressure = event.contains(.warning) || event.contains(.critical) - if isUnderPressure && !wasPressured { - logger.info("Memory pressure detected, reducing tab eviction budget") - } else if !isUnderPressure && wasPressured { - logger.info("Memory pressure resolved, restoring tab eviction budget") - } - } - } - source.activate() - return source - }() - - public static func startMonitoring() { - _ = pressureSource - } - - public static func budgetForInactiveTabs() -> Int { - let totalBytes = ProcessInfo.processInfo.physicalMemory - let gb: UInt64 = 1_073_741_824 - - let baseBudget: Int - if totalBytes >= 32 * gb { - baseBudget = 8 - } else if totalBytes >= 16 * gb { - baseBudget = 5 - } else if totalBytes >= 8 * gb { - baseBudget = 3 - } else { - baseBudget = 2 - } - - return isUnderPressure ? max(1, baseBudget / 2) : baseBudget - } - - public static func estimatedFootprint(rowCount: Int, columnCount: Int) -> Int { - rowCount * columnCount * 64 - } -} diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift index 325c1c55d9..30a7ce249d 100644 --- a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift +++ b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift @@ -243,10 +243,13 @@ private struct Scan { appendSpanningRegion(.blockComment, start: start, startLine: startLine, end: span.contentEnd) case .quoted where text.character(at: index) == SqlDollarQuote.dollar: openStatementIfNeeded() - boundaries.observeOpaqueToken() + boundaries.observeQuotedToken(opener: SqlDollarQuote.dollar) line += span.newlines appendSpanningRegion(.quotedBody, start: start, startLine: startLine, end: span.contentEnd) - case .quoted, .parameter: + case .quoted: + boundaries.observeQuotedToken(opener: text.character(at: index)) + line += span.newlines + case .parameter: boundaries.observeOpaqueToken() line += span.newlines } diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift index f6ca4fcf81..0d2304802d 100644 --- a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift +++ b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift @@ -2,7 +2,10 @@ import Foundation public enum SQLStateChangingFunctions { public static func contains(_ name: String) -> Bool { - names.contains(name.lowercased()) + let lowered = name.lowercased() + // KingbaseES ships each pg_ function under a sys_ name too, such as sys_terminate_backend (measured on V9R1C10). + if lowered.hasPrefix("sys_") { return names.contains("pg_" + lowered.dropFirst(4)) } + return names.contains(lowered) } private static let names: Set = serverSignals diff --git a/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/GridDisplayCacheBudgetTests.swift b/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/GridDisplayCacheBudgetTests.swift new file mode 100644 index 0000000000..0043289325 --- /dev/null +++ b/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/GridDisplayCacheBudgetTests.swift @@ -0,0 +1,27 @@ +import Testing + +@testable import TableProUtilities + +struct GridDisplayCacheBudgetTests { + private static let gigabyte: UInt64 = 1_073_741_824 + + @Test( + "More memory keeps more unmounted grids formatted", + arguments: [ + (UInt64(4) * gigabyte, 2), + (UInt64(8) * gigabyte, 3), + (UInt64(16) * gigabyte, 5), + (UInt64(24) * gigabyte, 5), + (UInt64(32) * gigabyte, 8), + (UInt64(128) * gigabyte, 8), + ] + ) + func tiers(physicalMemory: UInt64, expected: Int) { + #expect(GridDisplayCacheBudget.unmountedGrids(physicalMemory: physicalMemory) == expected) + } + + @Test("Just under a tier stays on the lower one") + func tierBoundary() { + #expect(GridDisplayCacheBudget.unmountedGrids(physicalMemory: 16 * Self.gigabyte - 1) == 3) + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/MemoryPressureAdvisorTests.swift b/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/MemoryPressureAdvisorTests.swift deleted file mode 100644 index f22ecebd86..0000000000 --- a/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/MemoryPressureAdvisorTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Testing - -@testable import TableProUtilities - -@MainActor -struct MemoryPressureAdvisorTests { - @Test("budget returns positive value") - func budgetPositive() { - let budget = MemoryPressureAdvisor.budgetForInactiveTabs() - #expect(budget >= 2) - #expect(budget <= 8) - } - - @Test("memory estimation for typical tab") - func typicalTabEstimate() { - let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 1_000, columnCount: 10) - #expect(bytes == 640_000) - } - - @Test("memory estimation for empty tab") - func emptyTabEstimate() { - let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 0, columnCount: 10) - #expect(bytes == 0) - } - - @Test("memory estimation for large tab") - func largeTabEstimate() { - let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 50_000, columnCount: 20) - #expect(bytes == 64_000_000) - } -} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift index 8c60c6df99..b5a7d7b39c 100644 --- a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift @@ -13,20 +13,20 @@ internal enum QueryGateTurn: Sendable, Equatable { case busy(for: Duration) } -/// What a health check should do with the turn it was given. +// What a health check does with its turn: probe an idle channel, call a busy one alive, or call it wedged once a +// statement has held it past the staleness limit. internal enum OraclePingDecision: Sendable, Equatable { - /// The probe owns the channel and may run, and may close it if it gets no answer. case probe - /// Somebody else is using the channel, which answers the question better than a probe could. case reportAlive - /// Nothing has moved on the channel for longer than any statement is allowed to run. case reportWedged - static func of(_ turn: QueryGateTurn, wedgedAfter: Duration) -> OraclePingDecision { + // No limit for a debug session: its statement is a routine paused at a breakpoint for as long as a person reads it. + static func of(_ turn: QueryGateTurn, wedgedAfter: Duration?) -> OraclePingDecision { switch turn { case .acquired: return .probe case .busy(let held): + guard let wedgedAfter else { return .reportAlive } return held > wedgedAfter ? .reportWedged : .reportAlive } } @@ -111,6 +111,7 @@ public final class OracleCoreConnection: @unchecked Sendable { /// Read before the first statement of each session, and again before the statement after one that can /// change them, so no result ever waits on them mid-stream. var sessionZones: OracleSessionTimeZones? + var isReservedForDebugging = false } private let state = OSAllocatedUnfairLock(initialState: LockedState()) @@ -409,43 +410,42 @@ public final class OracleCoreConnection: @unchecked Sendable { } public func applyQueryTimeout(_ seconds: Int) { - state.withLock { $0.queryTimeoutSeconds = OracleQueryTimeout.boundedSeconds(seconds) } + state.withLock { current in + guard !current.isReservedForDebugging else { return } + current.queryTimeoutSeconds = OracleQueryTimeout.boundedSeconds(seconds) + } + } + + // A debug session's statement is a routine that waits at a breakpoint for a person: no query timeout, no + // staleness limit on the health check, and no silent redial, since a new session would not be the one debugged. + public func reserveForDebugging() { + state.withLock { current in + current.isReservedForDebugging = true + current.queryTimeoutSeconds = 0 + } + } + + public var isReservedForDebugging: Bool { + state.withLock { $0.isReservedForDebugging } } public func noteSessionSchema(_ schema: String) { state.withLock { $0.sessionSchema = schema } } - /// How long a statement may hold the channel before a probe that cannot get a turn treats it - /// as wedged rather than as evidence the connection is alive. It is the app's own - /// `max(queryTimeout, 300)` staleness rule, read from the timeout the app already told the - /// driver about, so the two cannot drift. An unlimited query timeout is the user saying a - /// statement may run for as long as it runs, and the floor still catches a socket that died - /// without closing. - private var wedgedStatementSeconds: Double { - max(Double(state.withLock { $0.queryTimeoutSeconds }), 300) + // The app's own `max(queryTimeout, 300)` staleness rule, read from the timeout it gave the driver so the two + // cannot drift. Nil for a debug session. + var wedgedStatementLimit: Duration? { + state.withLock { current in + current.isReservedForDebugging ? nil : .seconds(max(Double(current.queryTimeoutSeconds), 300)) + } } - /// Answers whether this connection still works, without ever taking the channel away from - /// whoever is using it. - /// - /// A probe may close a channel only while it owns it. The old shape wrapped `executeQuery` in - /// a ten second deadline, and `executeQuery` opens by waiting on the query gate, so the - /// deadline covered the queue rather than the round trip: a probe that never got a turn fired - /// `disconnect()` into a healthy statement, which OracleNIO reports to that statement as - /// `clientClosedConnection` (#3053). - /// - /// A statement already in flight answers the question better than a probe could, so a busy - /// channel reads as alive. Past ``wedgedStatementSeconds`` it reads as wedged instead, which - /// is what keeps the app's own stale-query escape valve working. - /// - /// It asks OracleNIO directly rather than running `SELECT 1` through the app's statement path: - /// there is no transaction role to admit, no autocommit flag to choose, and no silent redial, - /// so a connection that has gone away reports that it has gone away instead of reporting the - /// health of a replacement nobody asked for. + // A probe closes the channel only while it owns it (#3053). A busy channel reads as alive until + // `wedgedStatementLimit`; OracleNIO is asked directly, so a dead connection is never replaced by a redial. public func ping() async throws { let turn = await queryGate.takeTurnIfFree() - switch OraclePingDecision.of(turn, wedgedAfter: .seconds(wedgedStatementSeconds)) { + switch OraclePingDecision.of(turn, wedgedAfter: wedgedStatementLimit) { case .reportAlive: return case .reportWedged: @@ -535,6 +535,9 @@ public final class OracleCoreConnection: @unchecked Sendable { guard state.withLock({ $0.hasEverConnected && $0.close.allowsReconnect }) else { throw OracleCoreError.notConnected } + if state.withLock({ $0.isReservedForDebugging }) { + throw OracleCoreError.connectionClosed + } osLogger.notice("Reconnecting to Oracle after the previous connection was closed") try await connect() @@ -617,6 +620,56 @@ public final class OracleCoreConnection: @unchecked Sendable { return answer.rows.first?.first.map { $0 != .null } ?? false } + // MARK: - Debugging + + // Runs one statement as given: no transaction bookkeeping, session zone read or redial, all of which would send + // PL/SQL of their own, and a session in DBMS_DEBUG mode stops at the first PL/SQL it runs. A statement that opens + // `:rows` returns that cursor's first column. + public func executeDirect( + _ sql: String, + binds: [OracleBindValue] = [], + autoCommit: Bool = false, + readsRows: Bool = false + ) async throws -> [String?] { + await queryGate.acquire() + do { + let connection = try requireConnection() + let rows = try await withQueryDeadline { [self] in + try await runDirect(sql, binds: binds, autoCommit: autoCommit, readsRows: readsRows, on: connection) + } + await queryGate.release() + return rows + } catch { + let mapped = mapExecutionError(error) + await queryGate.release() + throw mapped + } + } + + private func runDirect( + _ sql: String, + binds: [OracleBindValue], + autoCommit: Bool, + readsRows: Bool, + on connection: OracleNIO.OracleConnection + ) async throws -> [String?] { + var bindings = OracleBindValue.bindings(for: binds) + let options = StatementOptions(autoCommit: autoCommit) + guard readsRows else { + try await discardRows(OracleStatement(unsafeSQL: sql, binds: bindings), options: options, on: connection) + return [] + } + let cursorRef = OracleRef(dataType: .cursor) + bindings.append(cursorRef, bindName: OracleDebugPayload.cursorBindName, isReturning: false) + try await connection.execute(OracleStatement(unsafeSQL: sql, binds: bindings), options: options, logger: nioLogger) + let cursor = try cursorRef.decode(as: Cursor.self) + var rows: [String?] = [] + for try await row in try await cursor.execute(on: connection, logger: nioLogger) { + rows.append(try row.decode(String?.self)) + } + return rows + } + // MARK: - Server Output /// Turns `DBMS_OUTPUT` on for this session, and for every session a reconnect replaces it with, since the setting diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugSession.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugSession.swift new file mode 100644 index 0000000000..725f38aa2d --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugSession.swift @@ -0,0 +1,614 @@ +import Foundation +import OSLog + +private let logger = Logger(subsystem: "com.TablePro", category: "OracleDBMSDebugSession") + +// DBMS_DEBUG: the target runs the routine in debug mode and the supervisor drives it through DBMS_DEBUG calls. Both +// are client connections, so it works through every transport the app has, where JDWP needs the server to reach +// this Mac. Oracle documents it as deprecated since 12.2; it works on 23.26 (measured). +public actor OracleDBMSDebugSession: OracleDebugEngine { + nonisolated public let mechanism = OracleDebugMechanism.dbmsDebug + // break_any_return stops in the caller after the return (measured from a depth-3 frame). + nonisolated public let supportsStepOut = true + nonisolated public let notices: AsyncStream + private let noticeContinuation: AsyncStream.Continuation + + private enum State { + case ready + case running + case paused + case ended + } + + // What the supervisor last heard from the target. A wait whose answer never came or did not read leaves it unknown: + // the target may then be paused, waiting for the supervisor, while no step shows it. + private enum TargetPosition { + case running + case paused + case unknown + } + + private struct BreakpointKey: Hashable { + let unit: OracleDebugUnit + let line: Int + } + + private struct VisibleFrame { + let location: OracleDebugLocation + let subprogram: String? + // GET_VALUE's frame number: 0 for the running frame, else the PRINT_BACKTRACE index. + let valueFrame: Int + } + + private typealias Statements = OracleDBMSDebugStatements + + private let target: any OracleDebugChannel + private let supervisor: any OracleDebugChannel + private let entryPoint: OracleDebugEntryPoint + private let invocation: OracleDebugStatements.Invocation + private let callEndWait: Duration + + private var state = State.ready + private var targetPosition = TargetPosition.running + private var call: Task, Never>? + private var lastEvent: Statements.Event? + private var placed: [BreakpointKey: Int] = [:] + private var pending: Set = [] + private var holds = OracleDebugBreakpointHolds() + private var lineMaps: [OracleDebugUnit: [Int]] = [:] + private var scans: [OracleDebugUnit: OraclePLSQLDeclarationScan] = [:] + private var pinger: Task? + private var isDriving = false + private var stopRequested = false + private var isStopped = false + private var gaveUp = false + private var stopResult = OracleDebugStopResult.ended + private var targetIdentity: OracleDebugSessionIdentity? + private var callResult: Result<[String?], Error>? + private var stopWaiters: [CheckedContinuation] = [] + + init( + target: any OracleDebugChannel, + supervisor: any OracleDebugChannel, + entryPoint: OracleDebugEntryPoint, + invocation: OracleDebugStatements.Invocation, + callEndWait: Duration = OracleDebugSupport.callEndWait, + notices initial: [OracleDebugNotice] + ) { + self.target = target + self.supervisor = supervisor + self.entryPoint = entryPoint + self.invocation = invocation + self.callEndWait = callEndWait + (notices, noticeContinuation) = AsyncStream.makeStream(of: OracleDebugNotice.self) + for notice in initial { + noticeContinuation.yield(notice) + } + } + + // DEBUG_ON takes effect after its own call, so the next PL/SQL the target runs is the routine. + func start(breakpoints: [(unit: OracleDebugUnit, line: Int)]) async throws { + let debugID = "TP" + UUID().uuidString.replacingOccurrences(of: "-", with: "") + targetIdentity = await OracleDebugSupport.sessionIdentity(of: target) + let setup = Statements.targetSetup(debugID: debugID) + _ = try await target.executeDirect(setup.block, binds: setup.binds) + _ = try await supervisor.executeDirect(Statements.ownSchema) + let attach = Statements.attach(debugID: debugID) + _ = try await supervisor.executeDirect(attach.block, binds: attach.binds) + for breakpoint in breakpoints { + _ = try await setBreakpoint(in: breakpoint.unit, line: breakpoint.line, atStart: true) + } + } + + // MARK: - Running + + public func firstStop() async throws -> OracleDebugStop { + guard state == .ready else { throw OracleDebugError.notPaused } + if stopRequested { return .failed(.stopped) } + state = .running + let target = target + let invocation = invocation + let call = Task { () -> Result<[String?], Error> in + do { + return .success(try await target.executeDirect(invocation.block, binds: invocation.binds, readsRows: true)) + } catch { + return .failure(error) + } + } + self.call = call + Task { [weak self] in + let result = await call.value + await self?.markCallEnded(result) + } + isDriving = true + defer { isDriving = false } + return try await stoppedOnError { try await driveToEntry() } + } + + private func driveToEntry() async throws -> OracleDebugStop { + var event = try await wait(flags: nil) + guard event.isPause else { return await conclude() } + let (entryBreakpoints, entryLines) = try await placeEntryBreakpoints() + for key in pending { + pending.remove(key) + if let number = try await placeNow(key) { + placed[key] = number + } else { + holds.drop(key.line, in: key.unit) + noticeContinuation.yield(.breakpointHasNoCode(unit: key.unit, line: key.line)) + } + } + if entryBreakpoints.isEmpty { + event = try await stepToEntry(from: event) + } else { + // A person's breakpoint on the same header line can be the number that answers. + func isEntry(_ event: Statements.Event) -> Bool { + if entryBreakpoints.contains(event.breakpoint ?? -1) { return true } + guard let location = location(of: event) else { return false } + return location.unit == entryPoint.entry.unit && entryLines.contains(location.line) + } + repeat { + event = try await wait(flags: Statements.BreakFlags.breakpointsOnly) + } while event.isPause && !isEntry(event) && !stopRequested + let userNumbers = Set(placed.values) + let temporary = entryBreakpoints.subtracting(userNumbers) + if event.isPause, !temporary.isEmpty { + _ = try await supervisor.executeDirect(Statements.deleteBreakpoints(temporary.sorted())) + } + } + return await settle(event) + } + + // The header line of every overload with the entry point's name; DBMS_DEBUG accepts a breakpoint there and stops + // before the declarations run (measured). + private func placeEntryBreakpoints() async throws -> (numbers: Set, lines: Set) { + let unit = entryPoint.entry.unit + let headers: [Int] + if entryPoint.entry.isPackaged { + headers = try await scan(of: unit).headerLines(ofSubprogramNamed: entryPoint.entry.subprogram) + } else { + headers = [1] + } + let executable = try await executableLines(of: unit) + var numbers: Set = [] + var lines: Set = [] + for header in headers { + guard let line = executable.first(where: { $0 >= header }) else { continue } + lines.insert(line) + let fields = try await OracleDebugSupport.payload(Statements.setBreakpoint(unit: unit, line: line), on: supervisor) + let answer = try Statements.breakpointAnswer(fromPayload: fields) + if answer.status == Statements.Status.success, let number = answer.number { + numbers.insert(number) + } + } + return (numbers, lines) + } + + // Without a header line to stop at, step into calls until the entry point's own frame runs. + private func stepToEntry(from start: Statements.Event) async throws -> Statements.Event { + var event = start + for _ in 0..<10_000 where event.isPause && !stopRequested { + event = try await wait(flags: Statements.BreakFlags.anyCall) + if let top = event.backtrace.last, top.unit == entryPoint.entry.unit, + !entryPoint.entry.isPackaged || top.entryPoint?.uppercased() == entryPoint.entry.subprogram { + break + } + } + return event + } + + public func perform(_ command: OracleDebugCommand) async throws -> OracleDebugStop { + guard state == .paused, !stopRequested else { + if stopRequested { return .failed(.stopped) } + throw OracleDebugError.notPaused + } + state = .running + isDriving = true + defer { isDriving = false } + let flags: Int + switch command { + case .continueExecution: flags = Statements.BreakFlags.breakpointsOnly + case .stepOver: flags = Statements.BreakFlags.nextLine + case .stepInto: flags = Statements.BreakFlags.anyCall + case .stepOut: flags = Statements.BreakFlags.anyReturn + } + return try await stoppedOnError { await settle(try await wait(flags: flags)) } + } + + // A call Stop overlapped ends as stopped, whatever its own statements reported after the drivers closed. + private func stoppedOnError(_ body: () async throws -> OracleDebugStop) async throws -> OracleDebugStop { + do { + return try await body() + } catch { + if stopRequested { return await outcomeOfStop() } + throw error + } + } + + // A stop in the anonymous block that called the entry point has nothing to show, so it runs on. + private func settle(_ start: Statements.Event) async -> OracleDebugStop { + var event = start + while event.isPause, !stopRequested, location(of: event) == nil { + guard let next = try? await wait(flags: Statements.BreakFlags.breakpointsOnly) else { break } + event = next + } + if stopRequested, event.isPause { + _ = try? await resume(flags: Statements.BreakFlags.abortExecution) + } + guard event.isPause, !stopRequested, let location = location(of: event) else { + return await conclude() + } + lastEvent = event + state = .paused + return .paused(location) + } + + // One supervisor wait lasts `supervisorTimeoutSeconds`; between waits it checks for Stop and for the routine + // having ended, which a SYNCHRONIZE after the target finished would otherwise wait out. + private func wait(flags: Int?) async throws -> Statements.Event { + var event = try await resume(flags: flags) + while event.timedOut { + if gaveUp || callEnded || isStopped { + return event + } + event = try await resume(flags: nil) + } + if stopRequested, !isStopped, event.isPause { + event = try await resume(flags: Statements.BreakFlags.abortExecution) + } + return event + } + + private func resume(flags: Int?) async throws -> Statements.Event { + targetPosition = .unknown + let fields = try await OracleDebugSupport.payload(Statements.resume(flags: flags), on: supervisor) + let event = try Statements.event(fromPayload: fields) + targetPosition = event.isPause ? .paused : .running + // Here, not where a step reports its pause: a step that fails after the target paused leaves `state` running. + if event.isPause, !stopRequested { + startPinging() + } + return event + } + + private func markCallEnded(_ result: Result<[String?], Error>) { + callResult = result + } + + private var callEnded: Bool { callResult != nil } + + private var callEndedOnServer: Bool { callResult.map(OracleDebugSupport.endedOnServer) ?? false } + + private func location(of event: Statements.Event) -> OracleDebugLocation? { + guard let top = event.backtrace.last, let unit = top.unit, let line = event.line ?? top.line, line > 0 else { + return nil + } + return OracleDebugLocation(unit: unit, line: line) + } + + private func conclude() async -> OracleDebugStop { + stopPinging() + if stopRequested { + return await outcomeOfStop() + } + let result: Result<[String?], Error>? + if let call { + result = await OracleDebugSupport.value(of: call, within: OracleDebugStopBudget.seconds(callEndWait)) + } else { + result = .failure(OracleDebugError.ended) + } + var stop = await OracleDebugSupport.conclude( + result, + stopped: stopRequested, + entryPoint: entryPoint, + target: target, + targetIdentity: targetIdentity + ) { [noticeContinuation] in noticeContinuation.yield($0) } + if gaveUp { + stop = .failed(.stillRunning(targetIdentity)) + } + _ = try? await supervisor.executeDirect(Statements.detach) + state = .ended + noticeContinuation.finish() + return stop + } + + // Stop owns the cleanup and the drivers are the host's once it returns, so a waiting call only reports its result. + private func outcomeOfStop() async -> OracleDebugStop { + if !isStopped { + await withCheckedContinuation { stopWaiters.append($0) } + } + return gaveUp ? .failed(.stillRunning(targetIdentity)) : .failed(.stopped) + } + + // MARK: - Keeping the target alive + + // The target aborts itself after `targetTimeoutSeconds` without a word from the supervisor, which is what ends it + // if the app goes away. PING keeps it waiting while a person reads a pause. + private func startPinging() { + guard pinger == nil else { return } + pinger = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(Statements.pingIntervalSeconds) * 1_000_000_000) + guard !Task.isCancelled else { return } + await self?.pingIfPaused() + } + } + } + + private func stopPinging() { + pinger?.cancel() + pinger = nil + } + + private func pingIfPaused() async { + guard targetPosition == .paused, !isDriving, !stopRequested else { return } + do { + _ = try await supervisor.executeDirect(Statements.ping) + } catch { + logger.notice("DBMS_DEBUG ping failed: \(String(describing: type(of: error)), privacy: .public) \(String(describing: error), privacy: .private)") + } + } + + // MARK: - Breakpoints + + // A call Stop overlaps keeps the supervisor's gate until it finishes, then ends with Stop, not with what it read or + // failed to read from a closing connection. + private func readable(_ body: () async throws -> T) async throws -> T { + guard !stopRequested else { throw OracleDebugError.ended } + let value: T + do { + value = try await body() + } catch { + if stopRequested { throw OracleDebugError.ended } + throw error + } + if stopRequested { throw OracleDebugError.ended } + return value + } + + public func setBreakpoint(in unit: OracleDebugUnit, line: Int) async throws -> Int? { + try await setBreakpoint(in: unit, line: line, atStart: false) + } + + private func setBreakpoint(in unit: OracleDebugUnit, line: Int, atStart: Bool) async throws -> Int? { + guard state != .ended else { throw OracleDebugError.ended } + guard let landed = try await readable({ try await placeBreakpoint(in: unit, line: line) }) else { return nil } + holds.hold(landed, in: unit, by: atStart ? line : landed) + return landed + } + + private func placeBreakpoint(in unit: OracleDebugUnit, line: Int) async throws -> Int? { + let lines = try await executableLines(of: unit) + guard let resolved = lines.first(where: { $0 >= line }) else { return nil } + let key = BreakpointKey(unit: unit, line: resolved) + if placed[key] != nil || pending.contains(key) { return resolved } + // Before the target starts there is nothing for SET_BREAKPOINT to talk to. + guard state == .paused else { + pending.insert(key) + return resolved + } + guard let number = try await placeNow(key) else { return nil } + placed[key] = number + return resolved + } + + private func placeNow(_ key: BreakpointKey) async throws -> Int? { + let fields = try await OracleDebugSupport.payload( + Statements.setBreakpoint(unit: key.unit, line: key.line), + on: supervisor + ) + let answer = try Statements.breakpointAnswer(fromPayload: fields) + guard answer.status == Statements.Status.success else { return nil } + return answer.number + } + + public func removeBreakpoint(in unit: OracleDebugUnit, line: Int) async throws { + guard let landed = holds.release(line, in: unit) else { return } + let key = BreakpointKey(unit: unit, line: landed) + pending.remove(key) + guard let number = placed.removeValue(forKey: key), state == .paused else { return } + do { + try await readable { + _ = try await supervisor.executeDirect(Statements.deleteBreakpoints([number])) + } + } catch { + // Still set on the server, so removing it again has to find it. + holds.hold(landed, in: unit, by: line) + placed[key] = number + throw error + } + } + + private func executableLines(of unit: OracleDebugUnit) async throws -> [Int] { + if let lines = lineMaps[unit] { return lines } + let lines = try await OracleDebugSupport.executableLines(of: unit, on: supervisor) ?? [] + lineMaps[unit] = lines + return lines + } + + // MARK: - Inspection + + public func source(of unit: OracleDebugUnit) async throws -> String { + try await readable { try await OracleDebugSupport.source(of: unit, on: supervisor) } + } + + private func scan(of unit: OracleDebugUnit) async throws -> OraclePLSQLDeclarationScan { + if let scan = scans[unit] { return scan } + let scan = OraclePLSQLDeclarationScan(source: try await source(of: unit)) + scans[unit] = scan + return scan + } + + private var visibleFrames: [VisibleFrame] { + guard let event = lastEvent else { return [] } + var frames: [VisibleFrame] = [] + let count = event.backtrace.count + for (offset, frame) in event.backtrace.enumerated().reversed() { + guard let unit = frame.unit else { continue } + let isTop = offset == count - 1 + let line = isTop ? (event.line ?? frame.line ?? 0) : (frame.line ?? 0) + frames.append(VisibleFrame( + location: OracleDebugLocation(unit: unit, line: line), + subprogram: frame.entryPoint, + valueFrame: isTop ? 0 : offset + 1 + )) + } + return frames + } + + public func stack() async throws -> [OracleDebugFrame] { + guard !stopRequested else { throw OracleDebugError.ended } + guard state == .paused else { throw OracleDebugError.notPaused } + return visibleFrames.enumerated().map { level, frame in + OracleDebugFrame(level: level, location: frame.location, subprogram: frame.subprogram) + } + } + + public func variables(inFrame level: Int) async throws -> [OracleDebugVariable] { + guard state == .paused else { throw OracleDebugError.notPaused } + return try await readable { try await readVariables(inFrame: level) } + } + + private func readVariables(inFrame level: Int) async throws -> [OracleDebugVariable] { + let frames = visibleFrames + guard frames.indices.contains(level) else { return [] } + let frame = frames[level] + let unit = frame.location.unit + let candidates = (try? await scan(of: unit))?.candidates(atLine: frame.location.line) ?? [] + var requests = candidates.map { Statements.ValueRequest(name: $0.lookupName, frame: frame.valueFrame) } + var package: [(declaration: OraclePLSQLDeclarationScan.Declaration, unit: OracleDebugUnit)] = [] + if unit.kind == .packageBody || unit.kind == .package { + for kind in [OracleDebugUnitKind.packageBody, .package] { + let owner = OracleDebugUnit(owner: unit.owner, name: unit.name, kind: kind) + for declaration in (try? await scan(of: owner))?.packageVariables ?? [] { + package.append((declaration, owner)) + requests.append(Statements.ValueRequest(name: declaration.lookupName, package: owner)) + } + } + } + guard !requests.isEmpty else { return [] } + let values = Statements.values(requests) + let fields = try await OracleDebugSupport.payload(values.block, binds: values.binds, on: supervisor) + let answers = try Statements.valueAnswers(fromPayload: fields, count: requests.count) + var result: [OracleDebugVariable] = [] + for (index, answer) in answers.enumerated() { + let declaration = index < candidates.count ? candidates[index] : package[index - candidates.count].declaration + // GET_VALUE resolves a quoted name with a dot but not one with a space (measured), so that one is shown unreadable. + guard let value = Statements.value(status: answer.status, text: answer.value) + ?? (declaration.name.contains(where: \.isWhitespace) ? .unavailable : nil) + else { continue } + let kind: OracleDebugVariableKind + if index >= candidates.count { + kind = .package + } else { + kind = declaration.kind == .parameter ? .argument : .local + } + result.append(OracleDebugVariable( + name: declaration.name, + kind: kind, + typeName: declaration.typeText, + value: value, + isConstant: declaration.kind == .constant + )) + } + return result + } + + // MARK: - Stop + + // At a pause, abort_execution ends the routine with ORA-06543 and its work rolls back (measured). While it runs, + // the target reads nothing the supervisor sends, abort included (measured: SET_BREAKPOINT and the abort both + // timed out and the routine ran to its end), so Stop waits for its next stop or its end, for a bounded time. + // A step that failed may have left it paused, where only the abort ends it without ALTER SYSTEM. + public func stop() async -> OracleDebugStopResult { + if stopRequested { + if !isStopped { + await withCheckedContinuation { stopWaiters.append($0) } + } + return stopResult + } + stopRequested = true + stopPinging() + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: OracleDebugStopBudget.total) + let endBy = deadline - OracleDebugStopBudget.cleanup + if let call { + var ended: Bool + if targetPosition != .running, !isDriving { + // A read Stop overlaps holds the supervisor's gate first; the abort waits for it at most half the budget. + await bounded(by: clock.now.advanced(by: OracleDebugStopBudget.total / 2), closing: supervisor) { _ in + _ = try? await self.resume(flags: Statements.BreakFlags.abortExecution) + } + ended = await callEnds(call, before: clock.now.advanced(by: .seconds(1))) + if !ended { + ended = await OracleDebugStopBudget.endRunningTarget(clock: clock, budget: clock.now.duration(to: endBy)) { + await killTarget(within: clock.now.duration(to: endBy)) + } waitForEnd: { remaining in + await callEnds(call, before: clock.now.advanced(by: remaining)) + } + } + } else { + ended = await OracleDebugStopBudget.endRunningTarget(clock: clock) { + await killTarget(within: clock.now.duration(to: endBy)) + } waitForEnd: { remaining in + await callEnds(call, before: clock.now.advanced(by: remaining)) + } + } + ended = ended || callEndedOnServer + if ended { + await bounded(by: deadline, closing: target) { target in + _ = try? await target.executeDirect("ROLLBACK") + } + } else { + gaveUp = true + } + stopResult = ended ? .ended : .unconfirmed(targetIdentity) + } else { + // Before the routine ran, the target only switched debug mode on; it holds no work. + stopResult = .ended + } + if !isDriving { + await bounded(by: deadline, closing: supervisor) { supervisor in + _ = try? await supervisor.executeDirect(Statements.detach) + } + } + state = .ended + noticeContinuation.finish() + isStopped = true + let waiters = stopWaiters + stopWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + return stopResult + } + + // A call that failed on its own connection ended only on this side, so Stop goes on as for one still running. + private func callEnds(_ call: Task, Never>, before deadline: ContinuousClock.Instant) async -> Bool { + let remaining = ContinuousClock.now.duration(to: deadline) + guard remaining > .zero else { return callEndedOnServer } + return await OracleDebugSupport.endedOnServer(call, within: OracleDebugStopBudget.seconds(remaining)) + } + + // A statement that would run past the budget has its channel closed, which is what ends it. + private func bounded( + by deadline: ContinuousClock.Instant, + closing connection: any OracleDebugChannel, + _ body: @escaping @Sendable (any OracleDebugChannel) async -> Void + ) async { + let remaining = ContinuousClock.now.duration(to: deadline) + guard remaining > .zero else { return } + _ = try? await withOracleTimeout( + seconds: OracleDebugStopBudget.seconds(remaining), + onTimeout: { connection.cancelCurrentQuery() } + ) { + await body(connection) + } + } + + // The supervisor waits at most `supervisorTimeoutSeconds` on the gate behind its own poll. + private func killTarget(within budget: Duration) async -> OracleDebugStopBudget.KillOutcome { + await OracleDebugSupport.kill(targetIdentity, from: supervisor, within: OracleDebugStopBudget.seconds(budget)) + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugStatements.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugStatements.swift new file mode 100644 index 0000000000..71d2c94848 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugStatements.swift @@ -0,0 +1,382 @@ +import Foundation + +// DBMS_DEBUG's two sessions: the target runs the routine, the supervisor drives it. Every supervisor call is one +// block that answers through `OracleDebugPayload`, because the runtime_info and program_info records cannot be bound. +public enum OracleDBMSDebugStatements { + // DBMS_DEBUG's break flags. + public enum BreakFlags { + public static let breakpointsOnly = 0 + public static let nextLine = 32 + public static let anyCall = 12 + // Named backwards in the package (its own comment says so): this one stops in the caller after the return. + public static let anyReturn = 512 + public static let abortExecution = 8_192 + } + + public enum Status { + public static let success = 0 + public static let noSuchObject = 3 + public static let illegalLine = 12 + public static let badHandle = 16 + public static let indexedTable = 18 + public static let timeout = 31 + public static let nullValue = 32 + public static let nullCollection = 40 + } + + public enum Reason { + public static let interpreterStarting = 2 + public static let breakpoint = 3 + public static let knlExit = 25 + } + + // How long the target waits for the supervisor at a stop before it aborts itself. The supervisor pings it while + // the person reads a pause, so only a supervisor that went away lets it run out (measured: ORA-06543 after the + // timeout, its work rolled back). + public static let targetTimeoutSeconds = 30 + public static let pingIntervalSeconds = 10 + // How long one supervisor wait lasts before it returns to check for Stop and for the target's call ending. + // Also the longest Stop's kill waits on the gate behind a poll, inside its 3 second budget. + public static let supervisorTimeoutSeconds = 1 + + static let abortOnTimeout = 3 + static let infoFlags = 2 + 4 + 8 + + // MARK: - Target + + public static func targetSetup(debugID: String) -> (block: String, binds: [OracleBindValue]) { + let block = """ + DECLARE + l_id VARCHAR2(32767); + l_timeout BINARY_INTEGER; + BEGIN + EXECUTE IMMEDIATE 'CALL SYS.DBMS_DEBUG.INITIALIZE(:1) INTO :2' USING :1, OUT l_id; + EXECUTE IMMEDIATE 'CALL SYS.DBMS_DEBUG.SET_TIMEOUT_BEHAVIOUR(\(abortOnTimeout))'; + EXECUTE IMMEDIATE 'CALL SYS.DBMS_DEBUG.SET_TIMEOUT(\(targetTimeoutSeconds)) INTO :1' USING OUT l_timeout; + EXECUTE IMMEDIATE 'CALL SYS.DBMS_DEBUG.DEBUG_ON()'; + END; + """ + return (block, [.text(debugID)]) + } + + // Read before DEBUG_ON, so the target's own PL/SQL does not stop it. DBMS_DEBUG_JDWP's functions answer for any + // user (EXECUTE is granted to PUBLIC, measured); V$SESSION needs SELECT_CATALOG_ROLE; the SID alone always works. + public static let sessionIdentityQueries = [ + """ + SELECT SYS.DBMS_DEBUG_JDWP.CURRENT_SESSION_ID, SYS.DBMS_DEBUG_JDWP.CURRENT_SESSION_SERIAL, \ + SYS_CONTEXT('USERENV', 'INSTANCE') FROM SYS.DUAL + """, + "SELECT SID, SERIAL#, SYS_CONTEXT('USERENV', 'INSTANCE') FROM SYS.V_$SESSION WHERE SID = SYS_CONTEXT('USERENV', 'SID')", + "SELECT SYS_CONTEXT('USERENV', 'SID'), NULL, SYS_CONTEXT('USERENV', 'INSTANCE') FROM SYS.DUAL" + ] + + public static func sessionIdentity(fromRow row: [String?]) -> OracleDebugSessionIdentity? { + func number(_ index: Int) -> Int? { + (row.count > index ? row[index] : nil).flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } + } + guard let sid = number(0) else { return nil } + return OracleDebugSessionIdentity(sid: sid, serial: number(1), instance: number(2)) + } + + // Ends a target the supervisor cannot reach between breakpoints. Needs ALTER SYSTEM; ORA-01031 otherwise. + public static func killSession(_ identity: OracleDebugSessionIdentity) -> String? { + guard let serial = identity.serial else { return nil } + let instance = identity.instance.map { ",@\($0)" } ?? "" + return "ALTER SYSTEM KILL SESSION '\(identity.sid),\(serial)\(instance)' IMMEDIATE" + } + + // MARK: - Supervisor + + // The supervisor runs no code of the person's, so its schema can be its own, which keeps `SYS` meaning the schema + // in the blocks below. + public static let ownSchema = """ + BEGIN + EXECUTE IMMEDIATE 'ALTER SESSION SET CURRENT_SCHEMA = "' || REPLACE(SYS_CONTEXT('USERENV', 'SESSION_USER'), '"', '""') || '"'; + END; + """ + + public static func attach(debugID: String) -> (block: String, binds: [OracleBindValue]) { + let block = """ + DECLARE + l_timeout BINARY_INTEGER; + BEGIN + l_timeout := SYS.DBMS_DEBUG.SET_TIMEOUT(\(supervisorTimeoutSeconds)); + SYS.DBMS_DEBUG.ATTACH_SESSION(:1); + END; + """ + return (block, [.text(debugID)]) + } + + public static let detach = """ + BEGIN + SYS.DBMS_DEBUG.DETACH_SESSION; + END; + """ + + public static let ping = """ + BEGIN + SYS.DBMS_DEBUG.PING; + EXCEPTION + WHEN OTHERS THEN NULL; + END; + """ + + // `flags` nil synchronizes instead of continuing. A stop answers the runtime record and the backtrace in one round + // trip; a terminated program has no backtrace, and asking for one there raises inside SYS.PBREAK (measured). + public static func resume(flags: Int?) -> String { + let call = flags.map { "SYS.DBMS_DEBUG.CONTINUE(l_info, \($0), \(infoFlags))" } + ?? "SYS.DBMS_DEBUG.SYNCHRONIZE(l_info, \(infoFlags))" + return """ + DECLARE + l_info SYS.DBMS_DEBUG.RUNTIME_INFO; + l_trace SYS.DBMS_DEBUG.BACKTRACE_TABLE; + l_status BINARY_INTEGER; + \(OracleDebugPayload.declarations) + BEGIN + l_status := \(call); + put(TO_CHAR(l_status)); + put(TO_CHAR(l_info.Reason)); + put(TO_CHAR(l_info.Terminated)); + put(TO_CHAR(l_info.Line#)); + put(TO_CHAR(l_info.Breakpoint)); + IF l_status = 0 AND NVL(l_info.Terminated, 0) = 0 THEN + SYS.DBMS_DEBUG.PRINT_BACKTRACE(l_trace); + put(TO_CHAR(l_trace.COUNT)); + FOR i IN 1 .. l_trace.COUNT LOOP + put(l_trace(i).Owner); + put(l_trace(i).Name); + put(TO_CHAR(l_trace(i).Namespace)); + put(TO_CHAR(l_trace(i).LibunitType)); + put(TO_CHAR(l_trace(i).Line#)); + put(l_trace(i).EntrypointName); + END LOOP; + ELSE + put('0'); + END IF; + \(OracleDebugPayload.openCursor) + END; + """ + } + + public static func setBreakpoint(unit: OracleDebugUnit, line: Int) -> String { + """ + DECLARE + l_program SYS.DBMS_DEBUG.PROGRAM_INFO; + l_number BINARY_INTEGER; + l_status BINARY_INTEGER; + \(OracleDebugPayload.declarations) + BEGIN + l_program.Namespace := \(unit.kind.namespace); + l_program.Owner := \(OracleDebugUnit.literal(unit.owner)); + l_program.Name := \(OracleDebugUnit.literal(unit.name)); + l_program.Line# := \(line); + l_status := SYS.DBMS_DEBUG.SET_BREAKPOINT(l_program, \(line), l_number); + put(TO_CHAR(l_status)); + put(TO_CHAR(l_number)); + \(OracleDebugPayload.openCursor) + END; + """ + } + + public static func deleteBreakpoints(_ numbers: [Int]) -> String { + let calls = numbers.map { " l_status := SYS.DBMS_DEBUG.DELETE_BREAKPOINT(\($0));" }.joined(separator: "\n") + return """ + DECLARE + l_status BINARY_INTEGER; + BEGIN + \(calls.isEmpty ? " NULL;" : calls) + END; + """ + } + + // GET_LINE_MAP answers from any session, before or without ATTACH_SESSION (measured), so it also places JDWP + // breakpoints in a unit that has not loaded yet. + public static func lineMap(of unit: OracleDebugUnit) -> String { + """ + DECLARE + l_program SYS.DBMS_DEBUG.PROGRAM_INFO; + l_max BINARY_INTEGER; + l_entries BINARY_INTEGER; + l_map RAW(32767); + l_status BINARY_INTEGER; + \(OracleDebugPayload.declarations) + BEGIN + l_program.Namespace := \(unit.kind.namespace); + l_program.Owner := \(OracleDebugUnit.literal(unit.owner)); + l_program.Name := \(OracleDebugUnit.literal(unit.name)); + l_status := SYS.DBMS_DEBUG.GET_LINE_MAP(l_program, l_max, l_entries, l_map); + put(TO_CHAR(l_status)); + put(TO_CHAR(l_max)); + put(RAWTOHEX(l_map)); + \(OracleDebugPayload.openCursor) + END; + """ + } + + public struct ValueRequest: Sendable, Equatable { + public let name: String + // Nil reads a package variable through its handle; otherwise DBMS_DEBUG's frame number. + public let frame: Int? + public let package: OracleDebugUnit? + + public init(name: String, frame: Int) { + self.name = name + self.frame = frame + self.package = nil + } + + public init(name: String, package: OracleDebugUnit) { + self.name = name + self.frame = nil + self.package = package + } + } + + // A name comes from the unit's source text, so request n's goes in as bind :n, never as SQL. + public static func values(_ requests: [ValueRequest]) -> (block: String, binds: [OracleBindValue]) { + let reads = requests.enumerated().map { index, request -> String in + let name = ":\(index + 1)" + if let package = request.package { + return """ + l_handle.Namespace := \(package.kind.namespace); + l_handle.Owner := \(OracleDebugUnit.literal(package.owner)); + l_handle.Name := \(OracleDebugUnit.literal(package.name)); + l_value := NULL; + l_status := SYS.DBMS_DEBUG.GET_VALUE(\(name), l_handle, l_value, NULL); + put(TO_CHAR(l_status)); + put(l_value); + """ + } + return """ + l_value := NULL; + l_status := SYS.DBMS_DEBUG.GET_VALUE(\(name), \(request.frame ?? 0), l_value, NULL); + put(TO_CHAR(l_status)); + put(l_value); + """ + } + let block = """ + DECLARE + l_handle SYS.DBMS_DEBUG.PROGRAM_INFO; + l_value VARCHAR2(32767); + l_status BINARY_INTEGER; + \(OracleDebugPayload.declarations) + BEGIN + \(reads.joined(separator: "\n")) + \(OracleDebugPayload.openCursor) + END; + """ + return (block, requests.map { .text($0.name) }) + } + + // MARK: - Answers + + public struct Frame: Sendable, Equatable { + public let owner: String? + public let name: String? + public let namespace: Int? + public let libunitType: Int? + public let line: Int? + public let entryPoint: String? + + public init(owner: String?, name: String?, namespace: Int?, libunitType: Int?, line: Int?, entryPoint: String?) { + self.owner = owner + self.name = name + self.namespace = namespace + self.libunitType = libunitType + self.line = line + self.entryPoint = entryPoint + } + + // The anonymous block that called the entry point has namespace 0 and no name. + public var unit: OracleDebugUnit? { + guard let owner, let name, !name.isEmpty, let namespace, + let kind = OracleDebugUnitKind(namespace: namespace, libunitType: libunitType ?? -1) + else { return nil } + return OracleDebugUnit(owner: owner, name: name, kind: kind) + } + } + + public struct Event: Sendable, Equatable { + public let status: Int + public let reason: Int? + public let terminated: Bool + public let line: Int? + public let breakpoint: Int? + // Oldest first, as PRINT_BACKTRACE numbers them: GET_VALUE's frame number is this index plus one. + public let backtrace: [Frame] + + public var timedOut: Bool { status == Status.timeout } + public var isPause: Bool { status == Status.success && !terminated } + } + + public static func event(fromPayload fields: [String?]) throws -> Event { + var reader = OracleDebugPayloadReader(fields) + let status = try reader.int() ?? -1 + let reason = try reader.int() + let terminated = (try reader.int() ?? 0) != 0 + let line = try reader.int() + let breakpoint = try reader.int() + let count = try reader.int() ?? 0 + var frames: [Frame] = [] + for _ in 0.. (status: Int, number: Int?) { + var reader = OracleDebugPayloadReader(fields) + return (try reader.int() ?? -1, try reader.int()) + } + + public static func valueAnswers(fromPayload fields: [String?], count: Int) throws -> [(status: Int, value: String?)] { + var reader = OracleDebugPayloadReader(fields) + var answers: [(status: Int, value: String?)] = [] + for _ in 0.. OracleDebugValue? { + switch status { + case Status.success: return .scalar(text) + case Status.nullValue, Status.nullCollection: return .scalar(nil) + case Status.indexedTable: return .collection(count: nil) + default: return nil + } + } + + // Line N is executable when bit N mod 8 of byte N / 8 is set (the package's own description). + public static func executableLines(fromPayload fields: [String?]) throws -> [Int]? { + var reader = OracleDebugPayloadReader(fields) + guard try reader.int() == Status.success else { return nil } + let maxLine = try reader.int() ?? 0 + let hex = try reader.next() ?? "" + return executableLines(hexMap: hex, maxLine: maxLine) + } + + public static func executableLines(hexMap hex: String, maxLine: Int) -> [Int] { + var bytes: [UInt8] = [] + var index = hex.startIndex + while index < hex.endIndex, let next = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) { + if let byte = UInt8(hex[index..= 1 else { return [] } + var lines: [Int] = [] + for line in 1...last where bytes[line / 8] & (1 << UInt8(line % 8)) != 0 { + lines.append(line) + } + return lines + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugBreakpointHolds.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugBreakpointHolds.swift new file mode 100644 index 0000000000..23d8da4376 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugBreakpointHolds.swift @@ -0,0 +1,33 @@ +import Foundation + +// The lines a caller removes each breakpoint by, keyed by the line it landed on. One sent at start is held by the line +// it asked for, since its caller never learns where it landed; one set later, by the line `setBreakpoint` answered. +struct OracleDebugBreakpointHolds { + private var holders: [OracleDebugUnit: [Int: Set]] = [:] + + mutating func hold(_ landed: Int, in unit: OracleDebugUnit, by line: Int) { + holders[unit, default: [:]][landed, default: []].insert(line) + } + + // A pending JDWP breakpoint lands again once its unit loads, possibly on a later line. + mutating func move(_ landed: Int, to line: Int, in unit: OracleDebugUnit) { + guard line != landed, let held = holders[unit]?.removeValue(forKey: landed) else { return } + holders[unit, default: [:]][line, default: []].formUnion(held) + } + + mutating func drop(_ landed: Int, in unit: OracleDebugUnit) { + holders[unit]?[landed] = nil + } + + // The landed line of the breakpoint `line` names, once no other line holds it; nil while one still does. + mutating func release(_ line: Int, in unit: OracleDebugUnit) -> Int? { + guard let landed = holders[unit]?.first(where: { $0.value.contains(line) })?.key else { + drop(line, in: unit) + return line + } + holders[unit]?[landed]?.remove(line) + guard holders[unit]?[landed]?.isEmpty == true else { return nil } + drop(landed, in: unit) + return landed + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugChannel.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugChannel.swift new file mode 100644 index 0000000000..b0c4cd6f20 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugChannel.swift @@ -0,0 +1,16 @@ +import Foundation + +// A database session a debug engine sends its statements on: the target, the DBMS_DEBUG supervisor or the JDWP helper. +protocol OracleDebugChannel: AnyObject, Sendable { + func executeDirect(_ sql: String, binds: [OracleBindValue], autoCommit: Bool, readsRows: Bool) async throws -> [String?] + func executeQuery(_ query: String) async throws -> OracleRawResult + func cancelCurrentQuery() +} + +extension OracleDebugChannel { + func executeDirect(_ sql: String, binds: [OracleBindValue] = [], readsRows: Bool = false) async throws -> [String?] { + try await executeDirect(sql, binds: binds, autoCommit: false, readsRows: readsRows) + } +} + +extension OracleCoreConnection: OracleDebugChannel {} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugLauncher.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugLauncher.swift new file mode 100644 index 0000000000..58f242c9eb --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugLauncher.swift @@ -0,0 +1,263 @@ +import Foundation +import os +import OSLog + +private let logger = Logger(subsystem: "com.TablePro", category: "OracleDebugLauncher") + +public struct OracleDebugLaunch: Sendable { + public let entryPoint: OracleDebugEntryPoint + public let arguments: [String: OracleDebugArgumentValue] + public let breakpoints: [OracleDebugBreakpointRequest] + public let preference: OracleDebugProtocolPreference + public let debuggerHost: String + public let ports: ClosedRange + // The host the sessions dial, and any other name the server may connect back from (the server behind a tunnel). + public let databaseHost: String + public let databasePort: Int + public let otherDatabaseHosts: [String] + // Every overload of the entry point's subprogram, to refuse a call Oracle could not resolve. + public let overloads: [OracleDebugEntryPoint] + // How long the database gets to connect back before JDWP counts as unreachable. + public let connectTimeout: Duration + + public init( + entryPoint: OracleDebugEntryPoint, + arguments: [String: OracleDebugArgumentValue], + breakpoints: [OracleDebugBreakpointRequest], + preference: OracleDebugProtocolPreference, + debuggerHost: String, + ports: ClosedRange, + databaseHost: String, + databasePort: Int, + otherDatabaseHosts: [String] = [], + overloads: [OracleDebugEntryPoint] = [], + connectTimeout: Duration = .seconds(15) + ) { + self.entryPoint = entryPoint + self.arguments = arguments + self.breakpoints = breakpoints + self.preference = preference + self.debuggerHost = debuggerHost + self.ports = ports + self.databaseHost = databaseHost + self.databasePort = databasePort + self.otherDatabaseHosts = otherDatabaseHosts + self.overloads = overloads + self.connectTimeout = connectTimeout + } +} + +public struct OracleDebugBreakpointRequest: Sendable, Hashable { + public let unit: OracleDebugUnit + public let line: Int + + public init(unit: OracleDebugUnit, line: Int) { + self.unit = unit + self.line = line + } +} + +public enum OracleDebugLauncher { + // Two sessions either way: JDWP's target plus one that reads source, DBMS_DEBUG's target plus its supervisor. + // Cancelling the calling task ends whatever target exists, as Stop would, and throws CancellationError. + public static func launch( + _ launch: OracleDebugLaunch, + openSession: @escaping @Sendable () async throws -> OracleCoreConnection + ) async throws -> any OracleDebugEngine { + try Task.checkCancellation() + if OracleDebugProtocolDecision.initial(preference: launch.preference, host: launch.debuggerHost) == .fail { + throw OracleDebugError.jdwp(.noDebuggerHost) + } + switch OracleDebugStatements.ambiguity(of: launch.entryPoint, arguments: launch.arguments, overloads: launch.overloads) { + case .needsValue(let parameters): + throw OracleDebugError.ambiguousDefaults(subprogram: launch.entryPoint.entry.subprogram, parameters: parameters) + case .unresolvable: + throw OracleDebugError.ambiguousCall(subprogram: launch.entryPoint.entry.subprogram) + case nil: + break + } + let invocation: OracleDebugStatements.Invocation + do { + invocation = try OracleDebugStatements.invocation(of: launch.entryPoint, arguments: launch.arguments) + } catch OracleDebugStatements.InvocationError.unsupportedArgument(let name, let typeName) { + throw OracleDebugError.unsupportedArgument(name: name, typeName: typeName) + } catch OracleDebugStatements.InvocationError.undeclarableArgument(let name, let typeName) { + throw OracleDebugError.undeclarableArgument(name: name, typeName: typeName) + } catch OracleDebugStatements.InvocationError.undeclarableResult(let typeName) { + throw OracleDebugError.undeclarableArgument(name: launch.entryPoint.entry.subprogram, typeName: typeName) + } + let helper = try await reserved(openSession) + var target = try await reserved(openSession) + let breakpoints = launch.breakpoints.map { (unit: $0.unit, line: $0.line) } + var notices: [OracleDebugNotice] = [] + + switch OracleDebugProtocolDecision.initial(preference: launch.preference, host: launch.debuggerHost) { + case .dbmsDebug(let reason): + notices.append(.usingDBMSDebug(reason: reason)) + default: + do { + let session = try await startJDWP(launch, invocation: invocation, target: target, helper: helper, breakpoints: breakpoints) + return try await keep(session) + } catch let failure as JDWPStartFailure { + switch OracleDebugProtocolDecision.afterJDWPFailure(failure.reason, preference: launch.preference) { + case .fallBack: + logger.notice("JDWP did not connect; falling back to DBMS_DEBUG") + notices.append(.fellBack(failure.reason, host: launch.debuggerHost, port: Int(failure.port ?? 0))) + try Task.checkCancellation() + if failure.endedTarget || !target.isConnected { + target = try await reserved(openSession) + } + default: + throw OracleDebugError.jdwp(failure.reason) + } + } + } + + let session = OracleDBMSDebugSession( + target: target, + supervisor: helper, + entryPoint: launch.entryPoint, + invocation: invocation, + notices: notices + ) + do { + try await session.start(breakpoints: breakpoints) + } catch { + if Task.isCancelled { + _ = await session.stop() + throw CancellationError() + } + throw mapped(error) + } + return try await keep(session) + } + + // A start the caller cancelled hands back no session, so the target ends here. + private static func keep(_ engine: any OracleDebugEngine) async throws -> any OracleDebugEngine { + guard Task.isCancelled else { return engine } + _ = await engine.stop() + throw CancellationError() + } + + private static func reserved(_ openSession: @Sendable () async throws -> OracleCoreConnection) async throws -> OracleCoreConnection { + let connection = try await openSession() + connection.reserveForDebugging() + return connection + } + + struct JDWPStartFailure: Error { + let reason: OracleJDWPFailure + let port: UInt16? + var endedTarget = false + } + + // The addresses a connection back may come from: the server's own, and for a tunnel this Mac's loopback, where a + // remote forward the person set up arrives. + static func allowedPeers(for launch: OracleDebugLaunch) async -> Set { + var allowed: Set = [] + for host in [launch.databaseHost] + launch.otherDatabaseHosts { + allowed.formUnion(await OracleDebugNetwork.resolvedAddresses(of: host)) + } + return allowed + } + + private static func startJDWP( + _ launch: OracleDebugLaunch, + invocation: OracleDebugStatements.Invocation, + target: OracleCoreConnection, + helper: OracleCoreConnection, + breakpoints: [(unit: OracleDebugUnit, line: Int)] + ) async throws -> OracleJDWPDebugSession { + let allowed = await allowedPeers(for: launch) + let bindAddresses = await OracleDebugNetwork.bindAddresses( + debuggerHost: launch.debuggerHost, + databaseHost: launch.databaseHost, + databasePort: launch.databasePort + ) + let listener: OracleJDWPListener + do { + listener = try await OracleJDWPListener.open(ports: launch.ports, bindAddresses: bindAddresses, allowedPeers: allowed) + } catch { + throw JDWPStartFailure(reason: .noFreePort, port: nil) + } + let port = listener.port + let identity = await OracleDebugSupport.sessionIdentity(of: target) + let connectCall = Task { () -> Result<[String?], Error> in + do { + return .success(try await target.executeDirect( + OracleDebugStatements.jdwpConnect, + binds: [.text(launch.debuggerHost), .text(String(port))] + )) + } catch { + return .failure(error) + } + } + // A refused CONNECT_TCP ends the wait at once; one that hangs on an unreachable host is cut at the timeout. + let watcher = Task { + if case .failure = await connectCall.value { listener.cancel() } + } + defer { watcher.cancel() } + let connection: OracleJDWPConnection + do { + connection = try await listener.accept(timeout: launch.connectTimeout) + } catch { + listener.cancel() + let rejected = listener.rejectedPeers + if let result = await OracleDebugSupport.value(of: connectCall, within: 1), case .failure(let error) = result { + if Task.isCancelled { throw CancellationError() } + let reason = rejected.isEmpty + ? OracleDebugStatements.jdwpFailure(fromMessage: message(of: error)) + : .unexpectedPeer(rejected) + throw JDWPStartFailure(reason: reason, port: port) + } + // The statement is still waiting on the network; closing the channel is the only way to end it. + target.cancelCurrentQuery() + _ = await OracleDebugSupport.value(of: connectCall, within: 5) + if Task.isCancelled { throw CancellationError() } + throw JDWPStartFailure(reason: rejected.isEmpty ? .timedOut : .unexpectedPeer(rejected), port: port, endedTarget: true) + } + let session = OracleJDWPDebugSession( + connection: connection, + target: target, + helper: helper, + entryPoint: launch.entryPoint, + invocation: invocation, + connectCall: connectCall, + targetIdentity: identity, + notices: [.connectedOverJDWP(host: launch.debuggerHost, port: Int(port))] + ) + do { + try await session.start(breakpoints: breakpoints) + } catch { + // Closing the JDWP socket ends the target session. + _ = await session.stop() + if Task.isCancelled { throw CancellationError() } + if let failure = error as? OracleDebugError { + switch failure { + case .jdwp(let reason): + throw JDWPStartFailure(reason: reason, port: port, endedTarget: true) + case .malformedReply, .debuggerStoppedAnswering, .ended: + throw JDWPStartFailure(reason: .handshakeFailed, port: port, endedTarget: true) + default: + break + } + } + throw JDWPStartFailure(reason: .other(message(of: error)), port: port, endedTarget: true) + } + return session + } + + static func message(of error: Error) -> String { + OracleDebugSupport.message(of: error) + } + + // ORA-01031 from INITIALIZE or CONNECT_TCP is the missing DEBUG CONNECT SESSION (measured). + static func mapped(_ error: Error) -> Error { + guard let core = error as? OracleCoreError, case .queryFailed(let message) = core else { return error } + let codes = OracleDebugStatements.errorCodes(in: message) + if codes.first == 1_031 { + return OracleDebugError.privilegeMissing(message) + } + return OracleDebugError.server(code: codes.first, message: message) + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugNetwork.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugNetwork.swift new file mode 100644 index 0000000000..12cccb8cd6 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugNetwork.swift @@ -0,0 +1,134 @@ +import Darwin +import Foundation + +public enum OracleDebugNetwork { + // The address this Mac sends from to reach the database: a connected UDP socket picks the route and sends nothing. + // It is where a server on the same network can reach back; a NAT, VPN or container can still need another. + public static func localAddress(towardHost host: String, port: Int) async -> String? { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: resolveLocalAddress(towardHost: host, port: port)) + } + } + } + + // One spelling per address: no IPv6 zone, an IPv4-mapped IPv6 address as IPv4, lowercase hex. + public static func normalized(_ address: String) -> String { + var text = address.lowercased() + if let zone = text.firstIndex(of: "%") { text = String(text[.. Set { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: resolve(host)) + } + } + } + + static func resolve(_ host: String) -> Set { + var hints = addrinfo() + hints.ai_socktype = SOCK_STREAM + hints.ai_family = AF_UNSPEC + var list: UnsafeMutablePointer? + guard !host.isEmpty, getaddrinfo(host, nil, &hints, &list) == 0, let list else { return [] } + defer { freeaddrinfo(list) } + var addresses: Set = [] + var entry: UnsafeMutablePointer? = list + while let current = entry { + if let address = numericHost(current.pointee.ai_addr, length: current.pointee.ai_addrlen) { + addresses.insert(normalized(address)) + } + entry = current.pointee.ai_next + } + return addresses + } + + public static func localInterfaceAddresses() -> Set { + var list: UnsafeMutablePointer? + guard getifaddrs(&list) == 0, let list else { return [] } + defer { freeifaddrs(list) } + var addresses: Set = [] + var entry: UnsafeMutablePointer? = list + while let current = entry { + if let address = current.pointee.ifa_addr { + let family = Int32(address.pointee.sa_family) + if family == AF_INET || family == AF_INET6, + let text = numericHost(address, length: socklen_t(address.pointee.sa_len)) { + addresses.insert(normalized(text)) + } + } + entry = current.pointee.ifa_next + } + return addresses + } + + // The local addresses the listener binds: the Debugger Host when it names this Mac, else the address this Mac + // reaches the database from, which is where a server on that network connects back to. A loopback route binds + // 127.0.0.1: `localhost` resolves to ::1 first, but Docker Desktop delivers a container's connection on + // 127.0.0.1 (measured), and Network.framework refuses ::1 on a port 127.0.0.1 holds (EADDRINUSE, measured), so + // the two cannot share the one port CONNECT_TCP is given. Nil listens everywhere. + public static func bindAddresses(debuggerHost: String, databaseHost: String, databasePort: Int) async -> [String]? { + let local = localInterfaceAddresses() + let named = await resolvedAddresses(of: debuggerHost).filter { local.contains($0) } + if named.contains("127.0.0.1") { + return ["127.0.0.1"] + } + if let match = named.min() { + return [match] + } + guard let route = await localAddress(towardHost: databaseHost, port: databasePort).map(normalized) else { return nil } + return isLoopback(route) ? ["127.0.0.1"] : [route] + } + + public static func isLoopback(_ address: String) -> Bool { + let text = normalized(address) + return text == "::1" || text.hasPrefix("127.") + } + + static func numericHost(_ address: UnsafePointer, length: socklen_t) -> String? { + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + guard getnameinfo(address, length, &buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) == 0 else { return nil } + let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + return String(bytes: bytes, encoding: .utf8) + } + + static func resolveLocalAddress(towardHost host: String, port: Int) -> String? { + var hints = addrinfo() + hints.ai_socktype = SOCK_DGRAM + hints.ai_family = AF_UNSPEC + var list: UnsafeMutablePointer? + guard getaddrinfo(host, String(port), &hints, &list) == 0, let list else { return nil } + defer { freeaddrinfo(list) } + var entry: UnsafeMutablePointer? = list + while let current = entry { + defer { entry = current.pointee.ai_next } + let socketDescriptor = socket(current.pointee.ai_family, SOCK_DGRAM, IPPROTO_UDP) + guard socketDescriptor >= 0 else { continue } + defer { close(socketDescriptor) } + guard connect(socketDescriptor, current.pointee.ai_addr, current.pointee.ai_addrlen) == 0 else { continue } + var storage = sockaddr_storage() + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &storage) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(socketDescriptor, $0, &length) } + } + guard named == 0 else { continue } + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let printed = withUnsafePointer(to: &storage) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getnameinfo($0, length, &buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) + } + } + guard printed == 0 else { continue } + let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + guard let address = String(bytes: bytes, encoding: .utf8) else { continue } + // An IPv6 link-local address carries its interface after `%`, which the server has no use for. + return address.split(separator: "%").first.map(String.init) ?? address + } + return nil + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugPayload.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugPayload.swift new file mode 100644 index 0000000000..ab03ec7437 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugPayload.swift @@ -0,0 +1,108 @@ +import Foundation + +// What a debug block hands back: fields written as `:`, or `~` for NULL, read through a cursor of +// pieces. Out binds cap a VARCHAR2 at 4000 bytes, and a SQL VARCHAR2 over 4000 bytes fails the fetch with ORA-00910 +// (OracleServerOutput), so the block gathers the fields into a CLOB and the cursor reads it 990 characters at a time. +public enum OracleDebugPayload { + public static let cursorBindName = "rows" + static let pieceLength = 990 + // A field longer than this is cut, so `length:text` stays inside one VARCHAR2(32767) at four bytes a character. + public static let fieldLimit = 8_000 + + public enum ParseError: Error, Sendable, Equatable { + case malformed(offset: Int) + } + + public static let declarations = """ + l_out CLOB; + l_chunk VARCHAR2(32767); + PROCEDURE put(p VARCHAR2) IS + l_piece VARCHAR2(32767); + BEGIN + IF p IS NULL THEN + l_piece := '~'; + ELSE + l_piece := LENGTH(SUBSTR(p, 1, \(fieldLimit))) || ':' || SUBSTR(p, 1, \(fieldLimit)); + END IF; + IF NVL(LENGTHB(l_chunk), 0) + LENGTHB(l_piece) > 32000 THEN + l_out := l_out || l_chunk; + l_chunk := NULL; + END IF; + l_chunk := l_chunk || l_piece; + END; + """ + + public static let openCursor = """ + IF l_chunk IS NOT NULL THEN + l_out := l_out || l_chunk; + END IF; + OPEN :\(cursorBindName) FOR + SELECT TO_CHAR(SUBSTR(l_out, (LEVEL - 1) * \(pieceLength) + 1, \(pieceLength))) + FROM SYS.DUAL + CONNECT BY LEVEL <= GREATEST(CEIL(NVL(LENGTH(l_out), 0) / \(pieceLength)), 1); + """ + + public static func join(pieces: [String?]) -> String { + pieces.map { $0 ?? "" }.joined() + } + + // Lengths count characters as Oracle's LENGTH does in AL32UTF8, one per Unicode scalar. + public static func parse(_ text: String) throws -> [String?] { + let scalars = Array(text.unicodeScalars) + var fields: [String?] = [] + var position = 0 + while position < scalars.count { + if scalars[position] == "~" { + fields.append(nil) + position += 1 + continue + } + var length = 0 + var digits = 0 + // A field never exceeds `fieldLimit`, so more than nine digits is not a length this side wrote. + while position < scalars.count, (48...57).contains(scalars[position].value) { + guard digits < 9 else { throw ParseError.malformed(offset: position) } + length = length * 10 + Int(scalars[position].value - 48) + digits += 1 + position += 1 + } + guard digits > 0, position < scalars.count, scalars[position] == ":" else { + throw ParseError.malformed(offset: position) + } + position += 1 + guard length <= scalars.count - position else { throw ParseError.malformed(offset: position) } + var field = String.UnicodeScalarView() + field.append(contentsOf: scalars[position.. String { + fields.map { field in + guard let field else { return "~" } + return "\(field.unicodeScalars.count):\(field)" + }.joined() + } +} + +struct OracleDebugPayloadReader { + private var fields: ArraySlice + + init(_ fields: [String?]) { + self.fields = fields[...] + } + + var isAtEnd: Bool { fields.isEmpty } + + mutating func next() throws -> String? { + guard let field = fields.popFirst() else { throw OracleDebugError.unreadableAnswer } + return field + } + + mutating func int() throws -> Int? { + try next().flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStatements.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStatements.swift new file mode 100644 index 0000000000..3daf338180 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStatements.swift @@ -0,0 +1,665 @@ +import Foundation + +public enum OracleDebugStatements { + // MARK: - Catalog + + public static func targets(owner: String) -> String { + """ + SELECT OBJECT_NAME, OBJECT_TYPE + FROM SYS.ALL_OBJECTS + WHERE OWNER = \(OracleDebugUnit.literal(owner)) + AND OBJECT_TYPE IN ('PROCEDURE', 'FUNCTION', 'PACKAGE BODY') + ORDER BY OBJECT_TYPE, OBJECT_NAME + """ + } + + // Debug information exists when the unit was compiled with PLSQL_DEBUG or at PLSQL_OPTIMIZE_LEVEL 1 or lower. + // At level 2 SET_BREAKPOINT still answers success and never fires (measured). + public static func readiness(of unit: OracleDebugUnit) -> String { + """ + SELECT PLSQL_OPTIMIZE_LEVEL, PLSQL_DEBUG + FROM SYS.ALL_PLSQL_OBJECT_SETTINGS + WHERE OWNER = \(OracleDebugUnit.literal(unit.owner)) + AND NAME = \(OracleDebugUnit.literal(unit.name)) + AND TYPE = \(OracleDebugUnit.literal(unit.codeUnit.kind.rawValue)) + """ + } + + public enum Readiness: Sendable, Equatable { + case ready + case needsDebugCompile + case notVisible + } + + public static func readiness(fromRow row: [String?]?) -> Readiness { + guard let row else { return .notVisible } + let level = row.first.flatMap { $0 }.flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } + let debug = (row.count > 1 ? row[1] : nil)?.uppercased() == "TRUE" + if debug { return .ready } + if let level, level <= 1 { return .ready } + return .needsDebugCompile + } + + public static func privileges(of unit: OracleDebugUnit) -> String { + """ + SELECT SYS_CONTEXT('USERENV', 'SESSION_USER'), + (SELECT COUNT(*) FROM SYS.SESSION_PRIVS WHERE PRIVILEGE = 'DEBUG CONNECT SESSION'), + (SELECT COUNT(*) FROM SYS.SESSION_PRIVS WHERE PRIVILEGE = 'DEBUG ANY PROCEDURE'), + (SELECT COUNT(*) FROM SYS.ALL_TAB_PRIVS + WHERE TABLE_SCHEMA = \(OracleDebugUnit.literal(unit.owner)) + AND TABLE_NAME = \(OracleDebugUnit.literal(unit.name)) + AND PRIVILEGE = 'DEBUG') + FROM SYS.DUAL + """ + } + + public enum MissingPrivilege: Sendable, Equatable { + case debugConnectSession(user: String) + case debugOnObject(user: String) + } + + public static func missingPrivilege(fromRow row: [String?], unit: OracleDebugUnit) -> MissingPrivilege? { + func count(_ index: Int) -> Int { + (row.count > index ? row[index] : nil).flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } ?? 0 + } + let user = (row.first ?? nil) ?? "" + if count(1) == 0 { return .debugConnectSession(user: user) } + if unit.owner != user, count(2) == 0, count(3) == 0 { return .debugOnObject(user: user) } + return nil + } + + // One row per public subprogram and argument; a subprogram without arguments comes back once with NULLs. + public static func entryPoints(of unit: OracleDebugUnit) -> String { + let owner = OracleDebugUnit.literal(unit.owner) + let name = OracleDebugUnit.literal(unit.name) + let subprograms: String + if unit.kind.hasSubprograms { + subprograms = "p.OBJECT_TYPE = 'PACKAGE' AND p.PROCEDURE_NAME IS NOT NULL" + } else { + subprograms = "p.OBJECT_TYPE IN ('PROCEDURE', 'FUNCTION') AND p.PROCEDURE_NAME IS NULL" + } + return """ + SELECT NVL(p.PROCEDURE_NAME, p.OBJECT_NAME), p.SUBPROGRAM_ID, p.OVERLOAD, + a.POSITION, a.ARGUMENT_NAME, a.DATA_TYPE, a.IN_OUT, a.DEFAULTED, + a.TYPE_OWNER, a.TYPE_NAME, a.TYPE_SUBNAME, a.PLS_TYPE + FROM SYS.ALL_PROCEDURES p + LEFT JOIN SYS.ALL_ARGUMENTS a + ON a.OBJECT_ID = p.OBJECT_ID AND a.SUBPROGRAM_ID = p.SUBPROGRAM_ID AND a.DATA_LEVEL = 0 + WHERE p.OWNER = \(owner) AND p.OBJECT_NAME = \(name) AND \(subprograms) + ORDER BY p.SUBPROGRAM_ID, a.SEQUENCE + """ + } + + public static func parseEntryPoints(rows: [[String?]], unit: OracleDebugUnit) -> [OracleDebugEntryPoint] { + var order: [Int] = [] + var byID: [Int: (name: String, overload: String?, parameters: [OracleDebugParameter], result: OracleDebugParameter?)] = [:] + for row in rows { + func cell(_ index: Int) -> String? { row.count > index ? row[index] : nil } + guard let name = cell(0), let subprogramID = cell(1).flatMap({ Int($0.trimmingCharacters(in: .whitespaces)) }) + else { continue } + if byID[subprogramID] == nil { + order.append(subprogramID) + byID[subprogramID] = (name, cell(2), [], nil) + } + guard let dataType = cell(5), !dataType.isEmpty else { continue } + let parameter = OracleDebugParameter( + name: cell(4) ?? "", + position: cell(3).flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } ?? 0, + dataType: dataType, + mode: OracleDebugParameter.Mode(inOut: cell(6)), + hasDefault: cell(7)?.uppercased() == "Y", + typeOwner: cell(8), + typeName: cell(9), + typeSubname: cell(10), + plsType: cell(11) + ) + if parameter.position == 0, cell(4) == nil { + byID[subprogramID]?.result = parameter + } else { + byID[subprogramID]?.parameters.append(parameter) + } + } + return order.compactMap { id in + guard let entry = byID[id] else { return nil } + return OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: unit.codeUnit, subprogram: entry.name, subprogramID: id), + overload: entry.overload, + parameters: entry.parameters, + result: entry.result + ) + } + } + + public static func source(of unit: OracleDebugUnit) -> String { + """ + SELECT LINE, TEXT + FROM SYS.ALL_SOURCE + WHERE OWNER = \(OracleDebugUnit.literal(unit.owner)) + AND NAME = \(OracleDebugUnit.literal(unit.name)) + AND TYPE = \(OracleDebugUnit.literal(unit.kind.rawValue)) + ORDER BY LINE + """ + } + + // Lines stay where ALL_SOURCE numbers them, because both debug interfaces report those numbers. + public static func assembleSource(rows: [(line: Int, text: String?)]) -> String { + var lines: [String] = [] + for row in rows.sorted(by: { $0.line < $1.line }) where row.line >= 1 { + while lines.count < row.line - 1 { + lines.append("") + } + var text = row.text ?? "" + // "\r\n" is one Character, so dropping the last Character removes either ending whole. + if text.last?.isNewline == true { + text.removeLast() + } + if lines.count == row.line - 1 { + lines.append(text) + } else { + lines[row.line - 1] += text + } + } + return lines.joined(separator: "\n") + } + + // MARK: - JDWP + + // Through EXECUTE IMMEDIATE of a CALL, so `SYS` resolves at SQL level and a package named SYS in the session's + // schema cannot capture it (OracleServerOutput explains the measurement). + public static let jdwpConnect = """ + BEGIN + EXECUTE IMMEDIATE 'CALL SYS.DBMS_DEBUG_JDWP.CONNECT_TCP(:1, :2)' USING :1, :2; + END; + """ + + // "4000-4999" or "4000": a range, as the DBA's ACE grants one. A list is refused rather than read as the range + // between its first two ports, which would pick ports the ACE does not cover. SQL Developer's default is 4000-4999. + public static func ports(from text: String) -> ClosedRange? { + let parts = text.split(separator: "-", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + guard (1...2).contains(parts.count) else { return nil } + let numbers = parts.compactMap { UInt16($0) } + guard numbers.count == parts.count, let low = numbers.first, low > 0 else { return nil } + let high = numbers.last ?? low + guard high >= low else { return nil } + return low...high + } + + public static let defaultPorts = "4000-4999" + + // MARK: - Errors + + // Every ORA- code in a server message, in order; the first is the error, the rest its stack. + public static func errorCodes(in message: String) -> [Int] { + var codes: [Int] = [] + var remainder = Substring(message) + while let range = remainder.range(of: "ORA-") { + let digits = remainder[range.upperBound...].prefix { $0.isNumber } + if let code = Int(digits) { codes.append(code) } + remainder = remainder[range.upperBound...] + } + return codes + } + + public static func jdwpFailure(fromMessage message: String) -> OracleJDWPFailure { + let codes = errorCodes(in: message) + let upper = message.uppercased() + if codes.contains(24_247) { return .accessControlList } + if codes.contains(1_031) { return .missingPrivilege } + if codes.contains(30_683) || codes.contains(30_698) || codes.contains(30_681) || codes.contains(30_682) { + return .unreachable(message) + } + // Autonomous AI Database lists the package as unavailable; the CALL then cannot resolve it. + if upper.contains("DBMS_DEBUG_JDWP"), codes.contains(where: { [6_576, 904, 6_550, 4_067, 6_508, 942].contains($0) }) { + return .packageUnavailable + } + if codes.contains(6_576) || codes.contains(904) { return .packageUnavailable } + return .other(message) + } + + // MARK: - Invocation + + public struct Invocation: Sendable, Equatable { + public let block: String + public let binds: [OracleBindValue] + } + + public enum InvocationError: Error, Sendable, Equatable { + // Text cannot become a value of this type; NULL or the default still can. + case unsupportedArgument(name: String, typeName: String) + // No local of this type can be declared, so not even NULL can be passed. + case undeclarableArgument(name: String, typeName: String) + case undeclarableResult(typeName: String) + } + + // Runs the entry point and reports, through the payload: `S` or `F`, the error text, the number of outputs, each + // output's name and value, then every DBMS_OUTPUT line. A failure is caught so the routine's output survives it; + // the caller rolls back. + public static func invocation( + of entryPoint: OracleDebugEntryPoint, + arguments: [String: OracleDebugArgumentValue], + maxOutputLines: Int = 10_000 + ) throws -> Invocation { + var declarations: [String] = [] + var prologue: [String] = [] + var callArguments: [String] = [] + var outputs: [(name: String, expression: String)] = [] + var binds: [OracleBindValue] = [] + + func bind(_ value: String) -> String { + binds.append(.text(value)) + return ":\(binds.count)" + } + + for (index, parameter) in entryPoint.parameters.enumerated() { + let value = arguments[parameter.name] ?? (parameter.hasDefault ? .defaultValue : .null) + if parameter.mode == .input, value == .defaultValue, parameter.hasDefault { + continue + } + // Every argument is a local of the parameter's own type, NULL included: Oracle picks an overload by the + // types it is given, and a bare NULL or a text bind matches more than one. + guard let declaration = parameter.declaration else { + throw InvocationError.undeclarableArgument(name: parameter.name, typeName: parameter.displayType) + } + let local = "l_arg\(index + 1)" + declarations.append(" \(local) \(declaration);") + if parameter.mode != .output, case .text(let text) = value { + guard let expression = parameter.inputExpression(bindReference: bind(text)) else { + throw InvocationError.unsupportedArgument(name: parameter.name, typeName: parameter.displayType) + } + prologue.append(" \(local) := \(expression);") + } + callArguments.append("\(OracleDebugUnit.quote(parameter.name)) => \(local)") + if parameter.mode != .input, let text = parameter.textExpression(of: local) { + outputs.append((parameter.name, text)) + } + } + + var target = OracleDebugUnit.quote(entryPoint.entry.unit.owner) + if entryPoint.entry.isPackaged { + target += "." + OracleDebugUnit.quote(entryPoint.entry.unit.name) + } + target += "." + OracleDebugUnit.quote(entryPoint.entry.subprogram) + let call = callArguments.isEmpty ? target : "\(target)(\(callArguments.joined(separator: ", ")))" + + var statement = "\(call);" + if let result = entryPoint.result { + guard let declaration = result.declaration else { + throw InvocationError.undeclarableResult(typeName: result.displayType) + } + declarations.append(" l_result \(declaration);") + statement = "l_result := \(call);" + if let text = result.textExpression(of: "l_result") { + outputs.insert((entryPoint.entry.subprogram, text), at: 0) + } + } + + let outputLines = outputs.map { " put(\(OracleDebugUnit.literal($0.name)));\n put(\($0.expression));" } + let block = """ + DECLARE + l_failed BOOLEAN := FALSE; + l_error VARCHAR2(32767); + l_line VARCHAR2(32767); + l_status INTEGER; + l_lines PLS_INTEGER := 0; + \(declarations.joined(separator: "\n")) + \(OracleDebugPayload.declarations) + BEGIN + \(prologue.joined(separator: "\n")) + BEGIN + \(statement) + EXCEPTION + WHEN OTHERS THEN + l_failed := TRUE; + l_error := SQLERRM; + END; + IF l_failed THEN + put('F'); + put(l_error); + put('0'); + ELSE + put('S'); + put(NULL); + put('\(outputs.count)'); + \(outputLines.joined(separator: "\n")) + END IF; + LOOP + EXIT WHEN l_lines >= \(maxOutputLines); + EXECUTE IMMEDIATE 'CALL SYS.DBMS_OUTPUT.GET_LINE(:1, :2)' USING OUT l_line, OUT l_status; + EXIT WHEN l_status <> 0; + put(l_line); + l_lines := l_lines + 1; + END LOOP; + \(OracleDebugPayload.openCursor) + END; + """ + return Invocation(block: block, binds: binds) + } + + public enum Ambiguity: Sendable, Equatable { + // Leaving these parameters at their defaults leaves nothing to tell this overload from another. + case needsValue([String]) + // The call matches another overload whatever values are given (PLS-00307). + case unresolvable + } + + // Oracle picks an overload by the named arguments it is given, so leaving out defaulted ones can leave two + // overloads that both fit, and the call fails with PLS-00307 (`p(x NUMBER DEFAULT 1)` and + // `p(x VARCHAR2 DEFAULT 'a')`, both started with the default). ALL_ARGUMENTS cannot give the default's value to + // pass instead, so the person has to give one. + public static func ambiguity( + of entryPoint: OracleDebugEntryPoint, + arguments: [String: OracleDebugArgumentValue], + overloads: [OracleDebugEntryPoint] + ) -> Ambiguity? { + func value(of parameter: OracleDebugParameter) -> OracleDebugArgumentValue { + arguments[parameter.name] ?? (parameter.hasDefault ? .defaultValue : .null) + } + let omitted = entryPoint.parameters.filter { $0.mode == .input && $0.hasDefault && value(of: $0) == .defaultValue } + let passed = entryPoint.parameters.filter { parameter in !omitted.contains { $0.name == parameter.name } } + for other in overloads where other.entry.unit == entryPoint.entry.unit + && other.entry.subprogram == entryPoint.entry.subprogram + && other.entry.subprogramID != entryPoint.entry.subprogramID + && (other.result == nil) == (entryPoint.result == nil) { + let takesEveryPassed = passed.allSatisfy { parameter in + other.parameters.contains { $0.name == parameter.name && $0.declaration == parameter.declaration } + } + let needsNothingElse = other.parameters.allSatisfy { parameter in + passed.contains { $0.name == parameter.name } || (parameter.mode == .input && parameter.hasDefault) + } + guard takesEveryPassed, needsNothingElse else { continue } + let distinguishing = omitted.filter { parameter in + !other.parameters.contains { $0.name == parameter.name && $0.declaration == parameter.declaration } + } + return distinguishing.isEmpty ? .unresolvable : .needsValue(distinguishing.map(\.name)) + } + return nil + } + + public struct InvocationReport: Sendable, Equatable { + public let succeeded: Bool + public let error: String? + public let outputs: [(name: String, value: String?)] + public let lines: [String] + + public static func == (lhs: InvocationReport, rhs: InvocationReport) -> Bool { + lhs.succeeded == rhs.succeeded && lhs.error == rhs.error && lhs.lines == rhs.lines + && lhs.outputs.map(\.name) == rhs.outputs.map(\.name) + && lhs.outputs.map(\.value) == rhs.outputs.map(\.value) + } + } + + public static func invocationReport(fromPayload fields: [String?]) throws -> InvocationReport { + var reader = OracleDebugPayloadReader(fields) + let status = try reader.next() + let error = try reader.next() + let count = try reader.int() ?? 0 + var outputs: [(name: String, value: String?)] = [] + for _ in 0.. String? in + guard let part, !part.isEmpty else { return nil } + return OracleDebugUnit.quote(part) + }.joined(separator: ".") + } + + // An integer subtype with NOT NULL or a range cannot be declared bare; PLS_INTEGER holds every one of them. + private var numberDeclaration: String { + switch (plsType ?? dataType).uppercased() { + case "BINARY_FLOAT": return "BINARY_FLOAT" + case "BINARY_DOUBLE": return "BINARY_DOUBLE" + case "PLS_INTEGER", "BINARY_INTEGER", "PL/SQL PLS INTEGER", "PL/SQL BINARY INTEGER", "NATURAL", "NATURALN", + "POSITIVE", "POSITIVEN", "SIMPLE_INTEGER", "SIGNTYPE": + return "PLS_INTEGER" + default: return "NUMBER" + } + } + + // The text the person typed, converted explicitly to the parameter's type in the session's NLS formats. + func inputExpression(bindReference: String) -> String? { + guard let scalar else { return nil } + switch scalar { + case .text: return bindReference + case .nationalText: return "TO_NCHAR(\(bindReference))" + case .number: + switch numberDeclaration { + case "BINARY_FLOAT": return "TO_BINARY_FLOAT(\(bindReference))" + case "BINARY_DOUBLE": return "TO_BINARY_DOUBLE(\(bindReference))" + default: return "TO_NUMBER(\(bindReference))" + } + case .date: return "TO_DATE(\(bindReference))" + case .timestamp(let declaration): + return declaration.hasSuffix("WITH TIME ZONE") && !declaration.contains("LOCAL") + ? "TO_TIMESTAMP_TZ(\(bindReference))" + : "TO_TIMESTAMP(\(bindReference))" + case .interval(let declaration): + return declaration.hasPrefix("INTERVAL YEAR") ? "TO_YMINTERVAL(\(bindReference))" : "TO_DSINTERVAL(\(bindReference))" + case .raw: return "HEXTORAW(\(bindReference))" + case .characterLOB: return dataType.uppercased() == "NCLOB" ? "TO_NCLOB(\(bindReference))" : "TO_CLOB(\(bindReference))" + case .binaryLOB: return nil + case .rowID: return "CHARTOROWID(\(bindReference))" + case .bfile: return nil + case .boolean: + return "CASE UPPER(TRIM(\(bindReference))) WHEN 'TRUE' THEN TRUE WHEN 'FALSE' THEN FALSE " + + "WHEN '1' THEN TRUE WHEN '0' THEN FALSE END" + } + } + + func textExpression(of variable: String) -> String? { + guard let scalar else { return nil } + switch scalar { + case .text, .nationalText: return "TO_CHAR(\(variable))" + case .number, .date, .timestamp, .interval: return "TO_CHAR(\(variable))" + case .raw: return "RAWTOHEX(\(variable))" + case .characterLOB: return "TO_CHAR(SUBSTR(\(variable), 1, \(OracleDebugPayload.fieldLimit)))" + case .binaryLOB: return nil + case .rowID: return "ROWIDTOCHAR(\(variable))" + case .bfile: return nil + case .boolean: return "CASE WHEN \(variable) THEN 'TRUE' WHEN NOT \(variable) THEN 'FALSE' END" + } + } +} + +// Which interface to try first, and what to do when JDWP cannot connect: Oracle SQL Developer's model, where the +// database connects back over JDWP and DBMS_DEBUG covers networks that block it. +public enum OracleDebugProtocolPreference: String, Sendable, CaseIterable { + case automatic + case jdwp + case dbmsDebug + + public static let fieldID = "oracleDebugProtocol" + public static let hostFieldID = "oracleDebuggerHost" + public static let portsFieldID = "oracleDebuggerPorts" +} + +public enum OracleDebugProtocolDecision: Sendable, Equatable { + case jdwp + case dbmsDebug(OracleDebugProbeReason) + case fallBack + case fail + + public static func initial(preference: OracleDebugProtocolPreference, host: String) -> OracleDebugProtocolDecision { + switch preference { + case .dbmsDebug: + return .dbmsDebug(.chosen) + case .jdwp: + // Without a host CONNECT_TCP has nowhere to connect; say so before any session opens. + return host.trimmingCharacters(in: .whitespaces).isEmpty ? .fail : .jdwp + case .automatic: + return host.trimmingCharacters(in: .whitespaces).isEmpty ? .dbmsDebug(.noDebuggerHost) : .jdwp + } + } + + // DBMS_DEBUG needs the same DEBUG CONNECT SESSION, so a privilege failure is final; so is anything unrecognized. + public static func afterJDWPFailure( + _ failure: OracleJDWPFailure, + preference: OracleDebugProtocolPreference + ) -> OracleDebugProtocolDecision { + guard preference == .automatic else { return .fail } + switch failure { + case .accessControlList, .unreachable, .packageUnavailable, .timedOut, .noFreePort, .handshakeFailed, .unexpectedPeer: + return .fallBack + case .missingPrivilege, .noDebuggerHost, .other: + return .fail + } + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStopBudget.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStopBudget.swift new file mode 100644 index 0000000000..d973007ed7 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStopBudget.swift @@ -0,0 +1,37 @@ +import Foundation + +// `stop()` returns within 3 seconds: the host closes the session's transport 5 seconds after asking, and over a tunnel +// nothing reaches the server after that. +public enum OracleDebugStopBudget { + public static let total: Duration = .seconds(3) + // Kept back from the total for the rollback and detach that follow the target's end. + public static let cleanup: Duration = .milliseconds(500) + + public enum KillOutcome: Sendable, Equatable { + // ORA-00031 "session marked for kill": the server ends the session at its next check (measured within a second). + case marked + // ORA-01031 without ALTER SYSTEM, or no SERIAL# to name the session. + case refused + } + + // A DBMS_DEBUG target running between breakpoints reads nothing the supervisor sends, so the kill goes first, with + // no grace period; what is left of the budget waits for the end, which a refused kill leaves to the routine itself. + static func endRunningTarget( + isolation: isolated (any Actor)? = #isolation, + clock: C, + budget: Duration = total - cleanup, + kill: () async -> KillOutcome, + waitForEnd: (Duration) async -> Bool + ) async -> Bool where C.Duration == Duration { + let deadline = clock.now.advanced(by: budget) + _ = await kill() + let remaining = clock.now.duration(to: deadline) + guard remaining > .zero else { return false } + return await waitForEnd(remaining) + } + + static func seconds(_ duration: Duration) -> Double { + let components = duration.components + return Double(components.seconds) + Double(components.attoseconds) / 1e18 + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugSupport.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugSupport.swift new file mode 100644 index 0000000000..c15b5ac656 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugSupport.swift @@ -0,0 +1,177 @@ +import Foundation +import os + +enum OracleDebugSupport { + // Waits for `task` for at most `seconds`; nil when it is still running. The task keeps running either way. + static func value(of task: Task, within seconds: Double) async -> T? { + let slot = OSAllocatedUnfairLock?>(initialState: nil) + let take: @Sendable () -> CheckedContinuation? = { + slot.withLock { stored in + let continuation = stored + stored = nil + return continuation + } + } + return await withCheckedContinuation { continuation in + slot.withLock { $0 = continuation } + Task { + let value = await task.value + take()?.resume(returning: value) + } + Task { + try? await Task.sleep(nanoseconds: OracleQueryTimeout.nanoseconds(seconds)) + take()?.resume(returning: nil) + } + } + } + + // The call ending proves the target session ended only when the server answered it: the call returned, or failed + // with an ORA- error the server sent. A call that failed because its own transport did (a dropped connection, a + // closed tunnel) may have left that session running in the server and holding its locks. + static func endedOnServer(_ result: Result<[String?], Error>) -> Bool { + guard case .failure(let error) = result else { return true } + if case .server(let code, _) = error as? OracleDebugError { + return code != nil + } + guard case .queryFailed(let message) = error as? OracleCoreError else { return false } + return !OracleDebugStatements.errorCodes(in: message).isEmpty + } + + // False while the call runs past `seconds`, and for a call that ended without the server's answer. + static func endedOnServer(_ call: Task, Never>, within seconds: Double) async -> Bool { + await value(of: call, within: seconds).map(endedOnServer) ?? false + } + + static func rows(_ result: OracleRawResult) -> [[String?]] { + result.rows.map { row in row.map(\.stringValue) } + } + + static func source(of unit: OracleDebugUnit, on connection: any OracleDebugChannel) async throws -> String { + let result = try await connection.executeQuery(OracleDebugStatements.source(of: unit)) + let lines = rows(result).compactMap { row -> (line: Int, text: String?)? in + guard let line = row.first.flatMap({ $0 }).flatMap({ Int($0.trimmingCharacters(in: .whitespaces)) }) + else { return nil } + return (line, row.count > 1 ? row[1] : nil) + } + guard !lines.isEmpty else { throw OracleDebugError.unitNotFound } + return OracleDebugStatements.assembleSource(rows: lines) + } + + static func executableLines(of unit: OracleDebugUnit, on connection: any OracleDebugChannel) async throws -> [Int]? { + let fields = try await payload(OracleDBMSDebugStatements.lineMap(of: unit), on: connection) + return try OracleDBMSDebugStatements.executableLines(fromPayload: fields) + } + + static func payload( + _ block: String, + binds: [OracleBindValue] = [], + autoCommit: Bool = false, + on connection: any OracleDebugChannel + ) async throws -> [String?] { + let pieces = try await connection.executeDirect(block, binds: binds, autoCommit: autoCommit, readsRows: true) + do { + return try OracleDebugPayload.parse(OracleDebugPayload.join(pieces: pieces)) + } catch { + throw OracleDebugError.unreadableAnswer + } + } + + // How long the routine's call gets to return once the debugger has seen it end or lost the target. + static let callEndWait: Duration = .seconds(30) + + // The routine's own result: its outputs on success; a rollback otherwise, and always after Stop. Nil is a call + // still running, which holds the target's gate: a ROLLBACK would queue behind it with no bound. + static func conclude( + _ result: Result<[String?], Error>?, + stopped: Bool, + entryPoint: OracleDebugEntryPoint, + target: any OracleDebugChannel, + targetIdentity: OracleDebugSessionIdentity?, + notice: @Sendable (OracleDebugNotice) -> Void + ) async -> OracleDebugStop { + guard let result else { return .failed(.stillRunning(targetIdentity)) } + let report: OracleDebugStatements.InvocationReport? + switch result { + case .success(let pieces): + report = try? OracleDebugStatements.invocationReport( + fromPayload: OracleDebugPayload.parse(OracleDebugPayload.join(pieces: pieces)) + ) + case .failure: + report = nil + } + for line in report?.lines ?? [] { + notice(.serverOutput(line)) + } + if !stopped, let report, report.succeeded { + do { + _ = try await target.executeDirect("COMMIT") + } catch { + return .failed(.connectionLost(Self.message(of: error))) + } + return .finished(outcome(of: report, entryPoint: entryPoint)) + } + _ = try? await target.executeDirect("ROLLBACK") + if stopped { return .failed(.stopped) } + if let report { + return .failed(.routineFailed(report.error ?? "")) + } + if case .failure(let error) = result { + // Only the server's own error says the routine failed and rolled back; a dropped connection says neither. + guard endedOnServer(result) else { return .failed(.connectionLost(Self.message(of: error))) } + return .failed(.routineFailed(Self.message(of: error))) + } + return .failed(.connectionLost(nil)) + } + + static func outcome(of report: OracleDebugStatements.InvocationReport, entryPoint: OracleDebugEntryPoint) -> OracleDebugOutcome { + var types: [String: String] = [:] + for parameter in entryPoint.parameters { + types[parameter.name] = parameter.displayType + } + if let result = entryPoint.result { + types[entryPoint.entry.subprogram] = result.displayType + } + return OracleDebugOutcome(values: report.outputs.map { + OracleDebugOutcome.Value(name: $0.name, typeName: types[$0.name] ?? "", value: $0.value) + }) + } + + // The target's SID and SERIAL#, read before it is attached to a debugger, so its own PL/SQL does not stop it. + static func sessionIdentity(of connection: any OracleDebugChannel) async -> OracleDebugSessionIdentity? { + for query in OracleDBMSDebugStatements.sessionIdentityQueries { + guard let row = try? await connection.executeQuery(query).rows.first?.map(\.stringValue), + let identity = OracleDBMSDebugStatements.sessionIdentity(fromRow: row) else { continue } + return identity + } + return nil + } + + // The server ends a killed session at its next check, even inside a loop or a SQL statement, and rolls its work + // back. ORA-00031 "session marked for kill" is that taking effect on a busy session (measured: the target then + // failed with ORA-00028); ORA-01031 is a session without ALTER SYSTEM. + // A read Stop overlaps holds the connection's gate first; past `seconds` the channel is closed, which ends both. + static func kill( + _ identity: OracleDebugSessionIdentity?, + from connection: any OracleDebugChannel, + within seconds: Double + ) async -> OracleDebugStopBudget.KillOutcome { + guard let identity, let statement = OracleDBMSDebugStatements.killSession(identity), seconds > 0 else { + return .refused + } + do { + _ = try await withOracleTimeout(seconds: seconds, onTimeout: { connection.cancelCurrentQuery() }) { + try await connection.executeDirect(statement) + } + return .marked + } catch { + return OracleDebugStatements.errorCodes(in: message(of: error)).contains(31) ? .marked : .refused + } + } + + static func message(of error: Error) -> String { + if let core = error as? OracleCoreError { + return core.errorDescription ?? String(describing: core) + } + return String(describing: error) + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugTypes.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugTypes.swift new file mode 100644 index 0000000000..357e646788 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugTypes.swift @@ -0,0 +1,181 @@ +import Foundation + +public enum OracleDebugMechanism: String, Sendable, Hashable, CaseIterable { + case jdwp + case dbmsDebug +} + +public enum OracleDebugCommand: Sendable, Hashable { + case continueExecution + case stepOver + case stepInto + case stepOut +} + +public struct OracleDebugLocation: Sendable, Hashable { + public let unit: OracleDebugUnit + public let line: Int + + public init(unit: OracleDebugUnit, line: Int) { + self.unit = unit + self.line = line + } +} + +public struct OracleDebugOutcome: Sendable, Equatable { + public struct Value: Sendable, Equatable { + public let name: String + public let typeName: String + public let value: String? + + public init(name: String, typeName: String, value: String?) { + self.name = name + self.typeName = typeName + self.value = value + } + } + + // The function's result first, then each OUT and IN OUT parameter whose value can be shown as text. + public let values: [Value] + + public init(values: [Value]) { + self.values = values + } +} + +public enum OracleDebugStop: Sendable, Equatable { + case paused(OracleDebugLocation) + case finished(OracleDebugOutcome) + case failed(OracleDebugFailure) +} + +public enum OracleDebugFailure: Sendable, Equatable { + // The routine raised; the server's own message. + case routineFailed(String) + case stopped + // Stop could not end a routine running between breakpoints under DBMS_DEBUG; it ends on its own. + case stillRunning(OracleDebugSessionIdentity?) + case connectionLost(String?) +} + +public enum OracleDebugStopResult: Sendable, Equatable { + // The routine's call ended, so its uncommitted work is rolled back and its locks are gone. + case ended + // Nothing confirmed the end inside the budget; the session may still run and hold locks. + case unconfirmed(OracleDebugSessionIdentity?) +} + +public struct OracleDebugSessionIdentity: Sendable, Hashable { + public let sid: Int + public let serial: Int? + // On RAC a SID and SERIAL# name a session only within its instance, and the kill may go through another one. + public let instance: Int? + + public init(sid: Int, serial: Int?, instance: Int? = nil) { + self.sid = sid + self.serial = serial + self.instance = instance + } +} + +public struct OracleDebugFrame: Sendable, Hashable { + public let level: Int + public let location: OracleDebugLocation + public let subprogram: String? + + public init(level: Int, location: OracleDebugLocation, subprogram: String?) { + self.level = level + self.location = location + self.subprogram = subprogram + } +} + +public enum OracleDebugVariableKind: Sendable, Hashable { + case argument + case local + case package +} + +public struct OracleDebugVariable: Sendable, Hashable { + public let name: String + public let kind: OracleDebugVariableKind + public let typeName: String? + public let value: OracleDebugValue + public let isConstant: Bool + + public init(name: String, kind: OracleDebugVariableKind, typeName: String?, value: OracleDebugValue, isConstant: Bool) { + self.name = name + self.kind = kind + self.typeName = typeName + self.value = value + self.isConstant = isConstant + } +} + +// Things the person should read in the debugger's output: the engine says what happened, the plugin words it. +public enum OracleDebugNotice: Sendable, Equatable { + case connectedOverJDWP(host: String, port: Int) + case fellBack(OracleJDWPFailure, host: String, port: Int) + case usingDBMSDebug(reason: OracleDebugProbeReason) + case breakpointHasNoCode(unit: OracleDebugUnit, line: Int) + case breakpointMoved(unit: OracleDebugUnit, from: Int, to: Int) + case serverOutput(String) +} + +public enum OracleDebugProbeReason: Sendable, Equatable { + case chosen + case noDebuggerHost +} + +public enum OracleDebugError: Error, Sendable, Equatable { + case privilegeMissing(String) + case server(code: Int?, message: String) + case jdwp(OracleJDWPFailure) + case notPaused + case ended + case unsupportedArgument(name: String, typeName: String) + case undeclarableArgument(name: String, typeName: String) + // Leaving these defaulted parameters out would match another overload of the subprogram too. + case ambiguousDefaults(subprogram: String, parameters: [String]) + case ambiguousCall(subprogram: String) + // A JDWP command the database answered with an error code. + case debuggerRefused(code: Int) + // A DBMS_DEBUG answer that does not parse. + case unreadableAnswer + case unitNotFound + case malformedReply + // A JDWP command got no answer within the reply timeout; the connection, and with it the target, was closed. + case debuggerStoppedAnswering +} + +// Why the database did not connect back over JDWP. Each but `missingPrivilege` and `other` falls back to DBMS_DEBUG. +public enum OracleJDWPFailure: Sendable, Equatable { + case accessControlList + case unreachable(String) + case packageUnavailable + case timedOut + case noFreePort + case handshakeFailed + // Explicit DBMS_DEBUG_JDWP with an empty Debugger Host. + case noDebuggerHost + // Connections came only from these addresses, none of them the database server's. + case unexpectedPeer([String]) + case missingPrivilege + case other(String) +} + +public protocol OracleDebugEngine: AnyObject, Sendable { + var mechanism: OracleDebugMechanism { get } + var supportsStepOut: Bool { get } + var notices: AsyncStream { get } + func firstStop() async throws -> OracleDebugStop + func perform(_ command: OracleDebugCommand) async throws -> OracleDebugStop + func stack() async throws -> [OracleDebugFrame] + func variables(inFrame level: Int) async throws -> [OracleDebugVariable] + // The line the breakpoint landed on, or nil when the unit has no code there. + func setBreakpoint(in unit: OracleDebugUnit, line: Int) async throws -> Int? + func removeBreakpoint(in unit: OracleDebugUnit, line: Int) async throws + func source(of unit: OracleDebugUnit) async throws -> String + // Returns within 3 seconds, whatever call it overlaps; that call then ends promptly. + func stop() async -> OracleDebugStopResult +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugUnit.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugUnit.swift new file mode 100644 index 0000000000..cb4194bdec --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugUnit.swift @@ -0,0 +1,187 @@ +import Foundation + +public enum OracleDebugUnitKind: String, Sendable, Hashable, CaseIterable { + case procedure = "PROCEDURE" + case function = "FUNCTION" + case package = "PACKAGE" + case packageBody = "PACKAGE BODY" + case trigger = "TRIGGER" + case type = "TYPE" + case typeBody = "TYPE BODY" + + // The middle of `$Oracle///`. Package, PackageBody, Procedure and Function are measured on + // 23.26; Trigger, Type and TypeBody follow the same naming and are not. + public var jdwpKind: String { + switch self { + case .procedure: return "Procedure" + case .function: return "Function" + case .package: return "Package" + case .packageBody: return "PackageBody" + case .trigger: return "Trigger" + case .type: return "Type" + case .typeBody: return "TypeBody" + } + } + + public init?(jdwpKind: String) { + guard let kind = Self.allCases.first(where: { $0.jdwpKind == jdwpKind }) else { return nil } + self = kind + } + + // DBMS_DEBUG namespaces: 1 holds specs and top-level units, 2 bodies, 3 triggers. + public var namespace: Int { + switch self { + case .procedure, .function, .package, .type: return 1 + case .packageBody, .typeBody: return 2 + case .trigger: return 3 + } + } + + // DBMS_DEBUG's LibunitType values (the KGLT constants). + public init?(namespace: Int, libunitType: Int) { + switch (namespace, libunitType) { + case (1, 7): self = .procedure + case (1, 8): self = .function + case (1, 9): self = .package + case (1, 13): self = .type + case (2, 11): self = .packageBody + case (2, 14): self = .typeBody + case (3, _): self = .trigger + default: return nil + } + } + + public var hasSubprograms: Bool { + self == .packageBody || self == .typeBody || self == .package || self == .type + } + + // The statement that compiles the unit with debug information, without touching its other settings. A package + // compiles only its body, so nothing that depends on the specification is invalidated. + public func compileForDebugStatement(owner: String, name: String) -> String { + let qualified = "\(OracleDebugUnit.quote(owner)).\(OracleDebugUnit.quote(name))" + let tail = "COMPILE PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS" + switch self { + case .procedure: return "ALTER PROCEDURE \(qualified) \(tail)" + case .function: return "ALTER FUNCTION \(qualified) \(tail)" + case .package, .packageBody: return "ALTER PACKAGE \(qualified) COMPILE BODY PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS" + case .trigger: return "ALTER TRIGGER \(qualified) \(tail)" + case .type, .typeBody: return "ALTER TYPE \(qualified) COMPILE BODY PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS" + } + } +} + +public struct OracleDebugUnit: Sendable, Hashable { + public let owner: String + public let name: String + public let kind: OracleDebugUnitKind + + public init(owner: String, name: String, kind: OracleDebugUnitKind) { + self.owner = owner + self.name = name + self.kind = kind + } + + // `TYPE/OWNER/NAME`, each part with `%` and `/` escaped: a quoted Oracle name may hold either. + public var identity: String { + [kind.rawValue, owner, name].map(Self.escape).joined(separator: "/") + } + + public init?(identity: String) { + let parts = Self.split(identity) + guard parts.count == 3, let kind = OracleDebugUnitKind(rawValue: parts[0]) else { return nil } + self.init(owner: parts[1], name: parts[2], kind: kind) + } + + // The body holds the code a package or type runs. + public var codeUnit: OracleDebugUnit { + switch kind { + case .package: return OracleDebugUnit(owner: owner, name: name, kind: .packageBody) + case .type: return OracleDebugUnit(owner: owner, name: name, kind: .typeBody) + default: return self + } + } + + public var jdwpSignature: String { + "L\(jdwpClassName);" + } + + // The ClassMatch pattern, which is the class name without the `L` and `;`. + public var jdwpClassName: String { + "$Oracle/\(kind.jdwpKind)/\(owner)/\(name)" + } + + // Nil for anonymous blocks, built-ins, Java classes, and the classes Oracle makes for local types, which carry + // extra path components (`$Oracle/Function/PROBE/F_CALC/F_CALC/T_NUMS`). + public init?(jdwpSignature: String) { + guard jdwpSignature.hasPrefix("L$Oracle/"), jdwpSignature.hasSuffix(";") else { return nil } + let path = jdwpSignature.dropFirst("L$Oracle/".count).dropLast() + let parts = path.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + guard parts.count == 3, let kind = OracleDebugUnitKind(jdwpKind: parts[0]), + !parts[1].isEmpty, !parts[2].isEmpty else { return nil } + self.init(owner: parts[1], name: parts[2], kind: kind) + } + + public static func isAnonymousBlock(jdwpSignature: String) -> Bool { + jdwpSignature.hasPrefix("L$Oracle/Block/") + } + + // `L$Oracle/Builtin/NUMBER;` reads NUMBER; a local type reads its own name. + public static func typeName(fromJDWPSignature signature: String) -> String { + var text = Substring(signature) + if text.hasPrefix("L") { text = text.dropFirst() } + if text.hasSuffix(";") { text = text.dropLast() } + return String(text.split(separator: "/").last ?? text) + } + + public static func quote(_ identifier: String) -> String { + "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\"", options: .literal))\"" + } + + public static func literal(_ text: String) -> String { + "'\(text.replacingOccurrences(of: "'", with: "''", options: .literal))'" + } + + static func escape(_ part: String) -> String { + part.replacingOccurrences(of: "%", with: "%25", options: .literal) + .replacingOccurrences(of: "/", with: "%2F", options: .literal) + } + + static func unescape(_ part: String) -> String { + part.replacingOccurrences(of: "%2F", with: "/", options: .literal) + .replacingOccurrences(of: "%25", with: "%", options: .literal) + } + + static func split(_ identity: String) -> [String] { + identity.split(separator: "/", omittingEmptySubsequences: false).map { unescape(String($0)) } + } +} + +// One public subprogram overload of a unit, or the standalone routine itself. +public struct OracleDebugEntry: Sendable, Hashable { + public let unit: OracleDebugUnit + public let subprogram: String + public let subprogramID: Int + + public init(unit: OracleDebugUnit, subprogram: String, subprogramID: Int) { + self.unit = unit + self.subprogram = subprogram + self.subprogramID = subprogramID + } + + public var identity: String { + unit.identity + "/" + OracleDebugUnit.escape(subprogram) + "/" + String(subprogramID) + } + + public init?(identity: String) { + let parts = OracleDebugUnit.split(identity) + guard parts.count == 5, let kind = OracleDebugUnitKind(rawValue: parts[0]), + let subprogramID = Int(parts[4]) else { return nil } + self.init( + unit: OracleDebugUnit(owner: parts[1], name: parts[2], kind: kind), + subprogram: parts[3], + subprogramID: subprogramID + ) + } + + public var isPackaged: Bool { unit.kind.hasSubprograms } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPConnection.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPConnection.swift new file mode 100644 index 0000000000..ca75e1f80d --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPConnection.swift @@ -0,0 +1,197 @@ +import Foundation +import Network +import OSLog + +private let logger = Logger(subsystem: "com.TablePro", category: "OracleJDWP") + +public protocol OracleJDWPTransport: Sendable { + func send(_ data: Data) async throws + // Nil once the peer has closed. + func receive() async throws -> Data? + func close() +} + +public final class OracleJDWPNetworkTransport: OracleJDWPTransport, @unchecked Sendable { + private let connection: NWConnection + + public init(connection: NWConnection) { + self.connection = connection + } + + public func send(_ data: Data) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) + } + } + + public func receive() async throws -> Data? { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { data, _, isComplete, error in + if let data, !data.isEmpty { + continuation.resume(returning: data) + } else if let error { + continuation.resume(throwing: error) + } else if isComplete { + continuation.resume(returning: nil) + } else { + continuation.resume(returning: Data()) + } + } + } + } + + public func close() { + connection.cancel() + } +} + +// One JDWP conversation: replies matched to commands by id, everything the VM sends on its own delivered in order. +public actor OracleJDWPConnection { + private let transport: any OracleJDWPTransport + private var buffer = OracleJDWPFrameBuffer() + private var nextID: UInt32 = 1 + private var waiting: [UInt32: CheckedContinuation] = [:] + private var timers: [UInt32: Task] = [:] + private let replyTimeout: Duration + private var readTask: Task? + private var isClosed = false + private let commandContinuation: AsyncStream.Continuation + // Commands the VM sent (Event.Composite), raw: the first can arrive before ID sizes are known. + nonisolated public let commands: AsyncStream + + // Every command Oracle answers is a lookup; a peer that takes this long has stopped answering. + public static let defaultReplyTimeout: Duration = .seconds(15) + + public init(transport: any OracleJDWPTransport, replyTimeout: Duration = OracleJDWPConnection.defaultReplyTimeout) { + self.transport = transport + self.replyTimeout = replyTimeout + (commands, commandContinuation) = AsyncStream.makeStream(of: OracleJDWPPacket.self) + } + + // The JDWP spec has the debugger send first; Oracle sends its 14 bytes first (measured), so either order works. + public func handshake() async throws { + try await transport.send(OracleJDWPPacket.handshake) + while true { + if let head = buffer.take(OracleJDWPPacket.handshake.count) { + guard head == OracleJDWPPacket.handshake else { throw OracleJDWPError.handshakeFailed } + break + } + guard let data = try await transport.receive() else { throw OracleJDWPError.handshakeFailed } + buffer.append(data) + } + startReading() + } + + private func startReading() { + readTask = Task { [weak self] in + await self?.readLoop() + } + } + + private func readLoop() async { + do { + while true { + try deliverBufferedPackets() + guard let data = try await transport.receive() else { break } + buffer.append(data) + } + } catch { + logger.notice("JDWP read ended: \(String(describing: type(of: error)), privacy: .public) \(String(describing: error), privacy: .private)") + } + finish() + } + + private func deliverBufferedPackets() throws { + while let packet = try buffer.nextPacket() { + switch packet { + case let .reply(id, errorCode, data): + timers.removeValue(forKey: id)?.cancel() + guard let continuation = waiting.removeValue(forKey: id) else { continue } + if errorCode == 0 { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: OracleJDWPError.reply(code: Int(errorCode))) + } + case .command: + commandContinuation.yield(packet) + } + } + } + + // A reply that does not come within the timeout, or a caller that is cancelled, closes the connection: closing is + // what ends the target, and it fails every other reply still awaited. + public func send(_ commandSet: UInt8, _ command: UInt8, _ data: Data = Data()) async throws -> Data { + guard !isClosed else { throw OracleJDWPError.closed } + let id = nextID + nextID &+= 1 + let packet = OracleJDWPPacket.command(id: id, commandSet: commandSet, command: command, data: data) + do { + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + waiting[id] = continuation + timers[id] = Task { [weak self, replyTimeout] in + guard (try? await Task.sleep(for: replyTimeout)) != nil else { return } + await self?.expire(id: id) + } + Task { + do { + try await self.transport.send(packet.encoded) + } catch { + self.fail(id: id, error: error) + } + } + } + } onCancel: { + Task { await self.close() } + } + } catch { + if Task.isCancelled { throw CancellationError() } + throw error + } + } + + private func fail(id: UInt32, error: Error) { + timers.removeValue(forKey: id)?.cancel() + waiting.removeValue(forKey: id)?.resume(throwing: OracleJDWPError.closed) + } + + private func expire(id: UInt32) { + timers.removeValue(forKey: id) + guard let continuation = waiting.removeValue(forKey: id) else { return } + logger.error("The JDWP peer did not answer a command in time; closing the connection") + continuation.resume(throwing: OracleJDWPError.timedOut) + close() + } + + // Closing without VirtualMachine.Dispose ends the target session (ORA-30687, measured), which is what Stop needs. + public func close() { + transport.close() + finish() + } + + // A frame that fails to decode ends here too, and the socket closes with it so the target ends. + private func finish() { + guard !isClosed else { return } + isClosed = true + transport.close() + for timer in timers.values { + timer.cancel() + } + timers.removeAll() + let pending = waiting + waiting.removeAll() + for continuation in pending.values { + continuation.resume(throwing: OracleJDWPError.closed) + } + commandContinuation.finish() + readTask?.cancel() + } + + public var closed: Bool { isClosed } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPDebugInfo.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPDebugInfo.swift new file mode 100644 index 0000000000..31e05d3d5c --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPDebugInfo.swift @@ -0,0 +1,227 @@ +import Foundation + +public struct OracleJDWPMethod: Sendable, Hashable { + public let id: UInt64 + public let name: String + public let signature: String + + public init(id: UInt64, name: String, signature: String) { + self.id = id + self.name = name + self.signature = signature + } + + // A unit compiled without debug information lists `` methods with empty line tables (measured). + public var isSubprogram: Bool { + !name.hasPrefix("<") + } + + public var argumentTypeNames: [String] { + guard let open = signature.firstIndex(of: "("), let close = signature.firstIndex(of: ")") else { return [] } + var names: [String] = [] + var current = "" + for character in signature[signature.index(after: open).. [OracleJDWPMethod] { + var reader = OracleJDWPReader(data, sizes: sizes) + let count = try reader.count(minimumSize: sizes.method + 12) + var methods: [OracleJDWPMethod] = [] + for _ in 0.. Bool { + lhs.start == rhs.start && lhs.end == rhs.end + && lhs.entries.map(\.index) == rhs.entries.map(\.index) + && lhs.entries.map(\.line) == rhs.entries.map(\.line) + } + + static func decode(_ data: Data, sizes: OracleJDWPIDSizes) throws -> OracleJDWPLineTable { + var reader = OracleJDWPReader(data, sizes: sizes) + let start = try reader.i64() + let end = try reader.i64() + let count = try reader.count(minimumSize: 12) + var entries: [(index: Int64, line: Int)] = [] + for _ in 0.. Bool { + guard let first = entries.map(\.line).min(), let last = entries.map(\.line).max() else { return false } + return line >= first && line <= last + } + + // The code index for `line`, or for the next line after it that has code, within this method. + public func location(atOrAfter line: Int) -> (index: Int64, line: Int)? { + entries.filter { $0.line >= line }.min { $0.line == $1.line ? $0.index < $1.index : $0.line < $1.line } + } + + public func line(at index: Int64) -> Int? { + if let exact = entries.first(where: { $0.index == index }) { return exact.line } + return entries.last(where: { $0.index <= index })?.line + } +} + +public struct OracleJDWPVariable: Sendable, Hashable { + public let codeIndex: Int64 + public let name: String + public let signature: String + public let length: Int + public let slot: Int + public let isArgument: Bool + + public init(codeIndex: Int64, name: String, signature: String, length: Int, slot: Int, isArgument: Bool) { + self.codeIndex = codeIndex + self.name = name + self.signature = signature + self.length = length + self.slot = slot + self.isArgument = isArgument + } + + // `_return` and the like are the engine's own slots. + public var isUserVariable: Bool { + !name.hasPrefix("_") + } + + // A loop index is in scope only inside its loop: `I` measured at code index 13, length 3. + public func isVisible(at index: Int64) -> Bool { + let (end, overflow) = codeIndex.addingReportingOverflow(Int64(max(length, 1))) + return index >= codeIndex && (overflow || index < end) + } + + static func decodeTable(_ data: Data, sizes: OracleJDWPIDSizes) throws -> [OracleJDWPVariable] { + var reader = OracleJDWPReader(data, sizes: sizes) + let argumentCount = Int(try reader.i32()) + let count = try reader.count(minimumSize: 24) + var variables: [OracleJDWPVariable] = [] + for _ in 0..= 0, slot >= 0, !codeIndex.addingReportingOverflow(Int64(length)).overflow else { + throw OracleJDWPError.malformedPacket + } + variables.append(OracleJDWPVariable( + codeIndex: codeIndex, + name: name, + signature: signature, + length: length, + slot: slot, + isArgument: slot < argumentCount + )) + } + return variables + } +} + +public struct OracleJDWPField: Sendable, Hashable { + public let id: UInt64 + public let name: String + public let signature: String + public let modifiers: Int32 + + public init(id: UInt64, name: String, signature: String, modifiers: Int32) { + self.id = id + self.name = name + self.signature = signature + self.modifiers = modifiers + } + + public var isStatic: Bool { modifiers & 0x0008 != 0 } + + static func decodeList(_ data: Data, sizes: OracleJDWPIDSizes) throws -> [OracleJDWPField] { + var reader = OracleJDWPReader(data, sizes: sizes) + let count = try reader.count(minimumSize: sizes.field + 12) + var fields: [OracleJDWPField] = [] + for _ in 0..//`, subprograms are methods, the code index is the source line, and every value is an +// object whose `_value` string holds its text (all measured on 23.26). +public actor OracleJDWPDebugSession: OracleDebugEngine { + nonisolated public let mechanism = OracleDebugMechanism.jdwp + // SINGLE_STEP with depth OUT returns to the caller (measured). + nonisolated public let supportsStepOut = true + nonisolated public let notices: AsyncStream + private let noticeContinuation: AsyncStream.Continuation + + private enum Signal { + // One composite packet: two requests at one location arrive together and are one stop. + case events([OracleJDWPEvent]) + case callEnded + case connectionClosed + // The task waiting for the next signal was cancelled. + case cancelled + } + + private enum State { + case ready + case running + case paused + case ended + } + + private struct BreakpointKey: Hashable { + let unit: OracleDebugUnit + let line: Int + } + + private struct ClassInfo { + let id: UInt64 + let unit: OracleDebugUnit? + var methods: [OracleJDWPMethod]? + var lineTables: [UInt64: OracleJDWPLineTable] = [:] + } + + private struct MethodKey: Hashable { + let classID: UInt64 + let methodID: UInt64 + } + + private struct FrameInfo { + let id: UInt64 + let location: OracleJDWPLocation + let resolved: OracleDebugLocation + let method: OracleJDWPMethod? + } + + private let connection: OracleJDWPConnection + private let target: any OracleDebugChannel + private let helper: any OracleDebugChannel + private let entryPoint: OracleDebugEntryPoint + private let invocation: OracleDebugStatements.Invocation + private let connectCall: Task, Never> + private let targetIdentity: OracleDebugSessionIdentity? + private let callEndWait: Duration + private var gaveUp = false + private var stopResult = OracleDebugStopResult.ended + private var preparedClasses: Set = [] + + private var sizes = OracleJDWPIDSizes.oracle + private var signals: [Signal] = [] + private var waiter: CheckedContinuation? + private var pump: Task? + private var call: Task, Never>? + private var state = State.ready + private var thread: UInt64 = 1 + private var stopRequested = false + private var stopWaiters: [CheckedContinuation] = [] + private var isStopped = false + + private var classes: [UInt64: ClassInfo] = [:] + private var classIDs: [OracleDebugUnit: UInt64] = [:] + private var prepareRequests: Set = [] + private var placed: [BreakpointKey: Int32] = [:] + private var pending: Set = [] + private var holds = OracleDebugBreakpointHolds() + private var entryRequests: Set = [] + private var stepRequest: Int32? + private var frames: [FrameInfo] = [] + private var fieldCache: [UInt64: [OracleJDWPField]] = [:] + // Method ids are unique only within their class. + private var variableTables: [MethodKey: [OracleJDWPVariable]] = [:] + + init( + connection: OracleJDWPConnection, + target: any OracleDebugChannel, + helper: any OracleDebugChannel, + entryPoint: OracleDebugEntryPoint, + invocation: OracleDebugStatements.Invocation, + connectCall: Task, Never>, + targetIdentity: OracleDebugSessionIdentity? = nil, + callEndWait: Duration = OracleDebugSupport.callEndWait, + notices initial: [OracleDebugNotice] + ) { + self.targetIdentity = targetIdentity + self.callEndWait = callEndWait + self.connection = connection + self.target = target + self.helper = helper + self.entryPoint = entryPoint + self.invocation = invocation + self.connectCall = connectCall + (notices, noticeContinuation) = AsyncStream.makeStream(of: OracleDebugNotice.self) + for notice in initial { + noticeContinuation.yield(notice) + } + } + + // The target is suspended at VM_START inside CONNECT_TCP: ask for the classes that matter, then let it return. + func start(breakpoints: [(unit: OracleDebugUnit, line: Int)]) async throws { + try await guarded { try await handshakeVM(breakpoints: breakpoints) } + } + + private func handshakeVM(breakpoints: [(unit: OracleDebugUnit, line: Int)]) async throws { + let reply = try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.idSizes) + var reader = OracleJDWPReader(reply, sizes: sizes) + do { + sizes = try OracleJDWPIDSizes.validated( + field: try reader.i32(), + method: try reader.i32(), + object: try reader.i32(), + referenceType: try reader.i32(), + frame: try reader.i32() + ) + } catch { + throw OracleDebugError.jdwp(.handshakeFailed) + } + startPump() + try await waitForVMStart() + try await requestPrepare(of: entryPoint.entry.unit) + await adoptIfLoaded(entryPoint.entry.unit) + for breakpoint in breakpoints { + _ = try await setBreakpoint(in: breakpoint.unit, line: breakpoint.line, atStart: true) + } + try await resume() + switch await connectCall.value { + case .success: break + case .failure(let error): throw error + } + } + + private func startPump() { + let commands = connection.commands + pump = Task { [weak self] in + for await packet in commands { + await self?.receive(packet) + } + await self?.post(.connectionClosed) + } + } + + // An event that does not decode ends the session: closing the socket ends the target with it. + private func receive(_ packet: OracleJDWPPacket) async { + guard case let .command(_, commandSet, command, data) = packet, + commandSet == OracleJDWPCommand.event, command == OracleJDWPCommand.composite else { return } + do { + post(.events(try OracleJDWPEventSet.decode(data, sizes: sizes).events)) + } catch { + logger.error("Could not decode a JDWP event: \(String(describing: type(of: error)), privacy: .public) \(String(describing: error), privacy: .private)") + await connection.close() + } + } + + private func post(_ signal: Signal) { + if let waiter { + self.waiter = nil + waiter.resume(returning: signal) + } else { + signals.append(signal) + } + } + + private func nextSignal() async -> Signal { + if !signals.isEmpty { return signals.removeFirst() } + if Task.isCancelled { return .cancelled } + return await withTaskCancellationHandler { + await withCheckedContinuation { waiter = $0 } + } onCancel: { + Task { await self.post(.cancelled) } + } + } + + private func waitForVMStart() async throws { + let deadline = Task { [weak self] in + guard (try? await Task.sleep(nanoseconds: 10_000_000_000)) != nil else { return } + await self?.post(.connectionClosed) + } + defer { deadline.cancel() } + while true { + switch await nextSignal() { + case .events(let events): + for case .vmStart(let thread) in events { + self.thread = thread + return + } + case .callEnded, .connectionClosed: + throw OracleDebugError.jdwp(.handshakeFailed) + case .cancelled: + throw CancellationError() + } + } + } + + private func send(_ commandSet: UInt8, _ command: UInt8, _ build: (inout OracleJDWPWriter) -> Void = { _ in }) async throws -> OracleJDWPReader { + var writer = OracleJDWPWriter(sizes: sizes) + build(&writer) + let reply = try await connection.send(commandSet, command, writer.data) + return OracleJDWPReader(reply, sizes: sizes) + } + + private func resume() async throws { + _ = try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.resume) + } + + private func requestPrepare(of unit: OracleDebugUnit) async throws { + guard !prepareRequests.contains(unit) else { return } + prepareRequests.insert(unit) + _ = try await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.set, + OracleJDWPRequest.classPrepare(matching: unit.jdwpClassName, sizes: sizes) + ) + } + + // MARK: - Running + + public func firstStop() async throws -> OracleDebugStop { + guard state == .ready else { throw OracleDebugError.notPaused } + if stopRequested { return .failed(.stopped) } + state = .running + let target = target + let invocation = invocation + let call = Task { () -> Result<[String?], Error> in + do { + return .success(try await target.executeDirect(invocation.block, binds: invocation.binds, readsRows: true)) + } catch { + return .failure(error) + } + } + self.call = call + Task { [weak self] in + _ = await call.value + await self?.post(.callEnded) + } + return try await stoppedOnError { try await guarded { try await run(untilEntry: true) } } + } + + public func perform(_ command: OracleDebugCommand) async throws -> OracleDebugStop { + guard state == .paused, !stopRequested else { + if stopRequested { return .failed(.stopped) } + throw OracleDebugError.notPaused + } + state = .running + frames = [] + return try await stoppedOnError { + try await guarded { + switch command { + case .continueExecution: + break + case .stepOver: + try await setStep(.over) + case .stepInto: + try await setStep(.into) + case .stepOut: + try await setStep(.out) + } + try await resume() + return try await run(untilEntry: false) + } + } + } + + // A call Stop overlapped ends with Stop, not with what it read or failed to read from a closing connection. + private func guarded(_ body: () async throws -> T) async throws -> T { + guard !stopRequested else { throw OracleDebugError.ended } + let value: T + do { + value = try await translated(body) + } catch { + if stopRequested { throw OracleDebugError.ended } + throw error + } + if stopRequested { throw OracleDebugError.ended } + return value + } + + // Data the peer sent that does not decode ends the session: closing the socket ends the target. + private func translated(_ body: () async throws -> T) async throws -> T { + do { + return try await body() + } catch let error as OracleDebugError { + throw error + } catch OracleJDWPError.malformedPacket, OracleJDWPError.truncated { + await connection.close() + throw OracleDebugError.malformedReply + } catch OracleJDWPError.timedOut { + throw OracleDebugError.debuggerStoppedAnswering + } catch OracleJDWPError.closed { + throw OracleDebugError.ended + } catch OracleJDWPError.reply(let code) { + throw OracleDebugError.debuggerRefused(code: code) + } catch OracleJDWPError.handshakeFailed, OracleJDWPError.unexpectedEvent { + await connection.close() + throw OracleDebugError.malformedReply + } + } + + // A call Stop overlapped ends as stopped, whatever its own statements reported after the drivers closed. + private func stoppedOnError(_ body: () async throws -> OracleDebugStop) async throws -> OracleDebugStop { + do { + return try await body() + } catch { + if stopRequested { return await outcomeOfStop() } + throw error + } + } + + // Stop owns the ending and the drivers are the host's once it returns, so a waiting call only reports it. + private func outcomeOfStop() async -> OracleDebugStop { + if !isStopped { + await withCheckedContinuation { stopWaiters.append($0) } + } + return gaveUp ? .failed(.stillRunning(targetIdentity)) : .failed(.stopped) + } + + private func setStep(_ depth: OracleJDWPRequest.StepDepth) async throws { + var reader = OracleJDWPReader( + try await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.set, + OracleJDWPRequest.step(thread: thread, depth: depth, sizes: sizes) + ), + sizes: sizes + ) + stepRequest = try reader.i32() + } + + private func clearStep() async { + guard let request = stepRequest else { return } + stepRequest = nil + _ = try? await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.clear, + OracleJDWPRequest.clear(kind: .singleStep, requestID: request, sizes: sizes) + ) + } + + private func run(untilEntry: Bool) async throws -> OracleDebugStop { + while true { + let signal = await nextSignal() + // The caller gave up on this step: the target ends as Stop ends it. + if case .cancelled = signal { + _ = await stop() + throw CancellationError() + } + if stopRequested { + return await conclude() + } + guard case .events(let events) = signal else { return await conclude() } + if events.contains(.vmDeath) { return await conclude() } + var requests: Set = [] + var stopLocation: OracleJDWPLocation? + for event in events { + switch event { + case .classPrepare(_, let thread, let typeID, let signature): + self.thread = thread + await prepared(typeID: typeID, signature: signature) + case .breakpoint(let request, let thread, let location), .singleStep(let request, let thread, let location): + self.thread = thread + requests.insert(request) + stopLocation = stopLocation ?? location + default: + break + } + } + guard let location = stopLocation, !untilEntry || !requests.isDisjoint(with: entryRequests) else { + try await resume() + continue + } + await clearStep() + if untilEntry { + await clearEntryRequests() + } + guard let resolved = await resolve(location) else { + // Our own anonymous block, or code with no source to show: run on. + try await resume() + continue + } + state = .paused + return .paused(resolved) + } + } + + private func conclude() async -> OracleDebugStop { + if stopRequested { + return await outcomeOfStop() + } + let result: Result<[String?], Error>? + if let call { + result = await OracleDebugSupport.value(of: call, within: OracleDebugStopBudget.seconds(callEndWait)) + } else { + result = .failure(OracleDebugError.ended) + } + let stop = await OracleDebugSupport.conclude( + result, + stopped: stopRequested, + entryPoint: entryPoint, + target: target, + targetIdentity: targetIdentity + ) { [noticeContinuation] in noticeContinuation.yield($0) } + state = .ended + // Closing ends the target session, so it waits until the commit or rollback is done. + await connection.close() + noticeContinuation.finish() + return stop + } + + // MARK: - Classes and lines + + // A unit the target ran before CONNECT_TCP (a logon trigger, the connection's startup commands) is already loaded, + // and no CLASS_PREPARE announces it. + private func adoptIfLoaded(_ unit: OracleDebugUnit) async { + guard let id = await loadedClass(unit) else { return } + await prepared(typeID: id, signature: unit.jdwpSignature) + } + + private func prepared(typeID: UInt64, signature: String) async { + guard !preparedClasses.contains(typeID) else { return } + preparedClasses.insert(typeID) + let unit = OracleDebugUnit(jdwpSignature: signature) + classes[typeID] = ClassInfo(id: typeID, unit: unit) + guard let unit else { return } + classIDs[unit] = typeID + if unit == entryPoint.entry.unit { + await installEntryBreakpoints(classID: typeID) + } + for key in pending where key.unit == unit { + pending.remove(key) + if let placedLine = try? await place(key.line, in: unit, classID: typeID) { + if placedLine != key.line { + holds.move(key.line, to: placedLine, in: unit) + noticeContinuation.yield(.breakpointMoved(unit: unit, from: key.line, to: placedLine)) + } + } else { + holds.drop(key.line, in: unit) + noticeContinuation.yield(.breakpointHasNoCode(unit: unit, line: key.line)) + } + } + } + + // Every overload with the entry point's name: the anonymous block calls only the one asked for, so the first hit + // is it. The first code index is the subprogram's header line, where DBMS_DEBUG stops on entry as well. + private func installEntryBreakpoints(classID: UInt64) async { + guard let methods = try? await methods(of: classID) else { return } + for method in methods where method.name == entryPoint.entry.subprogram { + guard let table = try? await lineTable(classID: classID, methodID: method.id), + let first = table.entries.first else { continue } + let location = OracleJDWPLocation(classID: classID, methodID: method.id, index: first.index) + if let request = try? await setBreakpointRequest(at: location) { + entryRequests.insert(request) + } + } + } + + private func clearEntryRequests() async { + let requests = entryRequests.subtracting(placed.values) + entryRequests.removeAll() + for request in requests { + _ = try? await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.clear, + OracleJDWPRequest.clear(kind: .breakpoint, requestID: request, sizes: sizes) + ) + } + } + + private func methods(of classID: UInt64) async throws -> [OracleJDWPMethod] { + if let methods = classes[classID]?.methods { return methods } + let reply = try await connection.send( + OracleJDWPCommand.referenceType, + OracleJDWPCommand.methods, + idData(classID, .referenceType) + ) + let methods = try OracleJDWPMethod.decodeList(reply, sizes: sizes) + classes[classID]?.methods = methods + return methods + } + + private func lineTable(classID: UInt64, methodID: UInt64) async throws -> OracleJDWPLineTable { + if let table = classes[classID]?.lineTables[methodID] { return table } + var writer = OracleJDWPWriter(sizes: sizes) + writer.id(classID, .referenceType) + writer.id(methodID, .method) + let reply = try await connection.send(OracleJDWPCommand.method, OracleJDWPCommand.lineTable, writer.data) + let table = try OracleJDWPLineTable.decode(reply, sizes: sizes) + classes[classID]?.lineTables[methodID] = table + return table + } + + private func idData(_ id: UInt64, _ kind: OracleJDWPIDKind) -> Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.id(id, kind) + return writer.data + } + + // Only a unit's own classes are read: asking for the line table of a local collection type's method ended the + // target with ORA-00600 [kgamgub4_1] on 23.26 (measured). + private func classInfo(_ classID: UInt64) async -> ClassInfo { + if let info = classes[classID] { return info } + var unit: OracleDebugUnit? + if var reader = try? OracleJDWPReader( + await connection.send(OracleJDWPCommand.referenceType, OracleJDWPCommand.signature, idData(classID, .referenceType)), + sizes: sizes + ), let signature = try? reader.string() { + unit = OracleDebugUnit(jdwpSignature: signature) + } + let info = ClassInfo(id: classID, unit: unit) + classes[classID] = info + if let unit { classIDs[unit] = classID } + return info + } + + private func resolve(_ location: OracleJDWPLocation) async -> OracleDebugLocation? { + guard let unit = await classInfo(location.classID).unit, + let table = try? await lineTable(classID: location.classID, methodID: location.methodID), + let line = table.line(at: location.index) else { return nil } + return OracleDebugLocation(unit: unit, line: line) + } + + private func place(_ line: Int, in unit: OracleDebugUnit, classID: UInt64) async throws -> Int? { + var best: (location: OracleJDWPLocation, line: Int, covers: Bool)? + for method in try await methods(of: classID) { + guard let table = try? await lineTable(classID: classID, methodID: method.id), + let candidate = table.location(atOrAfter: line) else { continue } + let covers = table.covers(line: line) + let location = OracleJDWPLocation(classID: classID, methodID: method.id, index: candidate.index) + if let current = best { + if current.covers && !covers { continue } + if covers == current.covers && candidate.line >= current.line { continue } + } + best = (location, candidate.line, covers) + } + guard let best else { return nil } + let key = BreakpointKey(unit: unit, line: best.line) + if placed[key] == nil { + placed[key] = try await setBreakpointRequest(at: best.location) + } + return best.line + } + + private func setBreakpointRequest(at location: OracleJDWPLocation) async throws -> Int32 { + var reader = OracleJDWPReader( + try await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.set, + OracleJDWPRequest.breakpoint(at: location, sizes: sizes) + ), + sizes: sizes + ) + return try reader.i32() + } + + // MARK: - Breakpoints + + public func setBreakpoint(in unit: OracleDebugUnit, line: Int) async throws -> Int? { + try await setBreakpoint(in: unit, line: line, atStart: false) + } + + private func setBreakpoint(in unit: OracleDebugUnit, line: Int, atStart: Bool) async throws -> Int? { + guard state != .ended else { throw OracleDebugError.ended } + guard let landed = try await guarded({ try await placeBreakpoint(in: unit, line: line) }) else { return nil } + holds.hold(landed, in: unit, by: atStart ? line : landed) + return landed + } + + private func placeBreakpoint(in unit: OracleDebugUnit, line: Int) async throws -> Int? { + if classIDs[unit] == nil { + await adoptIfLoaded(unit) + } + if let classID = classIDs[unit] { + return try await place(line, in: unit, classID: classID) + } + // Not loaded yet: DBMS_DEBUG's line map answers for it, and the line table places it once the class loads. + guard let lines = try await OracleDebugSupport.executableLines(of: unit, on: helper), + let resolved = lines.first(where: { $0 >= line }) else { return nil } + pending.insert(BreakpointKey(unit: unit, line: resolved)) + try await requestPrepare(of: unit) + return resolved + } + + public func removeBreakpoint(in unit: OracleDebugUnit, line: Int) async throws { + guard let landed = holds.release(line, in: unit) else { return } + let key = BreakpointKey(unit: unit, line: landed) + pending.remove(key) + guard let request = placed.removeValue(forKey: key) else { return } + do { + try await guarded { + _ = try await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.clear, + OracleJDWPRequest.clear(kind: .breakpoint, requestID: request, sizes: sizes) + ) + } + } catch { + // Still set in the VM, so removing it again has to find it. + holds.hold(landed, in: unit, by: line) + placed[key] = request + throw error + } + } + + // MARK: - Inspection + + public func source(of unit: OracleDebugUnit) async throws -> String { + try await guarded { try await OracleDebugSupport.source(of: unit, on: helper) } + } + + public func stack() async throws -> [OracleDebugFrame] { + guard state == .paused else { throw OracleDebugError.notPaused } + try await guarded { try await loadFrames() } + return frames.enumerated().map { level, frame in + OracleDebugFrame(level: level, location: frame.resolved, subprogram: frame.method?.name) + } + } + + private func loadFrames() async throws { + guard frames.isEmpty else { return } + var reader = try await send(OracleJDWPCommand.threadReference, OracleJDWPCommand.frames) { writer in + writer.id(thread, .object) + writer.i32(0) + writer.i32(-1) + } + let count = try reader.count(minimumSize: sizes.frame + 1 + sizes.referenceType + sizes.method + 8) + var loaded: [FrameInfo] = [] + for _ in 0.. [OracleDebugVariable] { + guard state == .paused else { throw OracleDebugError.notPaused } + return try await guarded { try await readVariables(inFrame: level) } + } + + private func readVariables(inFrame level: Int) async throws -> [OracleDebugVariable] { + try await loadFrames() + guard frames.indices.contains(level) else { return [] } + let frame = frames[level] + let table = try await variableTable(classID: frame.location.classID, methodID: frame.location.methodID) + let visible = table.filter { $0.isUserVariable && $0.isVisible(at: frame.location.index) } + let values = try await Self.readSlots(visible) { variables in + try await slotValues(variables, frame: frame) + } + var result: [OracleDebugVariable] = [] + for (variable, value) in zip(visible, values) { + var described = OracleDebugValue.unavailable + if let value { + described = await describe(value) + } + result.append(OracleDebugVariable( + name: variable.name, + kind: variable.isArgument ? .argument : .local, + typeName: OracleDebugUnit.typeName(fromJDWPSignature: variable.signature), + value: described, + isConstant: false + )) + } + return result + (await packageVariables(of: frame.resolved.unit)) + } + + // One unreadable slot (INVALID_SLOT 35, say) fails the whole request; then each is read alone and the unreadable + // one is left out. + static func readSlots( + isolation: isolated (any Actor)? = #isolation, + _ variables: [Variable], + read: ([Variable]) async throws -> [OracleJDWPValue?] + ) async throws -> [OracleJDWPValue?] { + do { + return try await read(variables) + } catch OracleJDWPError.reply { + var values: [OracleJDWPValue?] = [] + for variable in variables { + values.append((try? await read([variable]))?.first ?? nil) + } + return values + } + } + + private func slotValues(_ variables: [OracleJDWPVariable], frame: FrameInfo) async throws -> [OracleJDWPValue?] { + guard !variables.isEmpty else { return [] } + var reader = try await send(OracleJDWPCommand.stackFrame, OracleJDWPCommand.frameValues) { writer in + writer.id(thread, .object) + writer.id(frame.id, .frame) + writer.i32(Int32(variables.count)) + for variable in variables { + writer.i32(Int32(variable.slot)) + writer.u8(variable.signature.utf8.first ?? OracleJDWPValue.objectTag) + } + } + let count = try reader.count(minimumSize: 1) + var values: [OracleJDWPValue?] = [] + for _ in 0.. [OracleJDWPVariable] { + let key = MethodKey(classID: classID, methodID: methodID) + if let table = variableTables[key] { return table } + var writer = OracleJDWPWriter(sizes: sizes) + writer.id(classID, .referenceType) + writer.id(methodID, .method) + let reply = try await connection.send(OracleJDWPCommand.method, OracleJDWPCommand.variableTable, writer.data) + let table = try OracleJDWPVariable.decodeTable(reply, sizes: sizes) + variableTables[key] = table + return table + } + + // The body's and the specification's package-level variables are their classes' static fields. + private func packageVariables(of unit: OracleDebugUnit) async -> [OracleDebugVariable] { + guard unit.kind == .packageBody || unit.kind == .package else { return [] } + var result: [OracleDebugVariable] = [] + for kind in [OracleDebugUnitKind.packageBody, .package] { + let owner = OracleDebugUnit(owner: unit.owner, name: unit.name, kind: kind) + guard let classID = await loadedClass(owner), + let fields = try? await fields(of: classID) else { continue } + let statics = fields.filter { $0.isStatic && !$0.name.hasPrefix("_") } + guard !statics.isEmpty, var reader = try? await send(OracleJDWPCommand.referenceType, OracleJDWPCommand.staticValues, { writer in + writer.id(classID, .referenceType) + writer.i32(Int32(statics.count)) + for field in statics { writer.id(field.id, .field) } + }), let count = try? reader.count(minimumSize: 1) else { continue } + for field in statics.prefix(count) { + guard let value = try? reader.taggedValue() else { break } + result.append(OracleDebugVariable( + name: field.name, + kind: .package, + typeName: OracleDebugUnit.typeName(fromJDWPSignature: field.signature), + value: await describe(value), + isConstant: false + )) + } + } + return result + } + + private func loadedClass(_ unit: OracleDebugUnit) async -> UInt64? { + if let id = classIDs[unit] { return id } + guard var reader = try? await send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.classesBySignature, { writer in + writer.string(unit.jdwpSignature) + }), let count = try? reader.count(minimumSize: 1 + sizes.referenceType + 4), count > 0 else { return nil } + _ = try? reader.u8() + guard let id = try? reader.id(.referenceType) else { return nil } + classIDs[unit] = id + if classes[id] == nil { classes[id] = ClassInfo(id: id, unit: unit) } + return id + } + + private func fields(of typeID: UInt64) async throws -> [OracleJDWPField] { + if let fields = fieldCache[typeID] { return fields } + let reply = try await connection.send(OracleJDWPCommand.referenceType, OracleJDWPCommand.fields, idData(typeID, .referenceType)) + let fields = try OracleJDWPField.decodeList(reply, sizes: sizes) + fieldCache[typeID] = fields + return fields + } + + private func describe(_ value: OracleJDWPValue, depth: Int = 0) async -> OracleDebugValue { + guard let objectID = value.objectID else { return .unavailable } + guard objectID != 0 else { return .scalar(nil) } + guard var typeReader = try? await send(OracleJDWPCommand.objectReference, OracleJDWPCommand.objectReferenceType, { writer in + writer.id(objectID, .object) + }), (try? typeReader.u8()) != nil, let typeID = try? typeReader.id(.referenceType), + let fields = try? await fields(of: typeID) else { return .unavailable } + switch OracleDebugValueShape(fields: fields) { + case .scalar(let field): + guard let values = try? await objectValues(objectID, fields: [field]), let text = values.first else { + return .unavailable + } + return .scalar(await string(text)) + case .record(let members): + guard depth == 0, let values = try? await objectValues(objectID, fields: members.map(\.id)) else { + return .record([]) + } + var described: [OracleDebugRecordMember] = [] + for (member, memberValue) in zip(members, values) { + if case .scalar(let text) = await describe(memberValue, depth: depth + 1) { + described.append(OracleDebugRecordMember(name: member.name, value: text)) + } + } + return .record(described) + case .collection(let field): + guard let values = try? await objectValues(objectID, fields: [field]), + let arrayID = values.first?.objectID, arrayID != 0, + var reader = try? await send(OracleJDWPCommand.arrayReference, OracleJDWPCommand.arrayLength, { writer in + writer.id(arrayID, .object) + }), let length = try? reader.i32(), length >= 0 else { return .collection(count: nil) } + return .collection(count: Int(length)) + case .opaque: + return .unavailable + } + } + + private func objectValues(_ objectID: UInt64, fields: [UInt64]) async throws -> [OracleJDWPValue] { + var reader = try await send(OracleJDWPCommand.objectReference, OracleJDWPCommand.objectValues) { writer in + writer.id(objectID, .object) + writer.i32(Int32(fields.count)) + for field in fields { writer.id(field, .field) } + } + let count = try reader.count(minimumSize: 1) + var values: [OracleJDWPValue] = [] + for _ in 0.. String? { + guard let id = value.objectID, id != 0, + var reader = try? await send(OracleJDWPCommand.stringReference, OracleJDWPCommand.stringValue, { writer in + writer.id(id, .object) + }) else { return nil } + return try? reader.string() + } + + // MARK: - Stop + + // Closing the socket without Dispose ends the target session in any state: ORA-30687 within a second while it + // was running a loop, and while paused (measured). The server rolls its transaction back with it. + // Closing the socket first fails every reply still awaited, so a read or breakpoint call Stop overlaps throws at + // once, and it ends the target: PL/SQL sees the close and ends with ORA-30687 within a second (measured). + public func stop() async -> OracleDebugStopResult { + if stopRequested { + if !isStopped { + await withCheckedContinuation { stopWaiters.append($0) } + } + return stopResult + } + stopRequested = true + await connection.close() + if let call { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: OracleDebugStopBudget.total - OracleDebugStopBudget.cleanup) + // A call that never returns to PL/SQL (DBMS_SESSION.SLEEP, measured) does not see the close, so it is + // killed from the helper session, as is one whose own connection failed under it. + var ended = await OracleDebugSupport.endedOnServer(call, within: 1) + if !ended { + ended = await OracleDebugStopBudget.endRunningTarget(clock: clock, budget: clock.now.duration(to: deadline)) { + await OracleDebugSupport.kill( + targetIdentity, + from: helper, + within: OracleDebugStopBudget.seconds(clock.now.duration(to: deadline)) + ) + } waitForEnd: { remaining in + await OracleDebugSupport.endedOnServer(call, within: OracleDebugStopBudget.seconds(remaining)) + } + } + if !ended { + gaveUp = true + target.cancelCurrentQuery() + } + stopResult = ended ? .ended : .unconfirmed(targetIdentity) + } else { + // Before the routine ran the target held no work; the closed socket ends its session. + _ = await OracleDebugSupport.value(of: connectCall, within: 1) + stopResult = .ended + } + state = .ended + post(.connectionClosed) + pump?.cancel() + noticeContinuation.finish() + isStopped = true + let waiters = stopWaiters + stopWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + return stopResult + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPListener.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPListener.swift new file mode 100644 index 0000000000..8b2bde323e --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPListener.swift @@ -0,0 +1,275 @@ +import Foundation +import Network +import os +import OSLog + +private let logger = Logger(subsystem: "com.TablePro", category: "OracleJDWPListener") + +public enum OracleJDWPListenerError: Error, Sendable, Equatable { + case noFreePort(ClosedRange) + // `rejected` lists the addresses that connected and were not the database server's. + case timedOut(rejected: [String]) + case cancelled +} + +// Waits for the database to connect back. It binds the given local addresses on one random port of the range, +// accepts only peers from the database server's addresses, gives each two seconds to send the JDWP handshake, and +// keeps the first that does: every other connection closes and the listener stops. +public final class OracleJDWPListener: @unchecked Sendable { + public let port: UInt16 + private let listeners: [NWListener] + private let queue: DispatchQueue + private let allowedPeers: Set? + private let state = OSAllocatedUnfairLock(initialState: State()) + + // NWConnection is Sendable only from macOS 14; it is thread-safe on every release this runs on. + private struct Peer: @unchecked Sendable { + let connection: NWConnection + } + + private struct State { + var peers: [ObjectIdentifier: Peer] = [:] + var rejected: [String] = [] + var result: Result? + var waiter: CheckedContinuation? + var isFinished = false + } + + static let handshakeSeconds: Double = 2 + static let portAttempts = 64 + + private init(port: UInt16, listeners: [NWListener], queue: DispatchQueue, allowedPeers: Set?) { + self.port = port + self.listeners = listeners + self.queue = queue + self.allowedPeers = allowedPeers + } + + // `allowedPeers` nil accepts any address; `bindAddresses` nil listens on every interface. + public static func open( + ports: ClosedRange, + bindAddresses: [String]?, + allowedPeers: Set? + ) async throws -> OracleJDWPListener { + for port in ports.shuffled().prefix(portAttempts) { + if let listener = await bind(port: port, addresses: bindAddresses, allowedPeers: allowedPeers) { + return listener + } + } + throw OracleJDWPListenerError.noFreePort(ports) + } + + private static func bind(port: UInt16, addresses: [String]?, allowedPeers: Set?) async -> OracleJDWPListener? { + guard let endpointPort = NWEndpoint.Port(rawValue: port) else { return nil } + var made: [NWListener] = [] + for address in addresses ?? [] { + let parameters = NWParameters.tcp + // The required local endpoint carries the port; naming it again in `on:` fails the listener. + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(address), port: endpointPort) + guard let listener = try? NWListener(using: parameters) else { return nil } + made.append(listener) + } + if addresses == nil { + guard let listener = try? NWListener(using: .tcp, on: endpointPort) else { return nil } + made.append(listener) + } + guard !made.isEmpty else { return nil } + let queue = DispatchQueue(label: "com.TablePro.OracleJDWPListener.\(port)") + let instance = OracleJDWPListener(port: port, listeners: made, queue: queue, allowedPeers: allowedPeers) + for listener in made { + listener.newConnectionHandler = { [weak instance] peer in + guard let instance else { + peer.cancel() + return + } + instance.arrived(peer) + } + } + for listener in made { + guard await start(listener, on: queue) else { + for other in made { + other.cancel() + } + return nil + } + } + return instance + } + + private static func start(_ listener: NWListener, on queue: DispatchQueue) async -> Bool { + await withCheckedContinuation { (result: CheckedContinuation) in + let resumed = OSAllocatedUnfairLock(initialState: false) + let resume: @Sendable (Bool) -> Void = { value in + let first = resumed.withLock { done -> Bool in + defer { done = true } + return !done + } + if first { result.resume(returning: value) } + } + listener.stateUpdateHandler = { state in + switch state { + case .ready: resume(true) + case .failed, .cancelled: resume(false) + default: break + } + } + listener.start(queue: queue) + } + } + + public var rejectedPeers: [String] { + state.withLock { $0.rejected } + } + + private func arrived(_ peer: NWConnection) { + let address = Self.address(of: peer.endpoint) + let admitted = state.withLock { current -> Bool in + guard !current.isFinished else { return false } + if let allowedPeers, !allowedPeers.contains(address ?? "") { + current.rejected.append(address ?? String(describing: peer.endpoint)) + return false + } + current.peers[ObjectIdentifier(peer)] = Peer(connection: peer) + return true + } + guard admitted else { + logger.notice("Closed a debugger connection from an address that is not the database server's") + peer.cancel() + return + } + Task { await self.handshake(peer) } + } + + private func handshake(_ peer: NWConnection) async { + let ready = (try? await withOracleTimeout(seconds: Self.handshakeSeconds, onTimeout: { peer.cancel() }) { + await self.start(peer) + }) ?? false + guard ready else { + drop(peer) + return + } + let connection = OracleJDWPConnection(transport: OracleJDWPNetworkTransport(connection: peer)) + do { + try await withOracleTimeout(seconds: Self.handshakeSeconds, onTimeout: { peer.cancel() }) { + try await connection.handshake() + } + } catch { + await connection.close() + drop(peer) + return + } + await claim(connection, peer: peer) + } + + private func start(_ peer: NWConnection) async -> Bool { + await withCheckedContinuation { (result: CheckedContinuation) in + let resumed = OSAllocatedUnfairLock(initialState: false) + let resume: @Sendable (Bool) -> Void = { value in + let first = resumed.withLock { done -> Bool in + defer { done = true } + return !done + } + if first { result.resume(returning: value) } + } + peer.stateUpdateHandler = { state in + switch state { + case .ready: resume(true) + case .failed, .cancelled: resume(false) + default: break + } + } + peer.start(queue: queue) + } + } + + private func drop(_ peer: NWConnection) { + state.withLock { _ = $0.peers.removeValue(forKey: ObjectIdentifier(peer)) } + peer.cancel() + } + + private func claim(_ connection: OracleJDWPConnection, peer: NWConnection) async { + let claimed = state.withLock { current -> (others: [Peer], waiter: CheckedContinuation?)? in + guard !current.isFinished else { return nil } + current.isFinished = true + current.peers.removeValue(forKey: ObjectIdentifier(peer)) + let others = Array(current.peers.values) + current.peers.removeAll() + let waiter = current.waiter + current.waiter = nil + if waiter == nil { current.result = .success(connection) } + return (others, waiter) + } + guard let claimed else { + await connection.close() + return + } + cancelListeners() + for other in claimed.others { + other.connection.cancel() + } + claimed.waiter?.resume(returning: connection) + } + + private func finish(with error: Error) { + let ended = state.withLock { current -> (others: [Peer], waiter: CheckedContinuation?)? in + guard !current.isFinished else { return nil } + current.isFinished = true + let others = Array(current.peers.values) + current.peers.removeAll() + let waiter = current.waiter + current.waiter = nil + if waiter == nil { current.result = .failure(error) } + return (others, waiter) + } + cancelListeners() + guard let ended else { return } + for other in ended.others { + other.connection.cancel() + } + ended.waiter?.resume(throwing: error) + } + + public func accept(timeout: Duration) async throws -> OracleJDWPConnection { + let timer = Task { [weak self] in + guard (try? await Task.sleep(for: timeout)) != nil, let self else { return } + finish(with: OracleJDWPListenerError.timedOut(rejected: rejectedPeers)) + } + defer { timer.cancel() } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let ready = state.withLock { current -> Result? in + if let result = current.result { + current.result = nil + return result + } + if current.isFinished { return .failure(OracleJDWPListenerError.cancelled) } + current.waiter = continuation + return nil + } + if let ready { continuation.resume(with: ready) } + } + } onCancel: { + finish(with: CancellationError()) + } + } + + public func cancel() { + finish(with: OracleJDWPListenerError.cancelled) + } + + private func cancelListeners() { + for listener in listeners { + listener.cancel() + } + } + + static func address(of endpoint: NWEndpoint) -> String? { + guard case let .hostPort(host, _) = endpoint else { return nil } + switch host { + case .ipv4(let address): return OracleDebugNetwork.normalized("\(address)") + case .ipv6(let address): return OracleDebugNetwork.normalized("\(address)") + case .name(let name, _): return OracleDebugNetwork.normalized(name) + @unknown default: return nil + } + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPPacket.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPPacket.swift new file mode 100644 index 0000000000..c40d1f71ad --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPPacket.swift @@ -0,0 +1,444 @@ +import Foundation + +public struct OracleJDWPIDSizes: Sendable, Equatable { + public var field: Int + public var method: Int + public var object: Int + public var referenceType: Int + public var frame: Int + + public init(field: Int, method: Int, object: Int, referenceType: Int, frame: Int) { + self.field = field + self.method = method + self.object = object + self.referenceType = referenceType + self.frame = frame + } + + // Measured on 23.26: 4, 4, 4, 4, 8. + public static let oracle = OracleJDWPIDSizes(field: 4, method: 4, object: 4, referenceType: 4, frame: 8) + + // The peer chooses these; an ID wider than 8 bytes cannot be held, and a zero-width one is no ID. + public static func validated(field: Int32, method: Int32, object: Int32, referenceType: Int32, frame: Int32) throws + -> OracleJDWPIDSizes { + let widths = [field, method, object, referenceType, frame].map(Int.init) + guard widths.allSatisfy({ (1...8).contains($0) }) else { throw OracleJDWPError.malformedPacket } + return OracleJDWPIDSizes(field: widths[0], method: widths[1], object: widths[2], referenceType: widths[3], frame: widths[4]) + } +} + +public enum OracleJDWPIDKind: Sendable { + case field + case method + case object + case referenceType + case frame +} + +extension OracleJDWPIDSizes { + func size(of kind: OracleJDWPIDKind) -> Int { + switch kind { + case .field: return field + case .method: return method + case .object: return object + case .referenceType: return referenceType + case .frame: return frame + } + } +} + +public enum OracleJDWPError: Error, Sendable, Equatable { + case truncated + case malformedPacket + case handshakeFailed + case closed + case reply(code: Int) + case timedOut + case unexpectedEvent +} + +public struct OracleJDWPWriter: Sendable { + public private(set) var data = Data() + let sizes: OracleJDWPIDSizes + + public init(sizes: OracleJDWPIDSizes) { + self.sizes = sizes + } + + public mutating func u8(_ value: UInt8) { + data.append(value) + } + + public mutating func i32(_ value: Int32) { + withUnsafeBytes(of: value.bigEndian) { data.append(contentsOf: $0) } + } + + public mutating func i64(_ value: Int64) { + withUnsafeBytes(of: value.bigEndian) { data.append(contentsOf: $0) } + } + + public mutating func string(_ value: String) { + let bytes = Array(value.utf8) + i32(Int32(bytes.count)) + data.append(contentsOf: bytes) + } + + public mutating func id(_ value: UInt64, _ kind: OracleJDWPIDKind) { + let width = min(max(sizes.size(of: kind), 1), 8) + for shift in stride(from: (width - 1) * 8, through: 0, by: -8) { + data.append(UInt8(truncatingIfNeeded: value >> UInt64(shift))) + } + } + + public mutating func location(_ location: OracleJDWPLocation) { + u8(location.typeTag) + id(location.classID, .referenceType) + id(location.methodID, .method) + i64(location.index) + } +} + +public struct OracleJDWPReader: Sendable { + private let bytes: [UInt8] + private var offset = 0 + let sizes: OracleJDWPIDSizes + + public init(_ data: Data, sizes: OracleJDWPIDSizes) { + self.bytes = Array(data) + self.sizes = sizes + } + + public var isAtEnd: Bool { offset >= bytes.count } + + public var remaining: Int { max(bytes.count - offset, 0) } + + // A count the peer sent, before a loop runs on it: never negative, and never more elements than the bytes left + // could hold at `minimumSize` bytes each. + public mutating func count(minimumSize: Int) throws -> Int { + let count = Int(try i32()) + guard count >= 0, count <= remaining / max(minimumSize, 1) else { throw OracleJDWPError.malformedPacket } + return count + } + + private mutating func take(_ count: Int) throws -> ArraySlice { + guard count >= 0, offset + count <= bytes.count else { throw OracleJDWPError.truncated } + defer { offset += count } + return bytes[offset.. UInt8 { + try take(1).first ?? 0 + } + + public mutating func i32() throws -> Int32 { + Int32(bitPattern: UInt32(try unsigned(4))) + } + + public mutating func i64() throws -> Int64 { + Int64(bitPattern: try unsigned(8)) + } + + public mutating func string() throws -> String { + let length = Int(try i32()) + let slice = try take(length) + return String(bytes: slice, encoding: .utf8) ?? String(bytes: slice, encoding: .isoLatin1) ?? "" + } + + public mutating func id(_ kind: OracleJDWPIDKind) throws -> UInt64 { + try unsigned(sizes.size(of: kind)) + } + + public mutating func location() throws -> OracleJDWPLocation { + OracleJDWPLocation( + typeTag: try u8(), + classID: try id(.referenceType), + methodID: try id(.method), + index: try i64() + ) + } + + public mutating func taggedValue() throws -> OracleJDWPValue { + let tag = try u8() + switch tag { + case UInt8(ascii: "B"), UInt8(ascii: "Z"): + return .primitive(tag: tag, bits: try unsigned(1)) + case UInt8(ascii: "C"), UInt8(ascii: "S"): + return .primitive(tag: tag, bits: try unsigned(2)) + case UInt8(ascii: "I"), UInt8(ascii: "F"): + return .primitive(tag: tag, bits: try unsigned(4)) + case UInt8(ascii: "J"), UInt8(ascii: "D"): + return .primitive(tag: tag, bits: try unsigned(8)) + case UInt8(ascii: "V"): + return .void + default: + return .object(tag: tag, id: try id(.object)) + } + } + + private mutating func unsigned(_ width: Int) throws -> UInt64 { + guard (0...8).contains(width) else { throw OracleJDWPError.malformedPacket } + var value: UInt64 = 0 + for byte in try take(width) { + value = value << 8 | UInt64(byte) + } + return value + } +} + +public struct OracleJDWPLocation: Sendable, Hashable { + public let typeTag: UInt8 + public let classID: UInt64 + public let methodID: UInt64 + public let index: Int64 + + public init(typeTag: UInt8 = 1, classID: UInt64, methodID: UInt64, index: Int64) { + self.typeTag = typeTag + self.classID = classID + self.methodID = methodID + self.index = index + } +} + +public enum OracleJDWPValue: Sendable, Equatable { + case primitive(tag: UInt8, bits: UInt64) + case object(tag: UInt8, id: UInt64) + case void + + public static let stringTag = UInt8(ascii: "s") + public static let objectTag = UInt8(ascii: "L") + public static let arrayTag = UInt8(ascii: "[") + + public var objectID: UInt64? { + if case .object(_, let id) = self { return id } + return nil + } +} + +public enum OracleJDWPPacket: Sendable, Equatable { + case command(id: UInt32, commandSet: UInt8, command: UInt8, data: Data) + case reply(id: UInt32, errorCode: UInt16, data: Data) + + static let headerLength = 11 + static let replyFlag: UInt8 = 0x80 + // Nothing this debugger asks for comes near it; a larger length is a peer trying to make the app buffer it. + public static let maximumLength = 16 * 1_024 * 1_024 + + public static let handshake = Data("JDWP-Handshake".utf8) + + public var encoded: Data { + var out = Data() + func append32(_ value: UInt32) { + withUnsafeBytes(of: value.bigEndian) { out.append(contentsOf: $0) } + } + switch self { + case let .command(id, commandSet, command, data): + append32(UInt32(Self.headerLength + data.count)) + append32(id) + out.append(0) + out.append(commandSet) + out.append(command) + out.append(data) + case let .reply(id, errorCode, data): + append32(UInt32(Self.headerLength + data.count)) + append32(id) + out.append(Self.replyFlag) + withUnsafeBytes(of: errorCode.bigEndian) { out.append(contentsOf: $0) } + out.append(data) + } + return out + } + + // Nil until `bytes` holds a whole packet; the consumed length comes back with it. + public static func decode(from bytes: Data) throws -> (packet: OracleJDWPPacket, length: Int)? { + guard bytes.count >= headerLength else { return nil } + let start = bytes.startIndex + func u32(at offset: Int) -> UInt32 { + (0..<4).reduce(UInt32(0)) { $0 << 8 | UInt32(bytes[start + offset + $1]) } + } + let length = Int(u32(at: 0)) + guard length >= headerLength, length <= maximumLength else { throw OracleJDWPError.malformedPacket } + guard bytes.count >= length else { return nil } + let id = u32(at: 4) + let flags = bytes[start + 8] + let body = Data(bytes[(start + headerLength)..<(start + length)]) + if flags & replyFlag != 0 { + let code = UInt16(bytes[start + 9]) << 8 | UInt16(bytes[start + 10]) + return (.reply(id: id, errorCode: code, data: body), length) + } + return (.command(id: id, commandSet: bytes[start + 9], command: bytes[start + 10], data: body), length) + } +} + +public struct OracleJDWPFrameBuffer: Sendable { + private var pending = Data() + + public init() {} + + public mutating func append(_ data: Data) { + pending.append(data) + } + + public mutating func nextPacket() throws -> OracleJDWPPacket? { + guard let (packet, length) = try OracleJDWPPacket.decode(from: pending) else { return nil } + pending = Data(pending.dropFirst(length)) + return packet + } + + public mutating func take(_ count: Int) -> Data? { + guard pending.count >= count else { return nil } + let head = Data(pending.prefix(count)) + pending = Data(pending.dropFirst(count)) + return head + } +} + +public enum OracleJDWPCommand { + public static let virtualMachine: UInt8 = 1 + public static let referenceType: UInt8 = 2 + public static let method: UInt8 = 6 + public static let objectReference: UInt8 = 9 + public static let stringReference: UInt8 = 10 + public static let threadReference: UInt8 = 11 + public static let arrayReference: UInt8 = 13 + public static let eventRequest: UInt8 = 15 + public static let stackFrame: UInt8 = 16 + public static let event: UInt8 = 64 + + public static let version: UInt8 = 1 + public static let classesBySignature: UInt8 = 2 + public static let suspend: UInt8 = 8 + public static let resume: UInt8 = 9 + public static let idSizes: UInt8 = 7 + + public static let signature: UInt8 = 1 + public static let fields: UInt8 = 4 + public static let methods: UInt8 = 5 + public static let staticValues: UInt8 = 6 + + public static let lineTable: UInt8 = 1 + public static let variableTable: UInt8 = 2 + + public static let objectReferenceType: UInt8 = 1 + public static let objectValues: UInt8 = 2 + + public static let stringValue: UInt8 = 1 + public static let frames: UInt8 = 6 + public static let arrayLength: UInt8 = 1 + + public static let set: UInt8 = 1 + public static let clear: UInt8 = 2 + + public static let frameValues: UInt8 = 1 + public static let composite: UInt8 = 100 +} + +public enum OracleJDWPEventKind: UInt8, Sendable { + case singleStep = 1 + case breakpoint = 2 + case classPrepare = 8 + case vmStart = 90 + case vmDeath = 99 +} + +public enum OracleJDWPEvent: Sendable, Equatable { + case vmStart(thread: UInt64) + case classPrepare(requestID: Int32, thread: UInt64, typeID: UInt64, signature: String) + case breakpoint(requestID: Int32, thread: UInt64, location: OracleJDWPLocation) + case singleStep(requestID: Int32, thread: UInt64, location: OracleJDWPLocation) + case vmDeath + case other(kind: UInt8) +} + +public struct OracleJDWPEventSet: Sendable, Equatable { + public let suspendPolicy: UInt8 + public let events: [OracleJDWPEvent] + + public static func decode(_ data: Data, sizes: OracleJDWPIDSizes) throws -> OracleJDWPEventSet { + var reader = OracleJDWPReader(data, sizes: sizes) + let policy = try reader.u8() + // An event is at least its kind and its request id. + let count = try reader.count(minimumSize: 5) + var events: [OracleJDWPEvent] = [] + for _ in 0.. Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(OracleJDWPEventKind.classPrepare.rawValue) + writer.u8(suspendAll) + writer.i32(1) + writer.u8(modifierClassMatch) + writer.string(pattern) + return writer.data + } + + public static func breakpoint(at location: OracleJDWPLocation, sizes: OracleJDWPIDSizes) -> Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(OracleJDWPEventKind.breakpoint.rawValue) + writer.u8(suspendAll) + writer.i32(1) + writer.u8(modifierLocationOnly) + writer.location(location) + return writer.data + } + + public static func step(thread: UInt64, depth: StepDepth, sizes: OracleJDWPIDSizes) -> Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(OracleJDWPEventKind.singleStep.rawValue) + writer.u8(suspendAll) + writer.i32(1) + writer.u8(modifierStep) + writer.id(thread, .object) + writer.i32(stepSizeLine) + writer.i32(depth.rawValue) + return writer.data + } + + public static func clear(kind: OracleJDWPEventKind, requestID: Int32, sizes: OracleJDWPIDSizes) -> Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(kind.rawValue) + writer.i32(requestID) + return writer.data + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OraclePLSQLDeclarationScan.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OraclePLSQLDeclarationScan.swift new file mode 100644 index 0000000000..a8798b9be8 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OraclePLSQLDeclarationScan.swift @@ -0,0 +1,582 @@ +import Foundation + +// DBMS_DEBUG has no call that lists a frame's variables; GET_VALUE takes a name. The names come from the unit's +// ALL_SOURCE text: PL/Scope would give exact declarations, but only for units compiled with PLSCOPE_SETTINGS +// 'IDENTIFIERS:ALL' (the default is NONE, and the compile for debug keeps the unit's settings), and on 26ai its +// USAGE_CONTEXT_ID came back 0 for every row, so scopes would still be guessed from lines. A name this scan offers +// that is not in scope comes back from GET_VALUE as no_such_object and is dropped, so the server decides scope. +public struct OraclePLSQLDeclarationScan: Sendable, Equatable { + public enum Kind: Sendable, Equatable { + case parameter + case variable + case constant + case iterator + } + + public struct Declaration: Sendable, Equatable { + public let name: String + public let isQuoted: Bool + public let typeText: String? + public let line: Int + public let kind: Kind + // Nil for a package-level variable. + public let subprogram: Int? + let block: Int? + + // GET_VALUE reads its name as an identifier path, so `"a.b"` unquoted would name member b of a, and a lower + // case or spaced name would not be found at all. + public var lookupName: String { isQuoted ? "\"\(name)\"" : name } + } + + public struct Subprogram: Sendable, Equatable { + public let name: String + public let headerLine: Int + public internal(set) var endLine: Int? + public let parent: Int? + public internal(set) var isDefinition: Bool + + public var depth: Int = 1 + } + + struct Block: Sendable, Equatable { + let startLine: Int + var endLine: Int? + } + + public private(set) var declarations: [Declaration] = [] + public private(set) var subprograms: [Subprogram] = [] + private var blocks: [Block] = [] + + public init(source: String) { + var parser = Parser(tokens: Lexer.tokens(of: source)) + parser.run() + declarations = parser.declarations.filter { declaration in + guard let index = declaration.subprogram else { return true } + return parser.subprograms[index].isDefinition + } + subprograms = parser.subprograms + blocks = parser.blocks + } + + // The header lines of the unit-level subprograms named `name`: every overload, since the first one entered is the + // one the debugger called. + public func headerLines(ofSubprogramNamed name: String) -> [Int] { + subprograms.filter { $0.isDefinition && $0.depth == 1 && $0.name == name }.map(\.headerLine) + } + + public func innermostSubprogram(containing line: Int) -> Int? { + var best: Int? + for (index, subprogram) in subprograms.enumerated() where subprogram.isDefinition { + guard subprogram.headerLine <= line, line <= (subprogram.endLine ?? Int.max) else { continue } + if let current = best, subprograms[current].headerLine > subprogram.headerLine { continue } + best = index + } + return best + } + + // Parameters and locals of the subprogram running at `line`, then of each subprogram around it. + public func candidates(atLine line: Int) -> [Declaration] { + guard let innermost = innermostSubprogram(containing: line) else { return [] } + var chain: [Int] = [] + var current: Int? = innermost + while let index = current { + chain.append(index) + current = subprograms[index].parent + } + var visible: [Declaration] = [] + // The lexer upper-cases unquoted names, so `name` compares as Oracle resolves: `y` is `"Y"`, `x` is not `"x"`. + var innermostBlock: [String: Int] = [:] + for index in chain { + for declaration in declarations where declaration.subprogram == index && encloses(declaration.block, line) { + visible.append(declaration) + // Blocks are numbered as they open, so of the blocks around one line the innermost has the highest. + if let block = declaration.block, block > innermostBlock[declaration.name] ?? -1 { + innermostBlock[declaration.name] = block + } + } + } + var seen: Set = [] + return visible.filter { declaration in + declaration.block == innermostBlock[declaration.name] && seen.insert(declaration.name).inserted + } + } + + private func encloses(_ block: Int?, _ line: Int) -> Bool { + guard let block else { return false } + return blocks[block].startLine <= line && line <= (blocks[block].endLine ?? Int.max) + } + + public var packageVariables: [Declaration] { + declarations.filter { $0.subprogram == nil } + } +} + +extension OraclePLSQLDeclarationScan { + struct Token: Equatable { + enum Kind: Equatable { + case word + case quoted + case symbol + case other + } + + let kind: Kind + let text: String + let line: Int + + func isWord(_ words: String...) -> Bool { + kind == .word && words.contains(text) + } + + func isSymbol(_ symbol: String) -> Bool { + kind == .symbol && text == symbol + } + + var isName: Bool { kind == .word || kind == .quoted } + } + + enum Lexer { + static func tokens(of source: String) -> [Token] { + let characters = Array(source.unicodeScalars) + var tokens: [Token] = [] + var index = 0 + var line = 1 + func peek(_ offset: Int = 0) -> Unicode.Scalar? { + index + offset < characters.count ? characters[index + offset] : nil + } + func isWordStart(_ scalar: Unicode.Scalar) -> Bool { + scalar.properties.isAlphabetic + } + func isWordPart(_ scalar: Unicode.Scalar) -> Bool { + scalar.properties.isAlphabetic || ("0"..."9").contains(scalar) || scalar == "_" || scalar == "$" || scalar == "#" + } + func skipString(closing: Unicode.Scalar, doubled: Bool) { + while let scalar = peek() { + index += 1 + if scalar == "\n" { line += 1 } + if scalar == closing { + if doubled, peek() == closing { + index += 1 + continue + } + if !doubled, peek() == "'" { + index += 1 + return + } + if doubled { return } + } + } + } + while let scalar = peek() { + if scalar == "\n" { + line += 1 + index += 1 + } else if scalar.properties.isWhitespace { + index += 1 + } else if scalar == "-", peek(1) == "-" { + while let next = peek(), next != "\n" { index += 1 } + } else if scalar == "/", peek(1) == "*" { + index += 2 + while let next = peek(), !(next == "*" && peek(1) == "/") { + if next == "\n" { line += 1 } + index += 1 + } + index += 2 + } else if scalar == "'" { + let startLine = line + index += 1 + skipString(closing: "'", doubled: true) + tokens.append(Token(kind: .other, text: "'", line: startLine)) + } else if scalar == "q" || scalar == "Q", peek(1) == "'", let open = peek(2) { + let startLine = line + let closing: Unicode.Scalar + switch open { + case "[": closing = "]" + case "{": closing = "}" + case "(": closing = ")" + case "<": closing = ">" + default: closing = open + } + index += 3 + skipString(closing: closing, doubled: false) + tokens.append(Token(kind: .other, text: "'", line: startLine)) + } else if scalar == "\"" { + let startLine = line + index += 1 + var text = String.UnicodeScalarView() + while let next = peek(), next != "\"" { + if next == "\n" { line += 1 } + text.append(next) + index += 1 + } + index += 1 + tokens.append(Token(kind: .quoted, text: String(text), line: startLine)) + } else if isWordStart(scalar) { + var text = String.UnicodeScalarView() + while let next = peek(), isWordPart(next) { + text.append(next) + index += 1 + } + tokens.append(Token(kind: .word, text: String(text).uppercased(), line: line)) + } else if ("0"..."9").contains(scalar) { + var text = String.UnicodeScalarView() + while let next = peek(), isWordPart(next) || next == "." { + text.append(next) + index += 1 + } + tokens.append(Token(kind: .other, text: String(text), line: line)) + } else if scalar == ":", peek(1) == "=" { + tokens.append(Token(kind: .symbol, text: ":=", line: line)) + index += 2 + } else if scalar == "=", peek(1) == ">" { + tokens.append(Token(kind: .symbol, text: "=>", line: line)) + index += 2 + } else { + tokens.append(Token(kind: .symbol, text: String(scalar), line: line)) + index += 1 + } + } + return tokens + } + } + + struct Parser { + enum Phase { + case declare + case body + } + + enum Frame { + case unit(Phase) + case subprogram(Int, block: Int, Phase) + case block(Int, Phase) + case caseExpression + // A FOR loop's block holds its index, from the FOR to its END LOOP; other loops hold nothing. + case loop(Int?) + } + + let tokens: [Token] + var position = 0 + var stack: [Frame] = [] + var declarations: [Declaration] = [] + var subprograms: [Subprogram] = [] + var blocks: [Block] = [] + var pendingLoopBlock: Int? + + init(tokens: [Token]) { + self.tokens = tokens + } + + var current: Token? { position < tokens.count ? tokens[position] : nil } + + func peekToken(at offset: Int) -> Token? { + position + offset < tokens.count ? tokens[position + offset] : nil + } + + var currentSubprogram: Int? { + for frame in stack.reversed() { + if case .subprogram(let index, _, _) = frame { return index } + } + return nil + } + + var currentBlock: Int? { + for frame in stack.reversed() { + switch frame { + case .subprogram(_, let block, _), .block(let block, _): return block + case .unit, .caseExpression, .loop: continue + } + } + return nil + } + + var subprogramDepth: Int { + stack.reduce(0) { count, frame in + if case .subprogram = frame { return count + 1 } + return count + } + } + + mutating func run() { + readUnitHeader() + while let token = current { + guard let frame = stack.last else { + if token.isWord("PROCEDURE", "FUNCTION") { + readSubprogramHeader() + } else if token.isWord("DECLARE") { + stack.append(.block(openBlock(at: token.line), .declare)) + position += 1 + } else if token.isWord("BEGIN") { + stack.append(.block(openBlock(at: token.line), .body)) + position += 1 + } else { + position += 1 + } + continue + } + switch frame { + case .unit(.declare), .subprogram(_, _, .declare), .block(_, .declare): + readDeclaration() + case .unit(.body), .subprogram(_, _, .body), .block(_, .body), .caseExpression, .loop: + readBodyToken() + } + } + } + + // PACKAGE [BODY] name IS | TYPE BODY name IS | TRIGGER ... ; a standalone PROCEDURE or FUNCTION is read as + // a subprogram. + mutating func readUnitHeader() { + guard let first = current else { return } + if first.isWord("CREATE") { + while let token = current, !token.isWord("PACKAGE", "PROCEDURE", "FUNCTION", "TYPE", "TRIGGER") { + position += 1 + } + } + guard let head = current else { return } + if head.isWord("PACKAGE", "TYPE") { + while let token = current, !token.isWord("IS", "AS") { position += 1 } + position += 1 + stack.append(.unit(.declare)) + } else if head.isWord("TRIGGER") { + while let token = current, !token.isWord("DECLARE", "BEGIN", "COMPOUND") { position += 1 } + } + } + + mutating func readSubprogramHeader() { + guard let keyword = current else { return } + position += 1 + guard let nameToken = current, nameToken.isName else { return } + position += 1 + let index = subprograms.count + var subprogram = Subprogram( + name: nameToken.text, + headerLine: keyword.line, + endLine: nil, + parent: currentSubprogram, + isDefinition: false + ) + subprogram.depth = subprogramDepth + 1 + subprograms.append(subprogram) + // From the header line: the first stop is there, with the parameters already bound. + let block = openBlock(at: keyword.line) + if current?.isSymbol("(") == true { + readParameters(of: index, block: block) + } + var depth = 0 + while let token = current { + if token.isSymbol("(") { depth += 1 } + if token.isSymbol(")") { depth -= 1 } + if depth == 0, token.isSymbol(";") { + position += 1 + return + } + if depth == 0, token.isWord("IS", "AS") { + position += 1 + // `IS LANGUAGE C` / `AS EXTERNAL` bodies have no PL/SQL to step through. + if current?.isWord("LANGUAGE", "EXTERNAL") == true { + skipStatement() + return + } + subprograms[index].isDefinition = true + stack.append(.subprogram(index, block: block, .declare)) + return + } + position += 1 + } + } + + mutating func readParameters(of subprogram: Int, block: Int) { + position += 1 + var parts: [Token] = [] + var depth = 1 + while let token = current { + position += 1 + if token.isSymbol("(") { depth += 1 } + if token.isSymbol(")") { + depth -= 1 + if depth == 0 { + addParameter(parts, subprogram: subprogram, block: block) + return + } + } + if depth == 1, token.isSymbol(",") { + addParameter(parts, subprogram: subprogram, block: block) + parts = [] + continue + } + parts.append(token) + } + } + + mutating func addParameter(_ parts: [Token], subprogram: Int, block: Int) { + guard let name = parts.first, name.isName else { return } + var rest = parts.dropFirst() + while let mode = rest.first, mode.isWord("IN", "OUT", "NOCOPY") { rest = rest.dropFirst() } + let type = rest.prefix { !$0.isSymbol(":=") && !$0.isWord("DEFAULT") } + declarations.append(Declaration( + name: name.text, + isQuoted: name.kind == .quoted, + typeText: Self.typeText(type), + line: name.line, + kind: .parameter, + subprogram: subprogram, + block: block + )) + } + + mutating func readDeclaration() { + guard let token = current, let frame = stack.last else { return } + if token.isWord("BEGIN") { + position += 1 + switch frame { + case .unit: replaceTop(with: .unit(.body)) + case .subprogram(let index, let block, _): replaceTop(with: .subprogram(index, block: block, .body)) + case .block(let block, _): replaceTop(with: .block(block, .body)) + case .caseExpression, .loop: break + } + return + } + if token.isWord("END") { + closeFrame(at: token.line) + skipStatement() + return + } + if token.isWord("PROCEDURE", "FUNCTION") { + readSubprogramHeader() + return + } + // A type body's method prefixes. + if token.isWord("MEMBER", "STATIC", "CONSTRUCTOR", "MAP", "ORDER", "OVERRIDING", "FINAL", "INSTANTIABLE") { + position += 1 + return + } + if token.isWord("TYPE", "SUBTYPE", "CURSOR", "PRAGMA") || !token.isName { + skipStatement() + return + } + position += 1 + var isConstant = false + if current?.isWord("CONSTANT") == true { + isConstant = true + position += 1 + } + if current?.isWord("EXCEPTION") == true { + skipStatement() + return + } + var typeTokens: [Token] = [] + var depth = 0 + while let next = current { + if next.isSymbol("(") { depth += 1 } + if next.isSymbol(")") { depth -= 1 } + if depth == 0, next.isSymbol(";") || next.isSymbol(":=") || next.isWord("DEFAULT", "NOT") { break } + typeTokens.append(next) + position += 1 + } + declarations.append(Declaration( + name: token.text, + isQuoted: token.kind == .quoted, + typeText: Self.typeText(typeTokens[...]), + line: token.line, + kind: isConstant ? .constant : .variable, + subprogram: currentSubprogram, + block: currentBlock + )) + skipStatement() + } + + mutating func readBodyToken() { + guard let token = current else { return } + position += 1 + if token.isWord("BEGIN") { + stack.append(.block(openBlock(at: token.line), .body)) + } else if token.isWord("DECLARE") { + stack.append(.block(openBlock(at: token.line), .declare)) + } else if token.isWord("CASE") { + stack.append(.caseExpression) + } else if token.isWord("LOOP") { + stack.append(.loop(pendingLoopBlock)) + pendingLoopBlock = nil + } else if token.isWord("END") { + if current?.isWord("LOOP") == true { + position += 1 + if case .loop = stack.last { closeFrame(at: token.line) } + } else if current?.isWord("IF") == true { + position += 1 + } else if current?.isWord("CASE") == true { + position += 1 + popCase() + } else if case .caseExpression = stack.last { + stack.removeLast() + } else { + closeFrame(at: token.line) + skipStatement() + } + } else if token.isWord("FOR"), let name = current, name.isName, peekToken(at: 1)?.isWord("IN") == true { + let block = openBlock(at: name.line) + pendingLoopBlock = block + declarations.append(Declaration( + name: name.text, + isQuoted: name.kind == .quoted, + typeText: nil, + line: name.line, + kind: .iterator, + subprogram: currentSubprogram, + block: block + )) + } + } + + mutating func popCase() { + if case .caseExpression = stack.last { stack.removeLast() } + } + + mutating func closeFrame(at line: Int) { + guard let frame = stack.popLast() else { return } + switch frame { + case .subprogram(let index, let block, _): + subprograms[index].endLine = line + blocks[block].endLine = line + case .block(let block, _), .loop(let block?): + blocks[block].endLine = line + case .unit, .caseExpression, .loop(nil): + break + } + } + + mutating func openBlock(at line: Int) -> Int { + blocks.append(Block(startLine: line)) + return blocks.count - 1 + } + + mutating func replaceTop(with frame: Frame) { + stack.removeLast() + stack.append(frame) + } + + mutating func skipStatement() { + var depth = 0 + while let token = current { + position += 1 + if token.isSymbol("(") { depth += 1 } + if token.isSymbol(")") { depth -= 1 } + if depth <= 0, token.isSymbol(";") { return } + } + } + + static func typeText(_ tokens: ArraySlice) -> String? { + var text = "" + for token in tokens { + let piece = token.kind == .quoted ? "\"\(token.text)\"" : token.text + if text.isEmpty || token.isSymbol("(") || token.isSymbol(")") || token.isSymbol(",") + || token.isSymbol("%") || token.isSymbol(".") || text.hasSuffix("(") || text.hasSuffix("%") + || text.hasSuffix(".") { + text += piece + } else { + text += " " + piece + } + } + return text.isEmpty ? nil : text + } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugSessionTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugSessionTests.swift new file mode 100644 index 0000000000..6994dd2cf9 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugSessionTests.swift @@ -0,0 +1,212 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +struct OracleDBMSDebugSessionTests { + private struct RefusedDelete: Error {} + + private typealias Statements = OracleDBMSDebugStatements + + private static let runCalc = OracleDebugUnit(owner: "PROBE", name: "RUN_CALC", kind: .procedure) + private static let invocationBlock = "BEGIN PROBE.RUN_CALC; END;" + private static let identity = OracleDebugSessionIdentity(sid: 42, serial: 7) + private static let anonymousFrame: [String?] = [nil, nil, "0", nil, "1", nil] + private static let runCalcFrame: [String?] = ["PROBE", "RUN_CALC", "1", "7", "3", nil] + // Lines 3, 5 and 7 have code. + private static let lineMap = payload(["0", "7", "A8"]) + + private static func payload(_ fields: [String?]) -> [String?] { + [OracleDebugPayload.encode(fields)] + } + + private static func event(terminated: Bool = false, line: Int? = nil, breakpoint: Int? = nil, frames: [[String?]] = []) -> [String?] { + let head: [String?] = [ + "0", + String(terminated ? Statements.Reason.knlExit : Statements.Reason.breakpoint), + terminated ? "1" : "0", + line.map(String.init), + breakpoint.map(String.init), + String(frames.count) + ] + return payload(head + frames.flatMap { $0 }) + } + + private static func isContinue(_ sql: String, flags: Int) -> Bool { + sql.contains("CONTINUE(l_info, \(flags),") + } + + // The routine's call waits until `ended` opens, as a target paused at the supervisor does, then fails with `endedWith`. + private func makeSession( + ended: Latch, + endedWith: any Error = OracleDebugError.server(code: 6_543, message: "ORA-06543"), + callEndWait: Duration = OracleDebugSupport.callEndWait, + supervisor respond: @escaping @Sendable (String) async throws -> [String?] + ) -> (OracleDBMSDebugSession, target: ScriptedChannel, supervisor: ScriptedChannel) { + let target = ScriptedChannel(rows: [[.string("42"), .string("7")]]) { sql in + guard sql == Self.invocationBlock else { return [] } + await ended.wait() + throw endedWith + } + let supervisor = ScriptedChannel { sql in + if Self.isContinue(sql, flags: Statements.BreakFlags.abortExecution) { + ended.open() + return Self.event(terminated: true) + } + return try await respond(sql) + } + let session = OracleDBMSDebugSession( + target: target, + supervisor: supervisor, + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: Self.runCalc, subprogram: "RUN_CALC", subprogramID: 1), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: Self.invocationBlock, binds: []), + callEndWait: callEndWait, + notices: [] + ) + return (session, target, supervisor) + } + + // The target paused at the supervisor, then an answer the first stop read did not parse. The target is still + // paused, so Stop aborts it there, which needs no ALTER SYSTEM, and the run ends confirmed. + @Test func testStopAbortsATargetAFailedFirstStopLeftPaused() async throws { + let ended = Latch() + defer { ended.open() } + let (session, _, supervisor) = makeSession(ended: ended) { sql in + if sql.contains("SYNCHRONIZE") { return Self.event(line: 1, frames: [Self.anonymousFrame]) } + if sql.contains("GET_LINE_MAP") { return ["not a payload"] } + return [] + } + try await session.start(breakpoints: []) + await #expect(throws: OracleDebugError.unreadableAnswer) { _ = try await session.firstStop() } + + #expect(await session.stop() == .ended) + let sent = supervisor.sent + #expect(sent.contains { Self.isContinue($0, flags: Statements.BreakFlags.abortExecution) }) + #expect(!sent.contains { $0.contains("ALTER SYSTEM KILL") }) + } + + // The target's own connection failed as the abort reached it, so its call ended on this side only and the server + // session may still hold its locks: Stop kills it through the supervisor and cannot confirm the end. An ORA- error + // is the server's answer, which does confirm it. + @Test(arguments: [ + (OracleCoreError.connectionClosed, false), + (OracleCoreError.queryFailed("ORA-06543: PL/SQL: execution aborted"), true) + ]) + func testStopConfirmsTheEndOnlyFromTheServersAnswer(endedWith: OracleCoreError, serverAnswered: Bool) async throws { + let ended = Latch() + defer { ended.open() } + let (session, _, supervisor) = makeSession(ended: ended, endedWith: endedWith) { sql in + if sql.contains("GET_LINE_MAP") { return Self.lineMap } + if sql.contains("SYNCHRONIZE") { return Self.event(line: 1, frames: [Self.anonymousFrame]) } + if sql.contains("SET_BREAKPOINT(l_program, 3,") { return Self.payload(["0", "1"]) } + if Self.isContinue(sql, flags: Statements.BreakFlags.breakpointsOnly) { + return Self.event(line: 3, breakpoint: 1, frames: [Self.anonymousFrame, Self.runCalcFrame]) + } + return [] + } + try await session.start(breakpoints: []) + #expect(try await session.firstStop() == .paused(OracleDebugLocation(unit: Self.runCalc, line: 3))) + + let outcome = await session.stop() + #expect(outcome == (serverAnswered ? .ended : .unconfirmed(Self.identity))) + #expect(supervisor.sent.contains(where: { $0.hasPrefix("ALTER SYSTEM KILL SESSION '42,7'") }) == !serverAnswered) + } + + // The supervisor saw the routine end, but its call has not returned. A ROLLBACK would queue behind that call on + // the target with no bound, so the run ends unconfirmed instead. + @Test func testARunWhoseCallStillRunsEndsUnconfirmedWithoutQueueingARollback() async throws { + let ended = Latch() + defer { ended.open() } + let (session, target, _) = makeSession(ended: ended, callEndWait: .milliseconds(200)) { sql in + sql.contains("SYNCHRONIZE") ? Self.event(terminated: true) : [] + } + try await session.start(breakpoints: []) + + let stop = await finished(within: 5) { try await session.firstStop() } + #expect(stop == .failed(.stillRunning(Self.identity))) + #expect(!target.sent.contains("ROLLBACK")) + } + + // A delete the server refuses leaves the breakpoint set, so removing it again deletes it again. + @Test func testARefusedDeleteCanBeRetried() async throws { + let ended = Latch() + defer { ended.open() } + let refusals = OSAllocatedUnfairLock(initialState: 1) + let (session, _, supervisor) = makeSession(ended: ended) { sql in + if sql.contains("GET_LINE_MAP") { return Self.lineMap } + if sql.contains("SYNCHRONIZE") { return Self.event(line: 1, frames: [Self.anonymousFrame]) } + if sql.contains("SET_BREAKPOINT(l_program, 3,") { return Self.payload(["0", "1"]) } + if sql.contains("SET_BREAKPOINT(l_program, 5,") { return Self.payload(["0", "2"]) } + if Self.isContinue(sql, flags: Statements.BreakFlags.breakpointsOnly) { + return Self.event(line: 3, breakpoint: 1, frames: [Self.anonymousFrame, Self.runCalcFrame]) + } + if sql.contains("DELETE_BREAKPOINT(2)"), refusals.withLock({ left in defer { left -= 1 }; return left > 0 }) { + throw RefusedDelete() + } + return [] + } + try await session.start(breakpoints: [(unit: Self.runCalc, line: 5)]) + #expect(try await session.firstStop() == .paused(OracleDebugLocation(unit: Self.runCalc, line: 3))) + + await #expect(throws: (any Error).self) { try await session.removeBreakpoint(in: Self.runCalc, line: 5) } + try await session.removeBreakpoint(in: Self.runCalc, line: 5) + #expect(supervisor.sent.filter { $0.contains("DELETE_BREAKPOINT(2)") }.count == 2) + #expect(await session.stop() == .ended) + } + + // A call that failed on its own dropped connection is not a routine that raised an error and rolled back. + @Test func testACallWhoseConnectionDroppedIsReportedAsALostConnection() async { + let entryPoint = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: Self.runCalc, subprogram: "RUN_CALC", subprogramID: 1), + overload: nil, + parameters: [], + result: nil + ) + func conclude(_ error: Error) async -> OracleDebugStop { + await OracleDebugSupport.conclude( + .failure(error), + stopped: false, + entryPoint: entryPoint, + target: ScriptedChannel(), + targetIdentity: Self.identity, + notice: { _ in } + ) + } + + guard case .failed(.connectionLost) = await conclude(OracleCoreError.connectionClosed) else { + Issue.record("a dropped connection read as the routine's own failure") + return + } + guard case .failed(.routineFailed) = await conclude(OracleCoreError.queryFailed("ORA-01476: divisor is equal to zero")) else { + Issue.record("the server's error did not read as the routine's failure") + return + } + } + + // A breakpoint sent at start on line 4 lands on line 5; the host never learns that and removes it by line 4. + @Test func testAStartBreakpointIsRemovedByTheLineItWasAskedFor() async throws { + let ended = Latch() + defer { ended.open() } + let (session, _, supervisor) = makeSession(ended: ended) { sql in + if sql.contains("GET_LINE_MAP") { return Self.lineMap } + if sql.contains("SYNCHRONIZE") { return Self.event(line: 1, frames: [Self.anonymousFrame]) } + if sql.contains("SET_BREAKPOINT(l_program, 3,") { return Self.payload(["0", "1"]) } + if sql.contains("SET_BREAKPOINT(l_program, 5,") { return Self.payload(["0", "2"]) } + if Self.isContinue(sql, flags: Statements.BreakFlags.breakpointsOnly) { + return Self.event(line: 3, breakpoint: 1, frames: [Self.anonymousFrame, Self.runCalcFrame]) + } + return [] + } + try await session.start(breakpoints: [(unit: Self.runCalc, line: 4)]) + #expect(try await session.firstStop() == .paused(OracleDebugLocation(unit: Self.runCalc, line: 3))) + + try await session.removeBreakpoint(in: Self.runCalc, line: 4) + #expect(supervisor.sent.contains { $0.contains("DELETE_BREAKPOINT(2)") }) + #expect(await session.stop() == .ended) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugStatementsTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugStatementsTests.swift new file mode 100644 index 0000000000..4d95febef9 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugStatementsTests.swift @@ -0,0 +1,134 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleDBMSDebugStatementsTests { + private typealias Statements = OracleDBMSDebugStatements + + // continue at the breakpoint on ADD_TAX line 6, called from RUN line 14 (the lane's measured stop). + @Test func testAPauseReadsTheRuntimeRecordAndTheBacktrace() throws { + let fields: [String?] = [ + "0", "3", "0", "6", "1", + "3", + nil, nil, "0", "0", "1", nil, + "PROBE", "PKG_CALC", "2", "11", "14", "RUN", + "PROBE", "PKG_CALC", "2", "11", "6", "ADD_TAX" + ] + let event = try Statements.event(fromPayload: fields) + #expect(event.isPause) + #expect(!event.timedOut) + #expect(event.reason == Statements.Reason.breakpoint) + #expect(event.line == 6) + #expect(event.breakpoint == 1) + #expect(event.backtrace.count == 3) + #expect(event.backtrace[0].unit == nil) + #expect(event.backtrace[2].unit == OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody)) + #expect(event.backtrace[1].entryPoint == "RUN") + } + + @Test func testTerminationAndTimeoutCarryNoBacktrace() throws { + let ended = try Statements.event(fromPayload: ["0", "25", "1", nil, nil, "0"]) + #expect(ended.terminated) + #expect(!ended.isPause) + let waiting = try Statements.event(fromPayload: ["31", "2", "0", nil, nil, "0"]) + #expect(waiting.timedOut) + #expect(!waiting.isPause) + } + + @Test func testResumeUsesTheMeasuredBreakFlags() { + #expect(Statements.resume(flags: Statements.BreakFlags.anyReturn).contains("SYS.DBMS_DEBUG.CONTINUE(l_info, 512, 14)")) + #expect(Statements.resume(flags: Statements.BreakFlags.nextLine).contains("CONTINUE(l_info, 32, 14)")) + #expect(Statements.resume(flags: Statements.BreakFlags.anyCall).contains("CONTINUE(l_info, 12, 14)")) + #expect(Statements.resume(flags: Statements.BreakFlags.abortExecution).contains("CONTINUE(l_info, 8192, 14)")) + #expect(Statements.resume(flags: nil).contains("SYS.DBMS_DEBUG.SYNCHRONIZE(l_info, 14)")) + // Asking a terminated program for its backtrace raises inside SYS.PBREAK. + #expect(Statements.resume(flags: nil).contains("IF l_status = 0 AND NVL(l_info.Terminated, 0) = 0 THEN")) + } + + // The target aborts itself when the supervisor goes quiet; without this a crashed app left it in `pipe get`. + @Test func testTheTargetAbortsOnTimeoutAndIsPinged() { + let setup = Statements.targetSetup(debugID: "TPX") + #expect(setup.binds == [.text("TPX")]) + #expect(setup.block.contains("SET_TIMEOUT_BEHAVIOUR(3)")) + #expect(setup.block.contains("SET_TIMEOUT(30)")) + #expect(setup.block.contains("'CALL SYS.DBMS_DEBUG.DEBUG_ON()'")) + #expect(Statements.pingIntervalSeconds * 2 < Statements.targetTimeoutSeconds) + #expect(Statements.supervisorTimeoutSeconds < Statements.pingIntervalSeconds) + } + + // GET_LINE_MAP for PROBE.PKG_CALC's body answered maxline 22 and EEFF3F. + @Test func testTheMeasuredLineMapDecodes() throws { + #expect(Statements.executableLines(hexMap: "EEFF3F", maxLine: 22) + == [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]) + #expect(try Statements.executableLines(fromPayload: ["0", "8", "FE"]) == [1, 2, 3, 4, 5, 6, 7]) + #expect(try Statements.executableLines(fromPayload: ["16", nil, nil]) == nil) + } + + @Test func testValueStatusesMapToValues() throws { + let answers = try Statements.valueAnswers(fromPayload: ["0", "1", "32", nil, "18", nil, "3", nil, "40", nil], count: 5) + #expect(answers.map { Statements.value(status: $0.status, text: $0.value) } == [ + .scalar("1"), .scalar(nil), .collection(count: nil), nil, .scalar(nil) + ]) + } + + // Read before DEBUG_ON: the target's SID and SERIAL#, which Stop needs to end a routine running between breakpoints. + @Test func testTheTargetSessionIsIdentifiedForAKill() { + #expect(Statements.sessionIdentity(fromRow: ["213", "28648", "2"]) + == OracleDebugSessionIdentity(sid: 213, serial: 28_648, instance: 2)) + #expect(Statements.sessionIdentity(fromRow: ["213", nil, "1"]) == OracleDebugSessionIdentity(sid: 213, serial: nil, instance: 1)) + #expect(Statements.sessionIdentity(fromRow: [nil, nil, "1"]) == nil) + #expect(Statements.killSession(OracleDebugSessionIdentity(sid: 213, serial: 28_648)) + == "ALTER SYSTEM KILL SESSION '213,28648' IMMEDIATE") + #expect(Statements.killSession(OracleDebugSessionIdentity(sid: 213, serial: 28_648, instance: 2)) + == "ALTER SYSTEM KILL SESSION '213,28648,@2' IMMEDIATE") + #expect(Statements.sessionIdentityQueries.allSatisfy { $0.contains("SYS_CONTEXT('USERENV', 'INSTANCE')") }) + #expect(Statements.killSession(OracleDebugSessionIdentity(sid: 213, serial: nil)) == nil) + #expect(Statements.sessionIdentityQueries.first?.contains("DBMS_DEBUG_JDWP.CURRENT_SESSION_SERIAL") == true) + } + + @Test func testBreakpointsAndValuesQuoteNames() { + let unit = OracleDebugUnit(owner: "PROBE", name: "O'NEIL", kind: .packageBody) + let set = Statements.setBreakpoint(unit: unit, line: 6) + #expect(set.contains("l_program.Namespace := 2;")) + #expect(set.contains("l_program.Name := 'O''NEIL';")) + #expect(set.contains("SET_BREAKPOINT(l_program, 6, l_number)")) + let values = Statements.values([ + .init(name: "P_AMOUNT", frame: 0), + .init(name: "G_RATE", package: OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody)) + ]) + #expect(values.block.contains("GET_VALUE(:1, 0, l_value, NULL)")) + #expect(values.block.contains("GET_VALUE(:2, l_handle, l_value, NULL)")) + #expect(values.binds == [.text("P_AMOUNT"), .text("G_RATE")]) + #expect(Statements.deleteBreakpoints([]).contains("NULL;")) + } + + // A quoted declaration is asked for quoted, so GET_VALUE reads one identifier, and the name stays a bind. + @Test func testQuotedVariableNamesAreBoundQuoted() { + let scan = OraclePLSQLDeclarationScan(source: """ + procedure p is + "a.b" number; + "lower" number; + "with space" number; + l_plain number; + begin + null; + end p; + """) + let requests = scan.candidates(atLine: 7).map { Statements.ValueRequest(name: $0.lookupName, frame: 0) } + let values = Statements.values(requests) + #expect(values.binds == [.text("\"a.b\""), .text("\"lower\""), .text("\"with space\""), .text("L_PLAIN")]) + #expect(values.block.contains("GET_VALUE(:4, 0, l_value, NULL)")) + #expect(!values.block.contains("a.b") && !values.block.contains("lower") && !values.block.contains("L_PLAIN")) + } + + // Every block that declares the payload helpers declares them last: PL/SQL wants items before subprograms. + @Test func testPayloadHelpersComeLastInEachDeclareSection() throws { + let unit = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + for block in [Statements.resume(flags: 0), Statements.setBreakpoint(unit: unit, line: 1), + Statements.lineMap(of: unit), Statements.values([.init(name: "X", frame: 0)]).block] { + let helper = try #require(block.range(of: "PROCEDURE put")) + let lastDeclaration = try #require(block.range(of: "l_status BINARY_INTEGER;")) + #expect(lastDeclaration.lowerBound < helper.lowerBound) + } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugLauncherTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugLauncherTests.swift new file mode 100644 index 0000000000..c787e632a0 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugLauncherTests.swift @@ -0,0 +1,74 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +struct OracleDebugLauncherTests { + private let entryPoint = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "F_CALC", kind: .function), subprogram: "F_CALC", subprogramID: 1), + overload: nil, + parameters: [OracleDebugParameter(name: "P_A", position: 1, dataType: "NUMBER", mode: .input, hasDefault: false)], + result: OracleDebugParameter(name: "", position: 0, dataType: "NUMBER", mode: .output, hasDefault: false) + ) + + private func launch(_ preference: OracleDebugProtocolPreference) -> OracleDebugLaunch { + OracleDebugLaunch( + entryPoint: entryPoint, + arguments: ["P_A": .text("1")], + breakpoints: [], + preference: preference, + debuggerHost: "127.0.0.1", + ports: 47_300...47_399, + databaseHost: "127.0.0.1", + databasePort: 1_521 + ) + } + + // A start the host cancelled opens nothing, so no target is left waiting. + @Test func testACancelledStartOpensNoSession() async { + let opened = OSAllocatedUnfairLock(initialState: 0) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await OracleDebugLauncher.launch(launch(.dbmsDebug)) { + opened.withLock { $0 += 1 } + return OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + } + } + await #expect(throws: CancellationError.self) { _ = try await task.value } + #expect(opened.withLock { $0 } == 0) + } + + @Test func testExplicitJDWPWithoutAHostIsRefusedBeforeAnySessionOpens() async { + let opened = OSAllocatedUnfairLock(initialState: 0) + let empty = OracleDebugLaunch( + entryPoint: entryPoint, + arguments: ["P_A": .text("1")], + breakpoints: [], + preference: .jdwp, + debuggerHost: "", + ports: 47_300...47_399, + databaseHost: "127.0.0.1", + databasePort: 1_521 + ) + await #expect(throws: OracleDebugError.jdwp(.noDebuggerHost)) { + _ = try await OracleDebugLauncher.launch(empty) { + opened.withLock { $0 += 1 } + return OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + } + } + #expect(opened.withLock { $0 } == 0) + } + + // Cancelled while the database has not connected back: the wait ends at once rather than at the 15 s deadline. + @Test func testCancellingWhileWaitingForTheDatabaseEndsTheWait() async { + let task = Task { + try await OracleDebugLauncher.launch(launch(.jdwp)) { + OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + } + } + let started = ContinuousClock.now + task.cancel() + await #expect(throws: (any Error).self) { _ = try await task.value } + #expect(ContinuousClock.now - started < .seconds(10)) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugOverloadAmbiguityTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugOverloadAmbiguityTests.swift new file mode 100644 index 0000000000..2c0c89ac4d --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugOverloadAmbiguityTests.swift @@ -0,0 +1,91 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +struct OracleDebugOverloadAmbiguityTests { + private let package = OracleDebugUnit(owner: "PROBE", name: "PKG_OVER", kind: .packageBody) + + private func overload( + _ id: Int, + _ parameters: [OracleDebugParameter], + returns result: String? = nil, + named subprogram: String = "P" + ) -> OracleDebugEntryPoint { + OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: package, subprogram: subprogram, subprogramID: id), + overload: String(id), + parameters: parameters, + result: result.map { OracleDebugParameter(name: "", position: 0, dataType: $0, mode: .output, hasDefault: false) } + ) + } + + private func parameter(_ name: String, _ type: String, defaulted: Bool, mode: OracleDebugParameter.Mode = .input) -> OracleDebugParameter { + OracleDebugParameter(name: name, position: 1, dataType: type, mode: mode, hasDefault: defaulted) + } + + // p(x NUMBER DEFAULT 1) and p(x VARCHAR2 DEFAULT 'a'): `p()` matches both (PLS-00307), so X needs a value. + @Test func testLeavingTheOnlyDistinguishingParameterAtItsDefaultNeedsAValue() { + let number = overload(1, [parameter("X", "NUMBER", defaulted: true)]) + let text = overload(2, [parameter("X", "VARCHAR2", defaulted: true)]) + let both = [number, text] + #expect(OracleDebugStatements.ambiguity(of: number, arguments: ["X": .defaultValue], overloads: both) == .needsValue(["X"])) + #expect(OracleDebugStatements.ambiguity(of: text, arguments: [:], overloads: both) == .needsValue(["X"])) + #expect(OracleDebugStatements.ambiguity(of: number, arguments: ["X": .text("5")], overloads: both) == nil) + #expect(OracleDebugStatements.ambiguity(of: number, arguments: ["X": .null], overloads: both) == nil) + } + + // p(a NUMBER) and p(a NUMBER, b VARCHAR2 DEFAULT 'x'): with B left out the second is `p(a => 1)`, which the first + // matches too; the first can never be called unambiguously. + @Test func testAnOverloadWhoseCallTheOtherAlwaysMatchesIsUnresolvable() { + let short = overload(1, [parameter("A", "NUMBER", defaulted: false)]) + let long = overload(2, [parameter("A", "NUMBER", defaulted: false), parameter("B", "VARCHAR2", defaulted: true)]) + let both = [short, long] + #expect(OracleDebugStatements.ambiguity(of: long, arguments: ["A": .text("1")], overloads: both) == .needsValue(["B"])) + #expect(OracleDebugStatements.ambiguity(of: long, arguments: ["A": .text("1"), "B": .text("y")], overloads: both) == nil) + #expect(OracleDebugStatements.ambiguity(of: short, arguments: ["A": .text("1")], overloads: both) == .unresolvable) + } + + @Test func testOverloadsTheCallCannotMatchAreNotAmbiguous() { + let number = overload(1, [parameter("X", "NUMBER", defaulted: true)]) + let needsZ = overload(2, [parameter("X", "VARCHAR2", defaulted: true), parameter("Z", "DATE", defaulted: false)]) + let function = overload(3, [parameter("X", "VARCHAR2", defaulted: true)], returns: "NUMBER") + let otherName = overload(4, [parameter("X", "VARCHAR2", defaulted: true)], named: "Q") + let renamed = overload(5, [parameter("Y", "VARCHAR2", defaulted: true), parameter("W", "NUMBER", defaulted: false)]) + let all = [number, needsZ, function, otherName, renamed] + #expect(OracleDebugStatements.ambiguity(of: number, arguments: [:], overloads: all) == nil) + #expect(OracleDebugStatements.ambiguity(of: number, arguments: [:], overloads: [number]) == nil) + #expect(OracleDebugStatements.ambiguity(of: number, arguments: [:], overloads: []) == nil) + } + + // An OUT parameter is always passed, so it tells overloads apart by its declaration like any passed one. + @Test func testAPassedOutParameterOfAnotherTypeDistinguishesTheCall() { + let numberOut = overload(1, [parameter("R", "NUMBER", defaulted: false, mode: .output), parameter("X", "NUMBER", defaulted: true)]) + let textOut = overload(2, [parameter("R", "VARCHAR2", defaulted: false, mode: .output), parameter("X", "VARCHAR2", defaulted: true)]) + #expect(OracleDebugStatements.ambiguity(of: numberOut, arguments: [:], overloads: [numberOut, textOut]) == nil) + } + + @Test func testTheLauncherRefusesAnAmbiguousCallBeforeAnySessionOpens() async { + let number = overload(1, [parameter("X", "NUMBER", defaulted: true)]) + let text = overload(2, [parameter("X", "VARCHAR2", defaulted: true)]) + let opened = OSAllocatedUnfairLock(initialState: 0) + let launch = OracleDebugLaunch( + entryPoint: number, + arguments: ["X": .defaultValue], + breakpoints: [], + preference: .dbmsDebug, + debuggerHost: "127.0.0.1", + ports: 47_300...47_399, + databaseHost: "127.0.0.1", + databasePort: 1_521, + overloads: [number, text] + ) + await #expect(throws: OracleDebugError.ambiguousDefaults(subprogram: "P", parameters: ["X"])) { + _ = try await OracleDebugLauncher.launch(launch) { + opened.withLock { $0 += 1 } + return OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + } + } + #expect(opened.withLock { $0 } == 0) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugScriptedChannel.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugScriptedChannel.swift new file mode 100644 index 0000000000..a659604ea5 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugScriptedChannel.swift @@ -0,0 +1,70 @@ +import Foundation +import os +@testable import TableProOracleCore + +// A database session that answers each statement through `respond` and records it as asked. Statements take turns on +// a gate as on `OracleCoreConnection`, so one that never returns holds up every later one. +final class ScriptedChannel: OracleDebugChannel, @unchecked Sendable { + private let gate = QueryGate() + private let asked = OSAllocatedUnfairLock(initialState: [String]()) + private let rows: [[OracleRawCell]] + private let respond: @Sendable (String) async throws -> [String?] + + init(rows: [[OracleRawCell]] = [], respond: @escaping @Sendable (String) async throws -> [String?] = { _ in [] }) { + self.rows = rows + self.respond = respond + } + + var sent: [String] { asked.withLock { $0 } } + + func executeDirect(_ sql: String, binds: [OracleBindValue], autoCommit: Bool, readsRows: Bool) async throws -> [String?] { + asked.withLock { $0.append(sql) } + await gate.acquire() + do { + let answer = try await respond(sql) + await gate.release() + return answer + } catch { + await gate.release() + throw error + } + } + + func executeQuery(_ query: String) async throws -> OracleRawResult { + asked.withLock { $0.append(query) } + return OracleRawResult(columns: [], rows: rows, affectedRows: 0, isTruncated: false) + } + + func cancelCurrentQuery() {} +} + +final class Latch: Sendable { + private let state = OSAllocatedUnfairLock(initialState: (isOpen: false, waiters: [CheckedContinuation]())) + + func wait() async { + await withCheckedContinuation { continuation in + let isOpen = state.withLock { current -> Bool in + if !current.isOpen { current.waiters.append(continuation) } + return current.isOpen + } + if isOpen { continuation.resume() } + } + } + + func open() { + let waiters = state.withLock { current -> [CheckedContinuation] in + current.isOpen = true + defer { current.waiters.removeAll() } + return current.waiters + } + for waiter in waiters { + waiter.resume() + } + } +} + +// Nil when `body` has not returned within `seconds`, so a call that hangs fails its test instead of the whole run. +func finished(within seconds: Double, _ body: @escaping @Sendable () async throws -> T) async -> T? { + let task = Task { try? await body() } + return await OracleDebugSupport.value(of: task, within: seconds) ?? nil +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugSessionReservationTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugSessionReservationTests.swift new file mode 100644 index 0000000000..2ada5c365d --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugSessionReservationTests.swift @@ -0,0 +1,43 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +// A debug target is a statement paused at a breakpoint for as long as a person reads it. The health check's +// staleness limit would close it after 300 seconds, and a query timeout sooner. +struct OracleDebugSessionReservationTests { + private func connection() -> OracleCoreConnection { + OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "probe_pw")) + } + + @Test func testAnOrdinarySessionKeepsTheStalenessLimit() { + let session = connection() + #expect(session.wedgedStatementLimit == .seconds(300)) + session.applyQueryTimeout(600) + #expect(session.wedgedStatementLimit == .seconds(600)) + } + + @Test func testADebugSessionHasNoStalenessLimitAndIgnoresQueryTimeouts() { + let session = connection() + session.applyQueryTimeout(600) + session.reserveForDebugging() + #expect(session.isReservedForDebugging) + #expect(session.wedgedStatementLimit == nil) + session.applyQueryTimeout(30) + #expect(session.wedgedStatementLimit == nil) + } + + @Test func testABusyDebugSessionIsAlwaysReportedAlive() { + #expect(OraclePingDecision.of(.busy(for: .seconds(86_400)), wedgedAfter: nil) == .reportAlive) + #expect(OraclePingDecision.of(.acquired, wedgedAfter: nil) == .probe) + #expect(OraclePingDecision.of(.busy(for: .seconds(301)), wedgedAfter: .seconds(300)) == .reportWedged) + } + + // A reserved session never redials: a new session would not be the one being debugged. + @Test func testADebugSessionWithoutAChannelDoesNotRedial() async { + let session = connection() + session.reserveForDebugging() + await #expect(throws: OracleCoreError.notConnected) { + _ = try await session.executeDirect("ROLLBACK") + } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStatementsTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStatementsTests.swift new file mode 100644 index 0000000000..c776df8747 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStatementsTests.swift @@ -0,0 +1,297 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleDebugStatementsTests { + private let body = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + + @Test func testPayloadRoundTripsNullsEmptyTextAndWideCharacters() throws { + let fields: [String?] = [nil, "", "12:34", "Tiếng Việt 🇻🇳", "~", "a\nb"] + let encoded = OracleDebugPayload.encode(fields) + #expect(try OracleDebugPayload.parse(encoded) == fields) + #expect(try OracleDebugPayload.parse(OracleDebugPayload.join(pieces: [String(encoded.prefix(7)), nil, String(encoded.dropFirst(7))])) + == fields) + } + + @Test func testAMalformedPayloadThrows() { + #expect(throws: OracleDebugPayload.ParseError.self) { try OracleDebugPayload.parse("5:abc") } + #expect(throws: OracleDebugPayload.ParseError.self) { try OracleDebugPayload.parse("x") } + } + + // Measured: level 2 with PLSQL_DEBUG FALSE answers SET_BREAKPOINT with success and never stops. + @Test func testReadinessFollowsTheMeasuredSettings() { + #expect(OracleDebugStatements.readiness(fromRow: ["2", "TRUE"]) == .ready) + #expect(OracleDebugStatements.readiness(fromRow: ["1", "FALSE"]) == .ready) + #expect(OracleDebugStatements.readiness(fromRow: ["0", "FALSE"]) == .ready) + #expect(OracleDebugStatements.readiness(fromRow: ["2", "FALSE"]) == .needsDebugCompile) + #expect(OracleDebugStatements.readiness(fromRow: nil) == .notVisible) + } + + @Test func testReadinessAsksForTheBodyOfAPackage() { + let spec = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .package) + #expect(OracleDebugStatements.readiness(of: spec).contains("TYPE = 'PACKAGE BODY'")) + #expect(OracleDebugStatements.readiness(of: spec).contains("FROM SYS.ALL_PLSQL_OBJECT_SETTINGS")) + } + + @Test func testMissingPrivilegesNameTheGrant() { + #expect(OracleDebugStatements.missingPrivilege(fromRow: ["PROBE", "0", "0", "0"], unit: body) + == .debugConnectSession(user: "PROBE")) + #expect(OracleDebugStatements.missingPrivilege(fromRow: ["PROBE", "1", "0", "0"], unit: body) == nil) + #expect(OracleDebugStatements.missingPrivilege(fromRow: ["NOACL", "1", "0", "0"], unit: body) + == .debugOnObject(user: "NOACL")) + #expect(OracleDebugStatements.missingPrivilege(fromRow: ["NOACL", "1", "0", "1"], unit: body) == nil) + #expect(OracleDebugStatements.missingPrivilege(fromRow: ["NOACL", "1", "1", "0"], unit: body) == nil) + } + + @Test func testEntryPointsGroupOverloadsAndReadTheResult() { + let rows: [[String?]] = [ + ["ADD_TAX", "1", "1", "0", nil, "NUMBER", "OUT", "N", nil, nil, nil, nil], + ["ADD_TAX", "1", "1", "1", "P_AMOUNT", "NUMBER", "IN", "N", nil, nil, nil, nil], + ["ADD_TAX", "2", "2", "0", nil, "NUMBER", "OUT", "N", nil, nil, nil, nil], + ["ADD_TAX", "2", "2", "1", "P_AMOUNT", "NUMBER", "IN", "N", nil, nil, nil, nil], + ["ADD_TAX", "2", "2", "2", "P_RATE", "NUMBER", "IN", "N", nil, nil, nil, nil], + ["RUN", "3", nil, "1", "P_N", "BINARY_INTEGER", "IN", "N", nil, nil, nil, "PLS_INTEGER"], + ["RESET", "4", nil, nil, nil, nil, nil, nil, nil, nil, nil, nil] + ] + let entries = OracleDebugStatements.parseEntryPoints(rows: rows, unit: body) + #expect(entries.map(\.displayName) == [ + "ADD_TAX(P_AMOUNT NUMBER) RETURN NUMBER", + "ADD_TAX(P_AMOUNT NUMBER, P_RATE NUMBER) RETURN NUMBER", + "RUN(P_N PLS_INTEGER)", + "RESET" + ]) + #expect(entries[1].entry.identity == "PACKAGE BODY/PROBE/PKG_CALC/ADD_TAX/2") + #expect(entries[3].parameters.isEmpty && entries[3].result == nil) + } + + @Test func testInvocationNamesArgumentsAndLeavesDefaultsOut() throws { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "F_CALC", kind: .function), subprogram: "F_CALC", subprogramID: 1), + overload: nil, + parameters: [ + OracleDebugParameter(name: "P_A", position: 1, dataType: "NUMBER", mode: .input, hasDefault: false), + OracleDebugParameter(name: "P_B", position: 2, dataType: "VARCHAR2", mode: .input, hasDefault: true), + OracleDebugParameter(name: "P_FLAG", position: 3, dataType: "PL/SQL BOOLEAN", mode: .input, hasDefault: true) + ], + result: OracleDebugParameter(name: "", position: 0, dataType: "NUMBER", mode: .output, hasDefault: false) + ) + let invocation = try OracleDebugStatements.invocation( + of: entry, + arguments: ["P_A": .text("21"), "P_B": .defaultValue, "P_FLAG": .text("false")] + ) + #expect(invocation.binds == [.text("21"), .text("false")]) + #expect(invocation.block.contains(" l_arg1 NUMBER;")) + #expect(invocation.block.contains(" l_arg3 BOOLEAN;")) + #expect(invocation.block.contains(" l_arg1 := TO_NUMBER(:1);")) + #expect(invocation.block.contains(" l_arg3 := CASE UPPER(TRIM(:2)) WHEN 'TRUE' THEN TRUE")) + #expect(invocation.block.contains("l_result := \"PROBE\".\"F_CALC\"(\"P_A\" => l_arg1, \"P_FLAG\" => l_arg3);")) + #expect(!invocation.block.contains("\"P_B\"")) + #expect(invocation.block.contains("put('F_CALC');")) + #expect(invocation.block.contains("OPEN :rows FOR")) + } + + @Test func testOutParametersAreDeclaredPassedAndReported() throws { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "P_WRITE", kind: .procedure), subprogram: "P_WRITE", subprogramID: 1), + overload: nil, + parameters: [ + OracleDebugParameter(name: "P_NOTE", position: 1, dataType: "VARCHAR2", mode: .input, hasDefault: false), + OracleDebugParameter(name: "P_COUNT", position: 2, dataType: "NUMBER", mode: .output, hasDefault: false), + OracleDebugParameter(name: "P_ROW", position: 3, dataType: "PL/SQL RECORD", mode: .inputOutput, hasDefault: false, + typeOwner: "PROBE", typeName: "PKG", typeSubname: "REC_T") + ], + result: nil + ) + let invocation = try OracleDebugStatements.invocation(of: entry, arguments: ["P_NOTE": .null, "P_ROW": .null]) + #expect(invocation.binds.isEmpty) + #expect(invocation.block.contains("l_arg1 VARCHAR2(32767);")) + #expect(invocation.block.contains("l_arg2 NUMBER;")) + #expect(invocation.block.contains("l_arg3 \"PROBE\".\"PKG\".\"REC_T\";")) + #expect(invocation.block.contains("\"PROBE\".\"P_WRITE\"(\"P_NOTE\" => l_arg1, \"P_COUNT\" => l_arg2, \"P_ROW\" => l_arg3);")) + #expect(!invocation.block.contains("=> NULL")) + #expect(invocation.block.contains("put('1');")) + #expect(invocation.block.contains("put(TO_CHAR(l_arg2));")) + } + + // Two overloads that differ only in the type of P_X: the typed local is what makes Oracle pick the chosen one. + @Test func testEachOverloadIsCalledWithItsOwnTypes() throws { + func overload(_ id: Int, _ type: String) -> OracleDebugEntryPoint { + OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "PKG_OVER", kind: .packageBody), subprogram: "SHOW", subprogramID: id), + overload: String(id), + parameters: [OracleDebugParameter(name: "P_X", position: 1, dataType: type, mode: .input, hasDefault: false)], + result: nil + ) + } + let number = try OracleDebugStatements.invocation(of: overload(1, "NUMBER"), arguments: ["P_X": .text("5")]) + #expect(number.block.contains(" l_arg1 NUMBER;")) + #expect(number.block.contains(" l_arg1 := TO_NUMBER(:1);")) + #expect(number.block.contains("\"PROBE\".\"PKG_OVER\".\"SHOW\"(\"P_X\" => l_arg1);")) + let text = try OracleDebugStatements.invocation(of: overload(2, "VARCHAR2"), arguments: ["P_X": .text("5")]) + #expect(text.block.contains(" l_arg1 VARCHAR2(32767);")) + #expect(text.block.contains(" l_arg1 := :1;")) + let null = try OracleDebugStatements.invocation(of: overload(2, "VARCHAR2"), arguments: ["P_X": .null]) + #expect(null.block.contains(" l_arg1 VARCHAR2(32767);")) + #expect(!null.block.contains("l_arg1 :=")) + #expect(null.block.contains("(\"P_X\" => l_arg1);")) + } + + @Test func testDateLikeTextConvertsExplicitly() throws { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "P", kind: .procedure), subprogram: "P", subprogramID: 1), + overload: nil, + parameters: [ + OracleDebugParameter(name: "P_D", position: 1, dataType: "DATE", mode: .input, hasDefault: false), + OracleDebugParameter(name: "P_T", position: 2, dataType: "TIMESTAMP WITH TIME ZONE", mode: .input, hasDefault: false), + OracleDebugParameter(name: "P_I", position: 3, dataType: "INTERVAL DAY TO SECOND", mode: .input, hasDefault: false), + OracleDebugParameter(name: "P_N", position: 4, dataType: "BINARY_INTEGER", mode: .input, hasDefault: false, plsType: "PLS_INTEGER") + ], + result: nil + ) + let invocation = try OracleDebugStatements.invocation( + of: entry, + arguments: ["P_D": .text("2024-01-02"), "P_T": .text("x"), "P_I": .text("+00 01:00:00"), "P_N": .text("3")] + ) + #expect(invocation.block.contains("l_arg1 := TO_DATE(:1);")) + #expect(invocation.block.contains("l_arg2 := TO_TIMESTAMP_TZ(:2);")) + #expect(invocation.block.contains("l_arg3 := TO_DSINTERVAL(:3);")) + #expect(invocation.block.contains("l_arg4 PLS_INTEGER;")) + } + + @Test func testTextCannotBePassedToACollection() { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "P", kind: .procedure), subprogram: "P", subprogramID: 1), + overload: nil, + parameters: [OracleDebugParameter(name: "P_LIST", position: 1, dataType: "TABLE", mode: .input, hasDefault: false, + typeOwner: "PROBE", typeName: "NUM_LIST")], + result: nil + ) + #expect(throws: OracleDebugStatements.InvocationError.unsupportedArgument(name: "P_LIST", typeName: "PROBE.NUM_LIST")) { + try OracleDebugStatements.invocation(of: entry, arguments: ["P_LIST": .text("1,2")]) + } + } + + @Test func testInvocationReportReadsOutputsAndLines() throws { + let fields: [String?] = ["S", nil, "2", "F_CALC", "42", "P_COUNT", nil, "probe total=3.3", "second"] + let report = try OracleDebugStatements.invocationReport(fromPayload: fields) + #expect(report.succeeded) + #expect(report.outputs.map(\.name) == ["F_CALC", "P_COUNT"]) + #expect(report.outputs.map(\.value) == ["42", nil]) + #expect(report.lines == ["probe total=3.3", "second"]) + let failed = try OracleDebugStatements.invocationReport(fromPayload: ["F", "ORA-01476: divisor is equal to zero", "0"]) + #expect(!failed.succeeded) + #expect(failed.error == "ORA-01476: divisor is equal to zero") + } + + @Test func testSourceKeepsAllSourceLineNumbers() { + let text = OracleDebugStatements.assembleSource(rows: [ + (1, "package body pkg_calc as\n"), + (3, " function add_tax return number is\n"), + (2, " g_rate number := 0.1;\n"), + (5, "end pkg_calc;") + ]) + #expect(text == "package body pkg_calc as\n g_rate number := 0.1;\n function add_tax return number is\n\nend pkg_calc;") + } + + @Test func testSourceDropsCRLFEndingsAndKeepsEveryCharacter() { + let text = OracleDebugStatements.assembleSource(rows: [ + (1, "begin\r\n"), + (2, "\r\n"), + (3, " x := 1;\r\n"), + (4, "end;") + ]) + #expect(text == "begin\n\n x := 1;\nend;") + } + + // The messages measured on 23.26 for each way the database can fail to connect back. + @Test func testJDWPFailuresClassifyTheMeasuredErrors() { + #expect(OracleDebugStatements.jdwpFailure(fromMessage: """ + ORA-24247: network access denied by access control list (ACL) + ORA-06512: at "SYS.DBMS_DEBUG_JDWP", line 68 + """) == .accessControlList) + let refused = "ORA-30683: failure establishing connection to debugger\nORA-30698: network connection failed: connection refused" + #expect(OracleDebugStatements.jdwpFailure(fromMessage: refused) == .unreachable(refused)) + #expect(OracleDebugStatements.jdwpFailure(fromMessage: "ORA-01031: insufficient privileges\nORA-06512: at \"SYS.DBMS_DEBUG_JDWP\", line 68") + == .missingPrivilege) + #expect(OracleDebugStatements.jdwpFailure(fromMessage: "ORA-06576: not a valid function or procedure name: DBMS_DEBUG_JDWP") + == .packageUnavailable) + #expect(OracleDebugStatements.errorCodes(in: "ORA-30683: x\nORA-30698: y") == [30_683, 30_698]) + } + + @Test func testPortRanges() { + #expect(OracleDebugStatements.ports(from: "4000-4999") == 4_000...4_999) + #expect(OracleDebugStatements.ports(from: "4000 - 4999") == 4_000...4_999) + #expect(OracleDebugStatements.ports(from: " 15400 ") == 15_400...15_400) + #expect(OracleDebugStatements.ports(from: "5000-4000") == nil) + #expect(OracleDebugStatements.ports(from: "abc") == nil) + #expect(OracleDebugStatements.ports(from: "0") == nil) + #expect(OracleDebugStatements.ports(from: "4000-") == nil) + } + + // A DBA's ACEs for ports 4000 and 4005 are not the range between them: a list is refused, never read as 4000-4005. + @Test func testAPortListIsRefused() { + #expect(OracleDebugStatements.ports(from: "4000, 4005") == nil) + #expect(OracleDebugStatements.ports(from: "4000 4005") == nil) + #expect(OracleDebugStatements.ports(from: "4000-4005-4010") == nil) + } + + @Test func testABFILEPassesAsATypedNullAndRefusesText() throws { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "P", kind: .procedure), subprogram: "P", subprogramID: 1), + overload: nil, + parameters: [OracleDebugParameter(name: "F", position: 1, dataType: "BFILE", mode: .input, hasDefault: false)], + result: nil + ) + let null = try OracleDebugStatements.invocation(of: entry, arguments: ["F": .null]) + #expect(null.block.contains(" l_arg1 BFILE;")) + #expect(null.block.contains("(\"F\" => l_arg1);")) + #expect(throws: OracleDebugStatements.InvocationError.unsupportedArgument(name: "F", typeName: "BFILE")) { + try OracleDebugStatements.invocation(of: entry, arguments: ["F": .text("x")]) + } + } + + // A type with no name to declare cannot be passed at all, not even as NULL. + @Test func testAnUndeclarableTypeIsToldApartFromText() { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "P", kind: .procedure), subprogram: "P", subprogramID: 1), + overload: nil, + parameters: [OracleDebugParameter(name: "T", position: 1, dataType: "PL/SQL TABLE", mode: .input, hasDefault: false)], + result: nil + ) + #expect(throws: OracleDebugStatements.InvocationError.undeclarableArgument(name: "T", typeName: "PL/SQL TABLE")) { + try OracleDebugStatements.invocation(of: entry, arguments: ["T": .null]) + } + } + + @Test func testConnectBlockBindsHostAndPortThroughACall() { + #expect(OracleDebugStatements.jdwpConnect.contains("'CALL SYS.DBMS_DEBUG_JDWP.CONNECT_TCP(:1, :2)' USING :1, :2")) + } +} + +struct OracleDebugProtocolDecisionTests { + @Test func testTheFirstChoiceFollowsThePreferenceAndTheHost() { + #expect(OracleDebugProtocolDecision.initial(preference: .automatic, host: "192.168.1.20") == .jdwp) + #expect(OracleDebugProtocolDecision.initial(preference: .automatic, host: " ") == .dbmsDebug(.noDebuggerHost)) + #expect(OracleDebugProtocolDecision.initial(preference: .dbmsDebug, host: "192.168.1.20") == .dbmsDebug(.chosen)) + #expect(OracleDebugProtocolDecision.initial(preference: .jdwp, host: "192.168.1.20") == .jdwp) + // Behind a tunnel the host starts empty; explicit DBMS_DEBUG_JDWP is refused before any session opens. + #expect(OracleDebugProtocolDecision.initial(preference: .jdwp, host: " ") == .fail) + } + + @Test func testAutomaticFallsBackOnTheNetworkFailures() { + let fallsBack: [OracleJDWPFailure] = [ + .accessControlList, .unreachable("ORA-30683"), .packageUnavailable, .timedOut, .noFreePort, .handshakeFailed + ] + for failure in fallsBack { + #expect(OracleDebugProtocolDecision.afterJDWPFailure(failure, preference: .automatic) == .fallBack) + #expect(OracleDebugProtocolDecision.afterJDWPFailure(failure, preference: .jdwp) == .fail) + } + } + + // DBMS_DEBUG needs the same DEBUG CONNECT SESSION (ORA-01031 from both, measured). + @Test func testAPrivilegeOrUnknownFailureIsFinal() { + #expect(OracleDebugProtocolDecision.afterJDWPFailure(.missingPrivilege, preference: .automatic) == .fail) + #expect(OracleDebugProtocolDecision.afterJDWPFailure(.noDebuggerHost, preference: .automatic) == .fail) + #expect(OracleDebugProtocolDecision.afterJDWPFailure(.other("ORA-00600"), preference: .automatic) == .fail) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStopBudgetTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStopBudgetTests.swift new file mode 100644 index 0000000000..5710e5e3bd --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStopBudgetTests.swift @@ -0,0 +1,105 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +// A clock that moves only when a step says it took time. +private final class StepClock: Clock, @unchecked Sendable { + struct Instant: InstantProtocol { + var offset: Duration + + func advanced(by duration: Duration) -> Instant { Instant(offset: offset + duration) } + func duration(to other: Instant) -> Duration { other.offset - offset } + static func < (lhs: Instant, rhs: Instant) -> Bool { lhs.offset < rhs.offset } + } + + private let current = OSAllocatedUnfairLock(initialState: Instant(offset: .zero)) + + var now: Instant { current.withLock { $0 } } + var minimumResolution: Duration { .zero } + + func advance(by duration: Duration) { + current.withLock { $0 = $0.advanced(by: duration) } + } + + func sleep(until deadline: Instant, tolerance: Duration?) async throws { + current.withLock { $0 = max($0, deadline) } + } +} + +// The host closes the transport 5 s after asking for Stop, so a DBMS_DEBUG kill must be sent at once and everything +// after it fit in 3 s. +struct OracleDebugStopBudgetTests { + @Test func testTheKillGoesFirstAndTheRestWaitsForTheEnd() async { + let clock = StepClock() + let order = OSAllocatedUnfairLock(initialState: [String]()) + let ended = await OracleDebugStopBudget.endRunningTarget(clock: clock) { + order.withLock { $0.append("kill at \(clock.now.offset)") } + clock.advance(by: .milliseconds(300)) + return .marked + } waitForEnd: { remaining in + order.withLock { $0.append("wait \(remaining)") } + return true + } + #expect(ended) + #expect(order.withLock { $0 } == ["kill at 0.0 seconds", "wait 2.2 seconds"]) + } + + // Without ALTER SYSTEM the routine may still reach its end or a breakpoint, but only inside the budget. + @Test func testARefusedKillWaitsOnlyForWhatIsLeft() async { + let clock = StepClock() + let waited = OSAllocatedUnfairLock(initialState: nil) + let ended = await OracleDebugStopBudget.endRunningTarget(clock: clock) { + clock.advance(by: .milliseconds(100)) + return .refused + } waitForEnd: { remaining in + waited.withLock { $0 = remaining } + clock.advance(by: remaining) + return false + } + #expect(!ended) + #expect(waited.withLock { $0 } == .milliseconds(2_400)) + #expect(clock.now.offset <= OracleDebugStopBudget.total) + } + + @Test func testAKillThatUsedTheBudgetDoesNotWait() async { + let clock = StepClock() + let waited = OSAllocatedUnfairLock(initialState: false) + let ended = await OracleDebugStopBudget.endRunningTarget(clock: clock) { + clock.advance(by: .seconds(3)) + return .marked + } waitForEnd: { _ in + waited.withLock { $0 = true } + return true + } + #expect(!ended) + #expect(!waited.withLock { $0 }) + } + + // ORA-00028 is how a killed target's call fails (measured); a transport the connection lost, or never had, says + // nothing about the server session. + @Test func testOnlyTheServersAnswerProvesTheCallEnded() { + let answered: [Result<[String?], Error>] = [ + .success([]), + .failure(OracleCoreError.queryFailed("ORA-00028: your session has been killed")), + .failure(OracleDebugError.server(code: 6_543, message: "ORA-06543")) + ] + let unanswered: [Result<[String?], Error>] = [ + .failure(OracleCoreError.connectionClosed), + .failure(OracleCoreError.notConnected), + .failure(OracleCoreError.protocolError), + .failure(OracleCoreError.queryFailed(String(format: OracleCoreError.driverErrorFormat, "uncleanShutdown"))), + .failure(OracleCoreError.queryFailed("IOError: Connection reset by peer (errno: 54)")), + .failure(CancellationError()) + ] + #expect(answered.map { OracleDebugSupport.endedOnServer($0) } == [true, true, true]) + #expect(unanswered.map { OracleDebugSupport.endedOnServer($0) } == Array(repeating: false, count: 6)) + } + + // The kill can wait one supervisor poll on the gate; that and the cleanup still leave time to see the end. + @Test func testTheSupervisorPollFitsInsideTheBudget() { + let poll = Duration.seconds(OracleDBMSDebugStatements.supervisorTimeoutSeconds) + #expect(poll + OracleDebugStopBudget.cleanup < OracleDebugStopBudget.total) + #expect(OracleDebugStopBudget.total <= .seconds(3)) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugUnitTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugUnitTests.swift new file mode 100644 index 0000000000..b594811d3e --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugUnitTests.swift @@ -0,0 +1,73 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleDebugUnitTests { + @Test func testMeasuredClassSignaturesMapToUnits() { + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/PackageBody/PROBE/PKG_CALC;") + == OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody)) + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Package/PROBE/PKG_CALC;") + == OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .package)) + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Procedure/PROBE/P_WRITE;") + == OracleDebugUnit(owner: "PROBE", name: "P_WRITE", kind: .procedure)) + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Function/PROBE/F_CALC;") + == OracleDebugUnit(owner: "PROBE", name: "F_CALC", kind: .function)) + } + + @Test func testBlocksBuiltinsAndLocalTypesAreNotUnits() { + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Block/PROBE/B5b7fkkBPUBJ4f33OXgisXQ;") == nil) + #expect(OracleDebugUnit.isAnonymousBlock(jdwpSignature: "L$Oracle/Block/PROBE/B5b7fkkBPUBJ4f33OXgisXQ;")) + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Builtin/NUMBER;") == nil) + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Function/PROBE/F_CALC/F_CALC/T_NUMS;") == nil) + #expect(OracleDebugUnit(jdwpSignature: "Ljava/lang/String;") == nil) + } + + @Test func testUnitsMapBackToClassMatchPatterns() { + let unit = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + #expect(unit.jdwpClassName == "$Oracle/PackageBody/PROBE/PKG_CALC") + #expect(unit.jdwpSignature == "L$Oracle/PackageBody/PROBE/PKG_CALC;") + #expect(OracleDebugUnit(jdwpSignature: unit.jdwpSignature) == unit) + } + + @Test func testIdentityRoundTripsNamesHoldingSlashesAndPercents() { + let unit = OracleDebugUnit(owner: "A/B%", name: "x/y%2F", kind: .packageBody) + #expect(unit.identity == "PACKAGE BODY/A%2FB%25/x%2Fy%252F") + #expect(OracleDebugUnit(identity: unit.identity) == unit) + #expect(OracleDebugUnit(identity: "VIEW/PROBE/V") == nil) + } + + @Test func testEntryIdentityCarriesTheSubprogramAndItsID() { + let entry = OracleDebugEntry( + unit: OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody), + subprogram: "ADD_TAX", + subprogramID: 2 + ) + #expect(entry.identity == "PACKAGE BODY/PROBE/PKG_CALC/ADD_TAX/2") + #expect(OracleDebugEntry(identity: entry.identity) == entry) + #expect(entry.isPackaged) + } + + @Test func testCompileForDebugKeepsSettingsAndQuotesNames() { + #expect(OracleDebugUnitKind.packageBody.compileForDebugStatement(owner: "PROBE", name: "PKG_CALC") + == "ALTER PACKAGE \"PROBE\".\"PKG_CALC\" COMPILE BODY PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS") + #expect(OracleDebugUnitKind.procedure.compileForDebugStatement(owner: "PROBE", name: "P\"Q") + == "ALTER PROCEDURE \"PROBE\".\"P\"\"Q\" COMPILE PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS") + #expect(OracleDebugUnitKind.function.compileForDebugStatement(owner: "PROBE", name: "F_CALC") + == "ALTER FUNCTION \"PROBE\".\"F_CALC\" COMPILE PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS") + } + + @Test func testDBMSDebugNamespacesAndLibunitTypes() { + #expect(OracleDebugUnitKind(namespace: 2, libunitType: 11) == .packageBody) + #expect(OracleDebugUnitKind(namespace: 1, libunitType: 7) == .procedure) + #expect(OracleDebugUnitKind(namespace: 1, libunitType: 8) == .function) + #expect(OracleDebugUnitKind(namespace: 1, libunitType: 9) == .package) + #expect(OracleDebugUnitKind(namespace: 0, libunitType: 0) == nil) + #expect(OracleDebugUnitKind.packageBody.namespace == 2) + #expect(OracleDebugUnitKind.function.namespace == 1) + } + + @Test func testTypeNamesComeFromSignatures() { + #expect(OracleDebugUnit.typeName(fromJDWPSignature: "L$Oracle/Builtin/NUMBER;") == "NUMBER") + #expect(OracleDebugUnit.typeName(fromJDWPSignature: "L$Oracle/Function/PROBE/F_CALC/F_CALC/T_REC;") == "T_REC") + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPBreakpointTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPBreakpointTests.swift new file mode 100644 index 0000000000..326e2ebf85 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPBreakpointTests.swift @@ -0,0 +1,194 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +struct OracleJDWPBreakpointTests { + private static let calcBody = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + + // A VM whose PKG_CALC body has one method, RUN, with code on `lines`; the unit counts as loaded once `isLoaded` + // says so. Records the request id it gave each breakpoint, by code index, which is the line. + private final class VM: @unchecked Sendable { + private struct State { + var isLoaded: Bool + var refusesNextClear = false + var nextRequest: Int32 = 0 + var breakpoints: [Int64: Int32] = [:] + } + + private let state: OSAllocatedUnfairLock + private let lines: [Int] + + init(lines: [Int], isLoaded: Bool) { + self.lines = lines + state = OSAllocatedUnfairLock(initialState: State(isLoaded: isLoaded)) + } + + func load() { + state.withLock { $0.isLoaded = true } + } + + func refuseNextClear() { + state.withLock { $0.refusesNextClear = true } + } + + func request(atLine line: Int) -> Int32? { + state.withLock { $0.breakpoints[Int64(line)] } + } + + func answer(_ sent: RespondingPeer.Sent) -> (events: [Data], errorCode: UInt16, body: Data)? { + var writer = OracleJDWPWriter(sizes: .oracle) + switch (sent.commandSet, sent.command) { + case (OracleJDWPCommand.virtualMachine, OracleJDWPCommand.idSizes): + return ([OracleJDWPRecording.vmStartEvent], 0, replyBody(OracleJDWPRecording.idSizesReply)) + case (OracleJDWPCommand.virtualMachine, OracleJDWPCommand.classesBySignature): + guard state.withLock({ $0.isLoaded }) else { + writer.i32(0) + break + } + writer.i32(1) + writer.u8(1) + writer.id(2, .referenceType) + writer.i32(7) + case (OracleJDWPCommand.referenceType, OracleJDWPCommand.methods): + writer.i32(1) + writer.id(5, .method) + writer.string("RUN") + writer.string("()V") + writer.i32(9) + case (OracleJDWPCommand.method, OracleJDWPCommand.lineTable): + writer.i64(Int64(lines.first ?? 0)) + writer.i64(Int64(lines.last ?? 0)) + writer.i32(Int32(lines.count)) + for line in lines { + writer.i64(Int64(line)) + writer.i32(Int32(line)) + } + case (OracleJDWPCommand.eventRequest, OracleJDWPCommand.clear): + let refuses = state.withLock { current -> Bool in + defer { current.refusesNextClear = false } + return current.refusesNextClear + } + // INVALID_EVENT_TYPE, with the session still up. + if refuses { return ([], 102, Data()) } + case (OracleJDWPCommand.eventRequest, OracleJDWPCommand.set): + let id = state.withLock { current -> Int32 in + current.nextRequest += 1 + if sent.body.first == OracleJDWPEventKind.breakpoint.rawValue { + current.breakpoints[Self.codeIndex(of: sent.body)] = current.nextRequest + } + return current.nextRequest + } + writer.i32(id) + default: + break + } + return ([], 0, writer.data) + } + + // A breakpoint request ends with its location, whose last eight bytes are the code index. + private static func codeIndex(of body: Data) -> Int64 { + body.suffix(8).reduce(Int64(0)) { $0 << 8 | Int64($1) } + } + } + + private func makeSession(_ vm: VM, helper: ScriptedChannel? = nil) async throws -> (OracleJDWPDebugSession, RespondingPeer) { + let peer = RespondingPeer { vm.answer($0) } + let connection = OracleJDWPConnection(transport: peer, replyTimeout: .seconds(5)) + try await connection.handshake() + let offline = OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + let session = OracleJDWPDebugSession( + connection: connection, + target: offline, + helper: helper ?? offline, + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: Self.calcBody, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: "BEGIN NULL; END;", binds: []), + connectCall: Task { .success([]) }, + notices: [] + ) + return (session, peer) + } + + private func clears(_ peer: RespondingPeer) -> [Data] { + peer.sent + .filter { $0.commandSet == OracleJDWPCommand.eventRequest && $0.command == OracleJDWPCommand.clear } + .map(\.body) + } + + private func clear(_ request: Int32?) -> Data { + OracleJDWPRequest.clear(kind: .breakpoint, requestID: request ?? -1, sizes: .oracle) + } + + // Line 4 has no code, so the breakpoint sent at start lands on line 5; the host never learns that. + @Test func testAStartBreakpointIsRemovedByTheLineItWasAskedFor() async throws { + let vm = VM(lines: [3, 5, 6], isLoaded: true) + let (session, peer) = try await makeSession(vm) + try await session.start(breakpoints: [(unit: Self.calcBody, line: 4)]) + + try await session.removeBreakpoint(in: Self.calcBody, line: 4) + #expect(clears(peer) == [clear(vm.request(atLine: 5))]) + #expect(await session.stop() == .ended) + } + + // The unit is not loaded at start: the line map places line 4 on line 5, and the class's line table then moves it + // to line 6 when the unit loads. + @Test func testAPendingBreakpointIsRemovedByItsLineAfterItsUnitLoads() async throws { + let vm = VM(lines: [3, 6], isLoaded: false) + // Lines 3, 5 and 6 have code in DBMS_DEBUG's line map. + let helper = ScriptedChannel { sql in + sql.contains("GET_LINE_MAP") ? [OracleDebugPayload.encode(["0", "6", "68"])] : [] + } + let (session, peer) = try await makeSession(vm, helper: helper) + try await session.start(breakpoints: [(unit: Self.calcBody, line: 4)]) + vm.load() + #expect(try await session.setBreakpoint(in: Self.calcBody, line: 3) == 3) + + try await session.removeBreakpoint(in: Self.calcBody, line: 4) + #expect(clears(peer) == [clear(vm.request(atLine: 6))]) + #expect(await session.stop() == .ended) + } + + // Lines 4 and 5 both land on line 5: the breakpoint stays until the host has removed both. + @Test func testABreakpointTwoStartLinesLandedOnStaysUntilBothAreRemoved() async throws { + let vm = VM(lines: [3, 5, 6], isLoaded: true) + let (session, peer) = try await makeSession(vm) + try await session.start(breakpoints: [(unit: Self.calcBody, line: 4), (unit: Self.calcBody, line: 5)]) + + try await session.removeBreakpoint(in: Self.calcBody, line: 4) + #expect(clears(peer).isEmpty) + try await session.removeBreakpoint(in: Self.calcBody, line: 5) + #expect(clears(peer) == [clear(vm.request(atLine: 5))]) + #expect(await session.stop() == .ended) + } + + // A Clear the VM refuses leaves the breakpoint set, so removing it again sends another Clear. + @Test func testARefusedClearCanBeRetried() async throws { + let vm = VM(lines: [3, 5, 6], isLoaded: true) + let (session, peer) = try await makeSession(vm) + try await session.start(breakpoints: []) + #expect(try await session.setBreakpoint(in: Self.calcBody, line: 5) == 5) + vm.refuseNextClear() + + await #expect(throws: (any Error).self) { try await session.removeBreakpoint(in: Self.calcBody, line: 5) } + try await session.removeBreakpoint(in: Self.calcBody, line: 5) + #expect(clears(peer) == [clear(vm.request(atLine: 5)), clear(vm.request(atLine: 5))]) + #expect(await session.stop() == .ended) + } + + // A breakpoint set after start answers the line it landed on, which is the line the host removes it by. + @Test func testABreakpointSetLaterIsRemovedByTheLineItAnswered() async throws { + let vm = VM(lines: [3, 5, 6], isLoaded: true) + let (session, peer) = try await makeSession(vm) + try await session.start(breakpoints: []) + #expect(try await session.setBreakpoint(in: Self.calcBody, line: 4) == 5) + + try await session.removeBreakpoint(in: Self.calcBody, line: 5) + #expect(clears(peer) == [clear(vm.request(atLine: 5))]) + #expect(await session.stop() == .ended) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConcludeTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConcludeTests.swift new file mode 100644 index 0000000000..4ed872db86 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConcludeTests.swift @@ -0,0 +1,134 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleJDWPConcludeTests { + // The routine sits in DBMS_SESSION.SLEEP, which never returns to PL/SQL to see the socket close. Its call holds + // the target's gate, so a ROLLBACK there would wait for the sleep: the step ends unconfirmed instead. + @Test func testASocketThatClosesDuringALongCallEndsTheRunUnconfirmed() async throws { + let calcBody = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + let identity = OracleDebugSessionIdentity(sid: 42, serial: 7) + let invocation = "BEGIN PROBE.PKG_CALC.RUN; END;" + let sleeping = Latch() + defer { sleeping.open() } + let (calls, call) = AsyncStream.makeStream(of: Void.self) + let target = ScriptedChannel { sql in + guard sql == invocation else { return [] } + call.yield() + await sleeping.wait() + return [] + } + let peer = RespondingPeer { sent in + if sent.commandSet == OracleJDWPCommand.virtualMachine, sent.command == OracleJDWPCommand.idSizes { + return ([OracleJDWPRecording.vmStartEvent], 0, replyBody(OracleJDWPRecording.idSizesReply)) + } + return ([], 0, Data()) + } + let connection = OracleJDWPConnection(transport: peer, replyTimeout: .seconds(5)) + try await connection.handshake() + let session = OracleJDWPDebugSession( + connection: connection, + target: target, + helper: ScriptedChannel(), + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: calcBody, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: invocation, binds: []), + connectCall: Task { .success([]) }, + targetIdentity: identity, + callEndWait: .milliseconds(200), + notices: [] + ) + try await session.start(breakpoints: []) + + let firstStop = Task { await finished(within: 5) { try await session.firstStop() } } + var started = calls.makeAsyncIterator() + _ = await started.next() + peer.close() + + #expect(await firstStop.value == .failed(.stillRunning(identity))) + #expect(!target.sent.contains("ROLLBACK")) + } + + // Stop closes the socket, and the routine's call fails with it. ORA-30687 is the server ending the session; a + // failed transport (the target's own connection dropped too) ends the call only on this side, so the target is + // killed through the helper and the end stays unconfirmed. + @Test(arguments: [ + (OracleCoreError.connectionClosed, false), + (OracleCoreError.queryFailed("ORA-30687: session terminated by debugger"), true) + ]) + func testStopConfirmsTheEndOnlyFromTheServersAnswer(endedWith: OracleCoreError, serverAnswered: Bool) async throws { + let calcBody = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + let identity = OracleDebugSessionIdentity(sid: 42, serial: 7) + let invocation = "BEGIN PROBE.PKG_CALC.RUN; END;" + let socketClosed = Latch() + defer { socketClosed.open() } + let (calls, call) = AsyncStream.makeStream(of: Void.self) + let target = ScriptedChannel { sql in + guard sql == invocation else { return [] } + call.yield() + await socketClosed.wait() + throw endedWith + } + let helper = ScriptedChannel() + let peer = RespondingPeer { sent in + if sent.commandSet == OracleJDWPCommand.virtualMachine, sent.command == OracleJDWPCommand.idSizes { + return ([OracleJDWPRecording.vmStartEvent], 0, replyBody(OracleJDWPRecording.idSizesReply)) + } + return ([], 0, Data()) + } + let connection = OracleJDWPConnection(transport: ClosingPeer(peer: peer) { socketClosed.open() }, replyTimeout: .seconds(5)) + try await connection.handshake() + let session = OracleJDWPDebugSession( + connection: connection, + target: target, + helper: helper, + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: calcBody, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: invocation, binds: []), + connectCall: Task { .success([]) }, + targetIdentity: identity, + notices: [] + ) + try await session.start(breakpoints: []) + + let firstStop = Task { await finished(within: 5) { try await session.firstStop() } } + var started = calls.makeAsyncIterator() + _ = await started.next() + + #expect(await session.stop() == (serverAnswered ? .ended : .unconfirmed(identity))) + #expect(helper.sent.contains(where: { $0.hasPrefix("ALTER SYSTEM KILL SESSION '42,7'") }) == !serverAnswered) + #expect(await firstStop.value == (serverAnswered ? .failed(.stopped) : .failed(.stillRunning(identity)))) + } +} + +// Tells the test when this side closes the socket, which is when the database sees the debugger go. +private final class ClosingPeer: OracleJDWPTransport { + private let peer: RespondingPeer + private let onClose: @Sendable () -> Void + + init(peer: RespondingPeer, onClose: @escaping @Sendable () -> Void) { + self.peer = peer + self.onClose = onClose + } + + func send(_ data: Data) async throws { + try await peer.send(data) + } + + func receive() async throws -> Data? { + try await peer.receive() + } + + func close() { + peer.close() + onClose() + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConnectionTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConnectionTests.swift new file mode 100644 index 0000000000..6630164e44 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConnectionTests.swift @@ -0,0 +1,133 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +// Plays the database's side of the recorded conversation: each command this side sends is answered with the bytes the +// database sent at that point, the reply renumbered to the command's id. +private final class ReplayTransport: OracleJDWPTransport, @unchecked Sendable { + private struct State { + var inbound: [Data] = [] + var waiter: CheckedContinuation? + var sent: [Data] = [] + var closed = false + } + + private let answers: [UInt8: [Data]] + private let state = OSAllocatedUnfairLock(initialState: State()) + + init(greeting: Data, answers: [UInt8: [Data]]) { + self.answers = answers + deliver(greeting) + } + + var sent: [Data] { state.withLock { $0.sent } } + + func send(_ data: Data) async throws { + state.withLock { $0.sent.append(data) } + guard data.count >= 11 else { return } + let id = data.prefix(8).suffix(4) + let command = data[data.startIndex + 10] + for answer in answers[command] ?? [] { + var packet = answer + if packet[packet.startIndex + 8] & 0x80 != 0 { + packet.replaceSubrange(packet.startIndex + 4.. CheckedContinuation? in + if let waiter = current.waiter { + current.waiter = nil + return waiter + } + current.inbound.append(data) + return nil + } + waiter?.resume(returning: data) + } + + func receive() async throws -> Data? { + await withCheckedContinuation { continuation in + let ready = state.withLock { current -> Data?? in + if !current.inbound.isEmpty { return .some(current.inbound.removeFirst()) } + if current.closed { return .some(nil) } + current.waiter = continuation + return nil + } + if let ready { continuation.resume(returning: ready) } + } + } + + func close() { + let waiter = state.withLock { current -> CheckedContinuation? in + current.closed = true + defer { current.waiter = nil } + return current.waiter + } + waiter?.resume(returning: nil) + } +} + +struct OracleJDWPConnectionTests { + @Test func testRecordedHandshakeRepliesAndEventsReachTheirCallers() async throws { + let transport = ReplayTransport( + greeting: OracleJDWPPacket.handshake, + answers: [ + OracleJDWPCommand.idSizes: [OracleJDWPRecording.vmStartEvent, OracleJDWPRecording.idSizesReply], + OracleJDWPCommand.methods: [OracleJDWPRecording.methodsReply] + ] + ) + let connection = OracleJDWPConnection(transport: transport) + try await connection.handshake() + + let sizesReply = try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.idSizes) + var reader = OracleJDWPReader(sizesReply, sizes: .oracle) + #expect(try reader.i32() == 4) + + var commands = connection.commands.makeAsyncIterator() + let event = try #require(await commands.next()) + guard case let .command(_, 64, 100, body) = event else { + Issue.record("expected the composite event") + return + } + #expect(try OracleJDWPEventSet.decode(body, sizes: .oracle).events == [.vmStart(thread: 1)]) + + var writer = OracleJDWPWriter(sizes: .oracle) + writer.id(2, .referenceType) + let methods = try OracleJDWPMethod.decodeList( + try await connection.send(OracleJDWPCommand.referenceType, OracleJDWPCommand.methods, writer.data), + sizes: .oracle + ) + #expect(methods.map(\.name) == ["", "ADD_TAX", "RUN", "ADD_TAX"]) + #expect(transport.sent.first == OracleJDWPPacket.handshake) + + await connection.close() + await #expect(throws: OracleJDWPError.closed) { + try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.resume) + } + } + + @Test func testAPeerThatAnswersSomethingElseFailsTheHandshake() async { + let transport = ReplayTransport(greeting: Data("HTTP/1.1 400 Ba".utf8), answers: [:]) + let connection = OracleJDWPConnection(transport: transport) + await #expect(throws: OracleJDWPError.handshakeFailed) { + try await connection.handshake() + } + } + + @Test func testAReplyErrorCodeComesBackAsAnError() async throws { + let failing = OracleJDWPPacket.reply(id: 0, errorCode: 35, data: Data()).encoded + let transport = ReplayTransport( + greeting: OracleJDWPPacket.handshake, + answers: [OracleJDWPCommand.frameValues: [failing]] + ) + let connection = OracleJDWPConnection(transport: transport) + try await connection.handshake() + await #expect(throws: OracleJDWPError.reply(code: 35)) { + try await connection.send(OracleJDWPCommand.stackFrame, OracleJDWPCommand.frameValues) + } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPDebugInfoTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPDebugInfoTests.swift new file mode 100644 index 0000000000..23faedc0f0 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPDebugInfoTests.swift @@ -0,0 +1,89 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleJDWPDebugInfoTests { + private let sizes = OracleJDWPIDSizes.oracle + + private func body(_ packet: Data) throws -> Data { + let decoded = try #require(try OracleJDWPPacket.decode(from: packet)) + guard case let .reply(_, _, data) = decoded.packet else { throw OracleJDWPError.malformedPacket } + return data + } + + @Test func testRecordedMethodsListOverloadsWithTheirSignatures() throws { + let methods = try OracleJDWPMethod.decodeList(try body(OracleJDWPRecording.methodsReply), sizes: sizes) + #expect(methods.map(\.name) == ["", "ADD_TAX", "RUN", "ADD_TAX"]) + #expect(methods.map(\.id) == [3, 4, 5, 6]) + #expect(methods[1].argumentTypeNames == ["NUMBER"]) + #expect(methods[3].argumentTypeNames == ["NUMBER", "NUMBER"]) + #expect(!methods[0].isSubprogram) + #expect(methods[2].isSubprogram) + } + + // ADD_TAX's table from the live run: lines 3, 5, 6, 7, 8, code index equal to the line. + @Test func testRecordedLineTableMapsLinesAndIndexes() throws { + let table = try OracleJDWPLineTable.decode(try body(OracleJDWPRecording.lineTableReply), sizes: sizes) + #expect(table.start == 3) + #expect(table.end == 8) + #expect(table.lines == [3, 5, 6, 7, 8]) + #expect(table.line(at: 6) == 6) + #expect(table.location(atOrAfter: 4)?.line == 5) + #expect(table.location(atOrAfter: 6)?.index == 6) + #expect(table.location(atOrAfter: 9) == nil) + #expect(table.covers(line: 4)) + #expect(!table.covers(line: 9)) + } + + @Test func testAUnitWithoutDebugInformationHasEmptyLineTables() { + let table = OracleJDWPLineTable(start: 0, end: 0, entries: []) + #expect(table.isEmpty) + #expect(table.location(atOrAfter: 1) == nil) + #expect(!OracleJDWPMethod(id: 1, name: "", signature: "()V").isSubprogram) + } + + // RUN's table: I is declared by the loop at line 13 and lives three lines. + @Test func testVariableScopeFollowsTheTable() throws { + var writer = OracleJDWPWriter(sizes: sizes) + writer.i32(1) + writer.i32(3) + for (index, name, signature, length, slot) in [ + (Int64(9), "P_N", "L$Oracle/Builtin/PLS_INTEGER;", Int32(9), Int32(0)), + (9, "_return", "L$Oracle/Builtin/NUMBER;", 1, 1), + (13, "I", "L$Oracle/Builtin/PLS_INTEGER;", 3, 2) + ] { + writer.i64(index) + writer.string(name) + writer.string(signature) + writer.i32(length) + writer.i32(slot) + } + let variables = try OracleJDWPVariable.decodeTable(writer.data, sizes: sizes) + #expect(variables.map(\.isArgument) == [true, false, false]) + #expect(variables.map(\.isUserVariable) == [true, false, true]) + #expect(!variables[2].isVisible(at: 9)) + #expect(variables[2].isVisible(at: 14)) + #expect(!variables[2].isVisible(at: 16)) + #expect(variables[0].isVisible(at: 17)) + } + + @Test func testValueShapesFollowTheWrapperFields() { + let number = [ + OracleJDWPField(id: 1, name: "_value", signature: "Ljava/lang/String;", modifiers: 1), + OracleJDWPField(id: 2, name: "_canBeNull", signature: "Z", modifiers: 1) + ] + #expect(OracleDebugValueShape(fields: number) == .scalar(valueField: 1)) + let record = [ + OracleJDWPField(id: 3, name: "A", signature: "L$Oracle/Builtin/NUMBER;", modifiers: 1), + OracleJDWPField(id: 4, name: "B", signature: "L$Oracle/Builtin/VARCHAR2;", modifiers: 1), + OracleJDWPField(id: 5, name: "_type", signature: "Ljava/lang/String;", modifiers: 1) + ] + #expect(OracleDebugValueShape(fields: record) == .record(members: Array(record.prefix(2)))) + let collection = [ + OracleJDWPField(id: 7, name: "_values", signature: "[L$Oracle/Function/PROBE/F_CALC/F_CALC/T_NUMS$element;", modifiers: 17), + OracleJDWPField(id: 10, name: "_type", signature: "Ljava/lang/String;", modifiers: 25) + ] + #expect(OracleDebugValueShape(fields: collection) == .collection(valuesField: 7)) + #expect(OracleDebugValueShape(fields: []) == .opaque) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPHostileInputTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPHostileInputTests.swift new file mode 100644 index 0000000000..652d246fea --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPHostileInputTests.swift @@ -0,0 +1,146 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +// Anything that connects to the listener, or a compromised server, chooses these bytes. Each malformed input must be a +// thrown error that ends the session, never a trap or an allocation sized by the peer. +struct OracleJDWPHostileInputTests { + private let sizes = OracleJDWPIDSizes.oracle + + private func compositeBody(policy: UInt8 = 2, count: Int32, rest: Data = Data()) -> Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(policy) + writer.i32(count) + return writer.data + rest + } + + @Test func testANegativeEventCountThrows() { + #expect(throws: OracleJDWPError.malformedPacket) { + try OracleJDWPEventSet.decode(compositeBody(count: -1), sizes: sizes) + } + } + + @Test func testAnEventCountTheBytesCannotHoldThrows() { + #expect(throws: OracleJDWPError.malformedPacket) { + try OracleJDWPEventSet.decode(compositeBody(count: Int32.max), sizes: sizes) + } + // Two events claimed, one event's bytes sent. + var event = OracleJDWPWriter(sizes: sizes) + event.u8(OracleJDWPEventKind.vmStart.rawValue) + event.i32(0) + event.id(1, .object) + #expect(throws: OracleJDWPError.self) { + try OracleJDWPEventSet.decode(compositeBody(count: 2, rest: event.data), sizes: sizes) + } + } + + @Test func testListCountsAreBoundedByTheBytesLeft() { + var writer = OracleJDWPWriter(sizes: sizes) + writer.i32(Int32.max) + #expect(throws: OracleJDWPError.malformedPacket) { try OracleJDWPMethod.decodeList(writer.data, sizes: sizes) } + #expect(throws: OracleJDWPError.malformedPacket) { try OracleJDWPField.decodeList(writer.data, sizes: sizes) } + var lines = OracleJDWPWriter(sizes: sizes) + lines.i64(1) + lines.i64(2) + lines.i32(-5) + #expect(throws: OracleJDWPError.malformedPacket) { try OracleJDWPLineTable.decode(lines.data, sizes: sizes) } + var reader = OracleJDWPReader(Data([0xFF, 0xFF, 0xFF, 0xFF]), sizes: sizes) + #expect(throws: OracleJDWPError.malformedPacket) { try reader.count(minimumSize: 1) } + } + + @Test func testAVariableWhoseScopeOverflowsThrows() { + var writer = OracleJDWPWriter(sizes: sizes) + writer.i32(0) + writer.i32(1) + writer.i64(Int64.max) + writer.string("X") + writer.string("L$Oracle/Builtin/NUMBER;") + writer.i32(5) + writer.i32(0) + #expect(throws: OracleJDWPError.malformedPacket) { try OracleJDWPVariable.decodeTable(writer.data, sizes: sizes) } + } + + @Test func testScopeArithmeticNeverTraps() { + let variable = OracleJDWPVariable(codeIndex: Int64.max - 1, name: "X", signature: "", length: Int.max, slot: 0, isArgument: false) + #expect(variable.isVisible(at: Int64.max)) + #expect(!variable.isVisible(at: 0)) + } + + @Test func testIDSizesOutsideOneToEightAreRejected() { + #expect(throws: OracleJDWPError.malformedPacket) { + try OracleJDWPIDSizes.validated(field: 4, method: 4, object: 0x7FFF_FFFF, referenceType: 4, frame: 8) + } + #expect(throws: OracleJDWPError.malformedPacket) { + try OracleJDWPIDSizes.validated(field: 0, method: 4, object: 4, referenceType: 4, frame: 8) + } + #expect(throws: OracleJDWPError.malformedPacket) { + try OracleJDWPIDSizes.validated(field: 4, method: 4, object: 4, referenceType: 4, frame: -8) + } + #expect((try? OracleJDWPIDSizes.validated(field: 4, method: 4, object: 4, referenceType: 4, frame: 8)) == .oracle) + } + + @Test func testAFrameLongerThanTheCapThrowsBeforeItIsBuffered() { + var header = Data() + withUnsafeBytes(of: UInt32(OracleJDWPPacket.maximumLength + 1).bigEndian) { header.append(contentsOf: $0) } + header.append(contentsOf: [0, 0, 0, 1, 0, 64, 100]) + var buffer = OracleJDWPFrameBuffer() + buffer.append(header) + #expect(throws: OracleJDWPError.malformedPacket) { try buffer.nextPacket() } + var short = Data([0, 0, 0, 5, 0, 0, 0, 1, 0, 1, 1]) + short.append(0) + #expect(throws: OracleJDWPError.malformedPacket) { try OracleJDWPPacket.decode(from: short) } + } + + @Test func testAnIDWiderThanEightBytesIsNeverRead() { + let wide = OracleJDWPIDSizes(field: 4, method: 4, object: 9, referenceType: 4, frame: 8) + var reader = OracleJDWPReader(Data(repeating: 0, count: 16), sizes: wide) + #expect(throws: OracleJDWPError.malformedPacket) { try reader.id(.object) } + var writer = OracleJDWPWriter(sizes: OracleJDWPIDSizes(field: 4, method: 4, object: 0x7FFF_FFFF, referenceType: 4, frame: 8)) + writer.id(1, .object) + #expect(writer.data.count == 8) + } + + // A malformed frame closes the connection, which is what ends the target, instead of leaving it suspended. + @Test func testAMalformedFrameClosesTheConnection() async throws { + let transport = ScriptedTransport(chunks: [OracleJDWPPacket.handshake, Data([0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 1, 0, 64, 100])]) + let connection = OracleJDWPConnection(transport: transport) + try await connection.handshake() + var commands = connection.commands.makeAsyncIterator() + #expect(await commands.next() == nil) + #expect(await connection.closed) + #expect(transport.isClosed) + } + + @Test func testPayloadLengthsCannotOverflow() { + #expect(throws: OracleDebugPayload.ParseError.self) { try OracleDebugPayload.parse("99999999999999999999:x") } + #expect(throws: OracleDebugPayload.ParseError.self) { try OracleDebugPayload.parse("999999999:x") } + } + + @Test func testALineMapClaimingHugeLinesReadsOnlyItsBytes() { + #expect(OracleDBMSDebugStatements.executableLines(hexMap: "FE", maxLine: Int.max) == [1, 2, 3, 4, 5, 6, 7]) + #expect(OracleDBMSDebugStatements.executableLines(hexMap: "", maxLine: 500).isEmpty) + #expect(OracleDBMSDebugStatements.executableLines(hexMap: "FF", maxLine: -3).isEmpty) + } +} + +private final class ScriptedTransport: OracleJDWPTransport, @unchecked Sendable { + private let lock = NSLock() + private var chunks: [Data] + private var closed = false + + init(chunks: [Data]) { + self.chunks = chunks + } + + var isClosed: Bool { lock.withLock { closed } } + + func send(_ data: Data) async throws {} + + func receive() async throws -> Data? { + lock.withLock { chunks.isEmpty ? nil : chunks.removeFirst() } + } + + func close() { + lock.withLock { closed = true } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPListenerTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPListenerTests.swift new file mode 100644 index 0000000000..618984b753 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPListenerTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Network +@testable import TableProOracleCore +import Testing + +// The listener runs on loopback here: the same code a database server reaches over the network. +struct OracleJDWPListenerTests { + private let ports: ClosedRange = 47_100...47_199 + + private func dial(_ port: UInt16, sending payload: Data?) -> NWConnection { + let connection = NWConnection( + host: "127.0.0.1", + port: NWEndpoint.Port(rawValue: port) ?? .any, + using: .tcp + ) + connection.stateUpdateHandler = { state in + if case .ready = state, let payload { + connection.send(content: payload, completion: .idempotent) + } + } + connection.start(queue: .global()) + return connection + } + + @Test func testAPeerFromTheDatabasesAddressIsAccepted() async throws { + let listener = try await OracleJDWPListener.open(ports: ports, bindAddresses: ["127.0.0.1"], allowedPeers: ["127.0.0.1"]) + let peer = dial(listener.port, sending: OracleJDWPPacket.handshake) + defer { peer.cancel() } + let connection = try await listener.accept(timeout: .seconds(5)) + await connection.close() + } + + // A strict address check refuses a peer the database host does not resolve to, and names it for the person. + @Test func testAPeerFromAnotherAddressIsClosedAndNamed() async throws { + let listener = try await OracleJDWPListener.open(ports: ports, bindAddresses: ["127.0.0.1"], allowedPeers: ["10.9.8.7"]) + let peer = dial(listener.port, sending: OracleJDWPPacket.handshake) + defer { peer.cancel() } + await #expect(throws: OracleJDWPListenerError.timedOut(rejected: ["127.0.0.1"])) { + try await listener.accept(timeout: .seconds(1)) + } + } + + // A connection that never sends the handshake is dropped after two seconds, and the real one still gets in. + @Test func testASilentPeerDoesNotHoldTheListener() async throws { + let listener = try await OracleJDWPListener.open(ports: ports, bindAddresses: ["127.0.0.1"], allowedPeers: ["127.0.0.1"]) + let silent = dial(listener.port, sending: nil) + defer { silent.cancel() } + let real = dial(listener.port, sending: OracleJDWPPacket.handshake) + defer { real.cancel() } + let started = ContinuousClock.now + let connection = try await listener.accept(timeout: .seconds(5)) + #expect(ContinuousClock.now - started < .seconds(2)) + await connection.close() + } + + @Test func testCancellingTheWaitEndsIt() async throws { + let listener = try await OracleJDWPListener.open(ports: ports, bindAddresses: ["127.0.0.1"], allowedPeers: ["127.0.0.1"]) + let started = ContinuousClock.now + let waiting = Task { try await listener.accept(timeout: .seconds(30)) } + waiting.cancel() + await #expect(throws: CancellationError.self) { try await waiting.value } + #expect(ContinuousClock.now - started < .seconds(5)) + } + + // `localhost` resolves to ::1 first on macOS, but Docker Desktop delivers a container's connection on 127.0.0.1. + @Test func testALoopbackRouteBinds127() async { + #expect(await OracleDebugNetwork.bindAddresses(debuggerHost: "localhost", databaseHost: "localhost", databasePort: 1_521) + == ["127.0.0.1"]) + #expect(await OracleDebugNetwork.bindAddresses(debuggerHost: "name.invalid", databaseHost: "localhost", databasePort: 1_521) + == ["127.0.0.1"]) + #expect(await OracleDebugNetwork.bindAddresses(debuggerHost: "::1", databaseHost: "localhost", databasePort: 1_521) + == ["::1"]) + #expect(OracleDebugNetwork.isLoopback("::1")) + #expect(OracleDebugNetwork.isLoopback("127.0.0.1")) + #expect(!OracleDebugNetwork.isLoopback("192.168.1.20")) + } + + @Test func testAddressesCompareInOneSpelling() { + #expect(OracleDebugNetwork.normalized("::FFFF:127.0.0.1") == "127.0.0.1") + #expect(OracleDebugNetwork.normalized("fe80::1%en0") == "fe80::1") + #expect(OracleDebugNetwork.normalized("[::1]") == "::1") + #expect(OracleDebugNetwork.resolve("127.0.0.1") == ["127.0.0.1"]) + #expect(OracleDebugNetwork.localInterfaceAddresses().contains("127.0.0.1")) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPPacketTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPPacketTests.swift new file mode 100644 index 0000000000..c515a88b56 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPPacketTests.swift @@ -0,0 +1,122 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleJDWPPacketTests { + private let sizes = OracleJDWPIDSizes.oracle + + @Test func testIDSizesCommandEncodesAsRecorded() { + let packet = OracleJDWPPacket.command(id: 1, commandSet: 1, command: 7, data: Data()) + #expect(packet.encoded == OracleJDWPRecording.bytes("0000000b00000001000107")) + #expect(packet.encoded == OracleJDWPRecording.idSizesCommand) + } + + @Test func testReplyAndCommandRoundTrip() throws { + let reply = OracleJDWPPacket.reply(id: 7, errorCode: 35, data: Data([1, 2, 3])) + let command = OracleJDWPPacket.command(id: 9, commandSet: 64, command: 100, data: Data([4])) + for packet in [reply, command] { + let decoded = try #require(try OracleJDWPPacket.decode(from: packet.encoded)) + #expect(decoded.packet == packet) + #expect(decoded.length == packet.encoded.count) + } + } + + @Test func testIDSizesReplyDecodesOraclesSizes() throws { + let decoded = try #require(try OracleJDWPPacket.decode(from: OracleJDWPRecording.idSizesReply)) + guard case let .reply(id, errorCode, data) = decoded.packet else { + Issue.record("expected a reply") + return + } + #expect(id == 1) + #expect(errorCode == 0) + var reader = OracleJDWPReader(data, sizes: sizes) + let read = try (0..<5).map { _ in try reader.i32() } + #expect(read == [4, 4, 4, 4, 8]) + } + + @Test func testAPartialPacketWaitsForTheRest() throws { + var buffer = OracleJDWPFrameBuffer() + let whole = OracleJDWPRecording.vmStartEvent + OracleJDWPRecording.idSizesReply + buffer.append(whole.prefix(20)) + #expect(try buffer.nextPacket() == nil) + buffer.append(whole.dropFirst(20)) + let first = try #require(try buffer.nextPacket()) + let second = try #require(try buffer.nextPacket()) + #expect(try buffer.nextPacket() == nil) + guard case .command(_, 64, 100, _) = first, case .reply(1, 0, _) = second else { + Issue.record("expected the event, then the reply") + return + } + } + + // Oracle sends VM_START before it answers IDSizes, with a 4-byte thread id. + @Test func testRecordedEventsDecode() throws { + func events(_ data: Data) throws -> [OracleJDWPEvent] { + let decoded = try #require(try OracleJDWPPacket.decode(from: data)) + guard case let .command(_, 64, 100, body) = decoded.packet else { return [] } + return try OracleJDWPEventSet.decode(body, sizes: sizes).events + } + #expect(try events(OracleJDWPRecording.vmStartEvent) == [.vmStart(thread: 1)]) + #expect(try events(OracleJDWPRecording.classPrepareEvent) == [ + .classPrepare(requestID: 1, thread: 1, typeID: 2, signature: "L$Oracle/PackageBody/PROBE/PKG_CALC;") + ]) + #expect(try events(OracleJDWPRecording.breakpointEvent) == [ + .breakpoint(requestID: 2, thread: 1, location: OracleJDWPLocation(classID: 2, methodID: 4, index: 6)) + ]) + } + + @Test func testRequestsEncodeAsRecorded() { + let classPrepare = OracleJDWPRequest.classPrepare(matching: "$Oracle/PackageBody/PROBE/PKG_CALC", sizes: sizes) + #expect(OracleJDWPPacket.command(id: 3, commandSet: 15, command: 1, data: classPrepare).encoded + == OracleJDWPRecording.classPrepareCommand) + let breakpoint = OracleJDWPRequest.breakpoint( + at: OracleJDWPLocation(classID: 2, methodID: 4, index: 6), + sizes: sizes + ) + #expect(OracleJDWPPacket.command(id: 7, commandSet: 15, command: 1, data: breakpoint).encoded + == OracleJDWPRecording.breakpointCommand) + var frames = OracleJDWPWriter(sizes: sizes) + frames.id(1, .object) + frames.i32(0) + frames.i32(-1) + #expect(OracleJDWPPacket.command(id: 9, commandSet: 11, command: 6, data: frames.data).encoded + == OracleJDWPRecording.framesCommand) + } + + @Test func testFramesReplyReadsThreeFramesWithEightByteIDs() throws { + let decoded = try #require(try OracleJDWPPacket.decode(from: OracleJDWPRecording.framesReply)) + guard case let .reply(_, _, data) = decoded.packet else { + Issue.record("expected a reply") + return + } + var reader = OracleJDWPReader(data, sizes: sizes) + #expect(try reader.i32() == 3) + #expect(try reader.id(.frame) == 0x1_0000_0001) + #expect(try reader.location() == OracleJDWPLocation(classID: 2, methodID: 4, index: 6)) + #expect(try reader.id(.frame) == 0x1_0000_0002) + #expect(try reader.location() == OracleJDWPLocation(classID: 2, methodID: 5, index: 14)) + } + + @Test func testTaggedValuesReadEachWidth() throws { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(UInt8(ascii: "Z")) + writer.u8(1) + writer.u8(UInt8(ascii: "I")) + writer.i32(30) + writer.u8(UInt8(ascii: "s")) + writer.id(0, .object) + writer.u8(UInt8(ascii: "L")) + writer.id(72, .object) + var reader = OracleJDWPReader(writer.data, sizes: sizes) + #expect(try reader.taggedValue() == .primitive(tag: UInt8(ascii: "Z"), bits: 1)) + #expect(try reader.taggedValue() == .primitive(tag: UInt8(ascii: "I"), bits: 30)) + #expect(try reader.taggedValue() == .object(tag: OracleJDWPValue.stringTag, id: 0)) + #expect(try reader.taggedValue().objectID == 72) + #expect(reader.isAtEnd) + } + + @Test func testATruncatedBodyThrows() { + var reader = OracleJDWPReader(Data([0, 0, 0, 9, 65]), sizes: sizes) + #expect(throws: OracleJDWPError.truncated) { try reader.string() } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPRecording.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPRecording.swift new file mode 100644 index 0000000000..d2b92701e6 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPRecording.swift @@ -0,0 +1,63 @@ +import Foundation + +// Bytes from a live run against Oracle AI Database 26ai Free 23.26.3 (the debugger stopping in PROBE.PKG_CALC.ADD_TAX at +// line 6, called from RUN line 14): what this side sent and what the database sent back, in order. +enum OracleJDWPRecording { + static let idSizesCommand = bytes("0000000b00000001000107") + static let vmStartEvent = bytes("000000190000000100406402000000015a0000000000000001") + static let idSizesReply = bytes("0000001f000000018000000000000400000004000000040000000400000008") + static let versionCommand = bytes("0000000b00000002000101") + static let versionReply = bytes( + "0000006e000000028000000000002c4f7261636c65204d756c74696c616e677561676520446562756767657220766572" + + "73696f6e2032332e302e3000000001000000000000000632332e302e300000001d4f7261636c65204d756c74696c616e" + + "6775616765204465627567676572" + ) + static let classPrepareCommand = bytes( + "0000003800000003000f010802000000010500000022244f7261636c652f5061636b616765426f64792f50524f42452f" + + "504b475f43414c43" + ) + static let classPrepareReply = bytes("0000000f0000000380000000000001") + static let resumeCommand = bytes("0000000b00000004000109") + static let resumeReply = bytes("0000000b00000004800000") + static let classPrepareEvent = bytes( + "0000004a0000000400406402000000010800000001000000010100000002000000244c244f7261636c652f5061636b61" + + "6765426f64792f50524f42452f504b475f43414c433b00000007" + ) + static let methodsCommand = bytes("0000000f0000000500020500000002") + static let methodsReply = bytes( + "00000107000000058000000000000400000003000000083c636c696e69743e000000032829560000000a000000040000" + + "00074144445f54415800000032284c244f7261636c652f4275696c74696e2f4e554d4245523b294c244f7261636c652f" + + "4275696c74696e2f4e554d4245523b00000009000000050000000352554e00000020284c244f7261636c652f4275696c" + + "74696e2f504c535f494e54454745523b29560000000900000006000000074144445f5441580000004a284c244f726163" + + "6c652f4275696c74696e2f4e554d4245523b4c244f7261636c652f4275696c74696e2f4e554d4245523b294c244f7261" + + "636c652f4275696c74696e2f4e554d4245523b00000009" + ) + static let lineTableCommand = bytes("00000013000000060006010000000200000004") + static let lineTableReply = bytes( + "0000005b0000000680000000000000000000030000000000000008000000050000000000000003000000030000000000" + + "00000500000005000000000000000600000006000000000000000700000007000000000000000800000008" + ) + static let breakpointCommand = bytes("0000002300000007000f01020200000001070100000002000000040000000000000006") + static let breakpointReply = bytes("0000000f0000000780000000000002") + static let resume2Command = bytes("0000000b00000008000109") + static let resume2Reply = bytes("0000000b00000008800000") + static let breakpointEvent = bytes("0000002a0000000700406402000000010200000002000000010100000002000000040000000000000006") + static let framesCommand = bytes("0000001700000009000b060000000100000000ffffffff") + static let framesReply = bytes( + "0000005a0000000980000000000003000000010000000101000000020000000400000000000000060000000100000002" + + "010000000200000005000000000000000e0000000100000003010000000a000000040000000000000001" + ) + static let disposeCommand = bytes("0000000b0000000a000106") + static let disposeReply = bytes("0000000b0000000a800000") + + static func bytes(_ hex: String) -> Data { + var data = Data() + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + data.append(UInt8(hex[index..? + var closed = false + var sent: [Sent] = [] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + private let respond: @Sendable (Sent) -> (events: [Data], errorCode: UInt16, body: Data)? + + init(respond: @escaping @Sendable (Sent) -> (events: [Data], errorCode: UInt16, body: Data)?) { + self.respond = respond + } + + var sent: [Sent] { state.withLock { $0.sent } } + + func send(_ data: Data) async throws { + guard data != OracleJDWPPacket.handshake, let (packet, _) = try OracleJDWPPacket.decode(from: data), + case let .command(id, commandSet, command, body) = packet else { return } + let sent = Sent(commandSet: commandSet, command: command, body: body) + state.withLock { $0.sent.append(sent) } + guard let answer = respond(sent) else { return } + for event in answer.events { + deliver(event) + } + deliver(OracleJDWPPacket.reply(id: id, errorCode: answer.errorCode, data: answer.body).encoded) + } + + private func deliver(_ data: Data) { + let waiter = state.withLock { current -> CheckedContinuation? in + if let waiter = current.waiter { + current.waiter = nil + return waiter + } + current.inbound.append(data) + return nil + } + waiter?.resume(returning: data) + } + + func receive() async throws -> Data? { + await withCheckedContinuation { continuation in + let ready = state.withLock { current -> Data?? in + if !current.inbound.isEmpty { return .some(current.inbound.removeFirst()) } + if current.closed { return .some(nil) } + current.waiter = continuation + return nil + } + if let ready { continuation.resume(returning: ready) } + } + } + + func close() { + let waiter = state.withLock { current -> CheckedContinuation? in + current.closed = true + defer { current.waiter = nil } + return current.waiter + } + waiter?.resume(returning: nil) + } +} + +func replyBody(_ packet: Data) -> Data { + guard let decoded = try? OracleJDWPPacket.decode(from: packet), case let .reply(_, _, body) = decoded.packet else { return Data() } + return body +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSessionStartTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSessionStartTests.swift new file mode 100644 index 0000000000..e46ed0a81b --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSessionStartTests.swift @@ -0,0 +1,99 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +struct OracleJDWPSessionStartTests { + private let calcBody = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + + private func makeSession(_ peer: RespondingPeer) async throws -> OracleJDWPDebugSession { + let connection = OracleJDWPConnection(transport: peer, replyTimeout: .seconds(5)) + try await connection.handshake() + let offline = OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + return OracleJDWPDebugSession( + connection: connection, + target: offline, + helper: offline, + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: calcBody, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: "BEGIN NULL; END;", binds: []), + connectCall: Task { .success([]) }, + notices: [] + ) + } + + // A logon trigger or startup command that ran the package loads it before CONNECT_TCP, so no CLASS_PREPARE may + // announce it; the session finds it with ClassesBySignature and sets the entry breakpoint before resuming. + @Test func testAnAlreadyLoadedUnitGetsItsEntryBreakpointBeforeTheResume() async throws { + let requestIDs = OSAllocatedUnfairLock(initialState: Int32(0)) + let peer = RespondingPeer { sent in + var writer = OracleJDWPWriter(sizes: .oracle) + switch (sent.commandSet, sent.command) { + case (OracleJDWPCommand.virtualMachine, OracleJDWPCommand.idSizes): + return ([OracleJDWPRecording.vmStartEvent], 0, replyBody(OracleJDWPRecording.idSizesReply)) + case (OracleJDWPCommand.virtualMachine, OracleJDWPCommand.classesBySignature): + writer.i32(1) + writer.u8(1) + writer.id(2, .referenceType) + writer.i32(7) + case (OracleJDWPCommand.referenceType, OracleJDWPCommand.methods): + return ([], 0, replyBody(OracleJDWPRecording.methodsReply)) + case (OracleJDWPCommand.method, OracleJDWPCommand.lineTable): + return ([], 0, replyBody(OracleJDWPRecording.lineTableReply)) + case (OracleJDWPCommand.eventRequest, OracleJDWPCommand.set): + writer.i32(requestIDs.withLock { id -> Int32 in + id += 1 + return id + }) + default: + break + } + return ([], 0, writer.data) + } + let session = try await makeSession(peer) + try await session.start(breakpoints: []) + let sent = peer.sent + let breakpoint = sent.firstIndex { + $0.commandSet == OracleJDWPCommand.eventRequest && $0.command == OracleJDWPCommand.set + && $0.body.first == OracleJDWPEventKind.breakpoint.rawValue + } + let resume = sent.firstIndex { $0.commandSet == OracleJDWPCommand.virtualMachine && $0.command == OracleJDWPCommand.resume } + #expect(breakpoint != nil) + #expect(resume != nil) + if let breakpoint, let resume { + #expect(breakpoint < resume) + } + #expect(await session.stop() == .ended) + } + + // An error code the database answers becomes a written error, never an unreadable Swift one. + @Test func testARefusedCommandBecomesAWrittenError() async throws { + let peer = RespondingPeer { sent in + if sent.commandSet == OracleJDWPCommand.virtualMachine, sent.command == OracleJDWPCommand.idSizes { + return ([OracleJDWPRecording.vmStartEvent], 0, replyBody(OracleJDWPRecording.idSizesReply)) + } + return ([], 99, Data()) + } + let session = try await makeSession(peer) + await #expect(throws: OracleDebugError.debuggerRefused(code: 99)) { + try await session.start(breakpoints: []) + } + #expect(await session.stop() == .ended) + } + + // One slot the database cannot read fails the batch; each is then read alone and only that one is left out. + @Test func testAnUnreadableSlotIsSkippedInsteadOfFailingTheFrame() async throws { + let calls = OSAllocatedUnfairLock(initialState: [[Int]]()) + let values = try await OracleJDWPDebugSession.readSlots([0, 1, 2]) { slots in + calls.withLock { $0.append(slots) } + if slots.contains(1) { throw OracleJDWPError.reply(code: 35) } + return slots.map { OracleJDWPValue.object(tag: OracleJDWPValue.objectTag, id: UInt64($0 + 10)) } + } + #expect(values == [.object(tag: OracleJDWPValue.objectTag, id: 10), nil, .object(tag: OracleJDWPValue.objectTag, id: 12)]) + #expect(calls.withLock { $0 } == [[0, 1, 2], [0], [1], [2]]) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSilentPeerTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSilentPeerTests.swift new file mode 100644 index 0000000000..5e714089af --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSilentPeerTests.swift @@ -0,0 +1,178 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +// A peer that completes the handshake, answers IDSizes with the recorded reply and its VM_START, then never answers +// again: what a hung or hostile server looks like after the database connected back. +private final class SilentPeerTransport: OracleJDWPTransport, @unchecked Sendable { + private struct State { + var inbound: [Data] = [OracleJDWPPacket.handshake] + var waiter: CheckedContinuation? + var closed = false + var commands = 0 + var commandWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + var closeWaiters: [CheckedContinuation] = [] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + var isClosed: Bool { state.withLock { $0.closed } } + + // Returns once this many commands (packets after the handshake) have been sent. + func commandsSent(_ count: Int) async { + await withCheckedContinuation { continuation in + let ready = state.withLock { current -> Bool in + if current.commands >= count { return true } + current.commandWaiters.append((count, continuation)) + return false + } + if ready { continuation.resume() } + } + } + + func closedEvent() async { + await withCheckedContinuation { continuation in + let ready = state.withLock { current -> Bool in + if current.closed { return true } + current.closeWaiters.append(continuation) + return false + } + if ready { continuation.resume() } + } + } + + func send(_ data: Data) async throws { + guard data.count >= 11 else { return } + let reached = state.withLock { current -> [CheckedContinuation] in + current.commands += 1 + let ready = current.commandWaiters.filter { $0.count <= current.commands }.map(\.continuation) + current.commandWaiters.removeAll { $0.count <= current.commands } + return ready + } + for waiter in reached { + waiter.resume() + } + guard data[data.startIndex + 9] == OracleJDWPCommand.virtualMachine, + data[data.startIndex + 10] == OracleJDWPCommand.idSizes else { return } + var reply = OracleJDWPRecording.idSizesReply + reply.replaceSubrange(reply.startIndex + 4.. CheckedContinuation? in + if let waiter = current.waiter { + current.waiter = nil + return waiter + } + current.inbound.append(data) + return nil + } + waiter?.resume(returning: data) + } + + func receive() async throws -> Data? { + await withCheckedContinuation { continuation in + let ready = state.withLock { current -> Data?? in + if !current.inbound.isEmpty { return .some(current.inbound.removeFirst()) } + if current.closed { return .some(nil) } + current.waiter = continuation + return nil + } + if let ready { continuation.resume(returning: ready) } + } + } + + func close() { + let (waiter, closeWaiters) = state.withLock { current -> (CheckedContinuation?, [CheckedContinuation]) in + current.closed = true + defer { + current.waiter = nil + current.closeWaiters.removeAll() + } + return (current.waiter, current.closeWaiters) + } + waiter?.resume(returning: nil) + for closeWaiter in closeWaiters { + closeWaiter.resume() + } + } +} + +struct OracleJDWPSilentPeerTests { + private func session(replyTimeout: Duration) async throws -> (OracleJDWPDebugSession, SilentPeerTransport) { + let transport = SilentPeerTransport() + let connection = OracleJDWPConnection(transport: transport, replyTimeout: replyTimeout) + try await connection.handshake() + let unit = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + let entryPoint = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: unit, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ) + let offline = OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + let session = OracleJDWPDebugSession( + connection: connection, + target: offline, + helper: offline, + entryPoint: entryPoint, + invocation: OracleDebugStatements.Invocation(block: "BEGIN NULL; END;", binds: []), + connectCall: Task { .success([]) }, + notices: [] + ) + return (session, transport) + } + + // The ClassPrepare request after VM_START gets no answer; the reply timeout ends the start and the socket. + @Test func testAStartAgainstASilentPeerFailsWithinTheReplyTimeout() async throws { + let (session, transport) = try await session(replyTimeout: .milliseconds(300)) + let started = ContinuousClock.now + await #expect(throws: OracleDebugError.debuggerStoppedAnswering) { + try await session.start(breakpoints: []) + } + #expect(ContinuousClock.now - started < .seconds(3)) + #expect(transport.isClosed) + } + + // Tab close or module off cancels the start: it returns at once and the socket closes, which ends the target. + @Test func testCancellingAStartWaitingOnASilentPeerReturnsPromptly() async throws { + let (session, transport) = try await session(replyTimeout: .seconds(60)) + let start = Task { try await session.start(breakpoints: []) } + // IDSizes is answered; the ClassPrepare request after VM_START is the one left waiting. + await transport.commandsSent(2) + let started = ContinuousClock.now + start.cancel() + await #expect(throws: CancellationError.self) { try await start.value } + await transport.closedEvent() + #expect(ContinuousClock.now - started < .seconds(3)) + } + + @Test func testAReplyThatNeverComesFailsTheCommandAndClosesTheConnection() async throws { + let transport = SilentPeerTransport() + let connection = OracleJDWPConnection(transport: transport, replyTimeout: .milliseconds(200)) + try await connection.handshake() + await #expect(throws: OracleJDWPError.timedOut) { + try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.resume) + } + #expect(await connection.closed) + #expect(transport.isClosed) + await #expect(throws: OracleJDWPError.closed) { + try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.resume) + } + } + + @Test func testACancelledCommandThrowsCancellationAndClosesTheConnection() async throws { + let transport = SilentPeerTransport() + let connection = OracleJDWPConnection(transport: transport, replyTimeout: .seconds(60)) + try await connection.handshake() + let waiting = Task { try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.resume) } + await transport.commandsSent(1) + waiting.cancel() + await #expect(throws: CancellationError.self) { try await waiting.value } + await transport.closedEvent() + #expect(await connection.closed) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPStopOverlapTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPStopOverlapTests.swift new file mode 100644 index 0000000000..cf976abab1 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPStopOverlapTests.swift @@ -0,0 +1,56 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleJDWPStopOverlapTests { + private let calcBody = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + + private func makeSession(_ peer: RespondingPeer) async throws -> OracleJDWPDebugSession { + let connection = OracleJDWPConnection(transport: peer, replyTimeout: .seconds(5)) + try await connection.handshake() + let offline = OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + return OracleJDWPDebugSession( + connection: connection, + target: offline, + helper: offline, + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: calcBody, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: "BEGIN NULL; END;", binds: []), + connectCall: Task { .success([]) }, + notices: [] + ) + } + + // Stop closes the socket, which fails the reply the breakpoint call still awaits: the call ends with Stop at once, + // not at the 5 s reply timeout. + @Test func testStopEndsABreakpointCallStillWaitingForItsReply() async throws { + let (arrivals, arrival) = AsyncStream.makeStream(of: Void.self) + let peer = RespondingPeer { _ in + arrival.yield() + return nil + } + let session = try await makeSession(peer) + let breakpoint = Task { try await session.setBreakpoint(in: calcBody, line: 4) } + var waiting = arrivals.makeAsyncIterator() + _ = await waiting.next() + let started = ContinuousClock.now + #expect(await session.stop() == .ended) + await #expect(throws: OracleDebugError.ended) { _ = try await breakpoint.value } + #expect(ContinuousClock.now - started < .seconds(3)) + } + + @Test func testConcurrentStopsShareOneOutcomeAndLaterCallsSendNothing() async throws { + let peer = RespondingPeer { _ in ([], 0, Data()) } + let session = try await makeSession(peer) + async let first = session.stop() + async let second = session.stop() + #expect(await [first, second] == [.ended, .ended]) + await #expect(throws: OracleDebugError.ended) { _ = try await session.setBreakpoint(in: calcBody, line: 4) } + await #expect(throws: OracleDebugError.ended) { _ = try await session.source(of: calcBody) } + #expect(peer.sent.isEmpty) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePLSQLDeclarationScanTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePLSQLDeclarationScanTests.swift new file mode 100644 index 0000000000..375106131c --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePLSQLDeclarationScanTests.swift @@ -0,0 +1,220 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OraclePLSQLDeclarationScanTests { + // PROBE.PKG_CALC's body exactly as ALL_SOURCE returned it in the measurements. + private let packageBody = """ + package body pkg_calc as + g_rate number := 0.1; + function add_tax(p_amount number) return number is + l_tax number; + begin + l_tax := p_amount * g_rate; + return p_amount + l_tax; + end add_tax; + procedure run(p_n pls_integer) is + l_total number := 0; + l_name varchar2(30) := 'probe'; + begin + for i in 1 .. p_n loop + l_total := l_total + add_tax(i); + end loop; + dbms_output.put_line(l_name || ' total=' || l_total); + end run; + function add_tax(p_amount number, p_rate number) return number is + begin + return p_amount + p_amount * p_rate; + end add_tax; + end pkg_calc; + """ + + @Test func testSubprogramsAndTheirLineRanges() { + let scan = OraclePLSQLDeclarationScan(source: packageBody) + let subprograms = scan.subprograms.map { "\($0.name) \($0.headerLine)-\($0.endLine ?? 0)" } + #expect(subprograms == ["ADD_TAX 3-8", "RUN 9-17", "ADD_TAX 18-21"]) + #expect(scan.headerLines(ofSubprogramNamed: "ADD_TAX") == [3, 18]) + #expect(scan.headerLines(ofSubprogramNamed: "RUN") == [9]) + } + + @Test func testCandidatesFollowTheFrameLine() { + let scan = OraclePLSQLDeclarationScan(source: packageBody) + #expect(scan.candidates(atLine: 6).map(\.name) == ["P_AMOUNT", "L_TAX"]) + #expect(scan.candidates(atLine: 6).map(\.kind) == [.parameter, .variable]) + #expect(scan.candidates(atLine: 14).map(\.name) == ["P_N", "L_TOTAL", "L_NAME", "I"]) + #expect(scan.candidates(atLine: 9).map(\.name) == ["P_N", "L_TOTAL", "L_NAME"]) + #expect(scan.candidates(atLine: 20).map(\.name) == ["P_AMOUNT", "P_RATE"]) + #expect(scan.candidates(atLine: 11).first { $0.name == "L_NAME" }?.typeText == "VARCHAR2(30)") + #expect(scan.packageVariables.map(\.name) == ["G_RATE"]) + } + + // A FOR loop's index lives from its FOR to its END LOOP, and shadows a local of the same name only there. + @Test func testALoopIndexIsVisibleOnlyInsideItsLoop() { + let scan = OraclePLSQLDeclarationScan(source: packageBody) + #expect(scan.candidates(atLine: 16).map(\.name) == ["P_N", "L_TOTAL", "L_NAME"]) + + let shadowing = OraclePLSQLDeclarationScan(source: """ + procedure p is + i varchar2(10) := 'outer'; + begin + for i in 1 .. 2 loop + null; + end loop; + null; + end p; + """) + #expect(shadowing.candidates(atLine: 5).first { $0.name == "I" }?.kind == .iterator) + #expect(shadowing.candidates(atLine: 7).first { $0.name == "I" }?.kind == .variable) + } + + @Test func testTypesCursorsExceptionsAndConstantsAreTold() { + let scan = OraclePLSQLDeclarationScan(source: """ + function f_calc(p_a number, p_b in out nocopy varchar2 default 'x') return number is + type t_rec is record (a number, b varchar2(10)); + cursor c_rows is select 1 from dual; + e_bad exception; + pragma exception_init(e_bad, -20001); + c_limit constant pls_integer := 10; + l_rec t_rec; + begin + return p_a * 2; + end f_calc; + """) + let names = scan.candidates(atLine: 9).map(\.name) + #expect(names == ["P_A", "P_B", "C_LIMIT", "L_REC"]) + #expect(scan.candidates(atLine: 9).first { $0.name == "C_LIMIT" }?.kind == .constant) + #expect(scan.candidates(atLine: 9).first { $0.name == "P_B" }?.typeText == "VARCHAR2") + } + + @Test func testCommentsStringsAndQuotedNamesDoNotConfuseTheScan() { + let scan = OraclePLSQLDeclarationScan(source: """ + procedure p is + -- procedure fake is begin end; + /* function g return number is begin return 1; end; */ + l_text varchar2(100) := q'[it's begin; end]'; + "Mixed Case" number; + begin + l_text := 'end; begin'; + declare + l_inner number; + begin + null; + end; + case when l_text is null then null; else null; end case; + end p; + """) + #expect(scan.subprograms.map(\.name) == ["P"]) + #expect(scan.subprograms.first?.endLine == 14) + #expect(scan.candidates(atLine: 7).map(\.name) == ["L_TEXT", "Mixed Case"]) + #expect(scan.candidates(atLine: 11).map(\.name) == ["L_TEXT", "Mixed Case", "L_INNER"]) + } + + // GET_VALUE reads the innermost binding of a name, so the row must carry that declaration's type. + @Test func testAnInnerBlockShadowsAnOuterNameOnlyWhileItRuns() { + let scan = OraclePLSQLDeclarationScan(source: """ + procedure p_shadow is + x constant number := 1; + l_outer number := 2; + begin + declare + x varchar2(20) := 'inner'; + begin + l_outer := l_outer + 1; + end; + l_outer := x; + end p_shadow; + """) + let inside = scan.candidates(atLine: 8) + #expect(inside.map(\.name) == ["L_OUTER", "X"]) + #expect(inside.first { $0.name == "X" }?.typeText == "VARCHAR2(20)") + #expect(inside.first { $0.name == "X" }?.kind == .variable) + let after = scan.candidates(atLine: 10) + #expect(after.map(\.name) == ["X", "L_OUTER"]) + #expect(after.first { $0.name == "X" }?.typeText == "NUMBER") + #expect(after.first { $0.name == "X" }?.kind == .constant) + } + + @Test func testANestedBlockIsVisibleOnlyInsideItAndToSubprogramsItDeclares() { + let scan = OraclePLSQLDeclarationScan(source: """ + procedure p_nested(p_n number) is + l_a number := 1; + begin + declare + l_b number := 2; + procedure show(p_v number) is + begin + dbms_output.put_line(p_v + l_b); + end show; + begin + show(l_a); + end; + l_a := l_a + p_n; + end p_nested; + """) + #expect(scan.candidates(atLine: 8).map(\.name) == ["P_V", "P_N", "L_A", "L_B"]) + #expect(scan.candidates(atLine: 11).map(\.name) == ["P_N", "L_A", "L_B"]) + #expect(scan.candidates(atLine: 13).map(\.name) == ["P_N", "L_A"]) + } + + // Oracle folds an unquoted name to upper case: `y` is `"Y"`, but `x` is not `"x"`. + @Test func testAQuotedNameIsShadowedOnlyByTheSameName() { + let scan = OraclePLSQLDeclarationScan(source: """ + procedure p_case is + "x" number := 1; + "Y" number := 2; + begin + declare + x varchar2(20) := 'upper'; + y varchar2(5) := 'y'; + begin + null; + end; + null; + end p_case; + """) + let inside = scan.candidates(atLine: 9) + #expect(inside.map(\.lookupName) == ["\"x\"", "X", "Y"]) + #expect(inside.map(\.typeText) == ["NUMBER", "VARCHAR2(20)", "VARCHAR2(5)"]) + let after = scan.candidates(atLine: 11) + #expect(after.map(\.lookupName) == ["\"x\"", "\"Y\""]) + #expect(after.map(\.typeText) == ["NUMBER", "NUMBER"]) + } + + // A quoted name keeps its case and its dots and spaces; GET_VALUE gets it quoted, the Variables list shows it bare. + @Test func testAQuotedNameIsLookedUpQuotedAndShownBare() { + let scan = OraclePLSQLDeclarationScan(source: """ + package body pkg_quoted as + "g.Rate" number := 0.2; + procedure run("p.in" number) is + "a.b" number := 7; + "lower" number; + "with space" varchar2(10); + l_plain number; + begin + for "i.x" in 1 .. 2 loop + null; + end loop; + end run; + end pkg_quoted; + """) + let candidates = scan.candidates(atLine: 10) + #expect(candidates.map(\.name) == ["p.in", "a.b", "lower", "with space", "L_PLAIN", "i.x"]) + #expect(candidates.map(\.lookupName) == ["\"p.in\"", "\"a.b\"", "\"lower\"", "\"with space\"", "L_PLAIN", "\"i.x\""]) + #expect(scan.packageVariables.map(\.name) == ["g.Rate"]) + #expect(scan.packageVariables.map(\.lookupName) == ["\"g.Rate\""]) + } + + // A specification's forward declarations are not definitions, and their parameters are nobody's locals. + @Test func testASpecificationListsOnlyItsVariables() { + let scan = OraclePLSQLDeclarationScan(source: """ + package pkg_calc as + g_spec_count pls_integer := 0; + function add_tax(p_amount number) return number; + procedure run(p_n pls_integer); + end pkg_calc; + """) + #expect(scan.packageVariables.map(\.name) == ["G_SPEC_COUNT"]) + #expect(scan.declarations.allSatisfy { $0.kind != .parameter }) + #expect(scan.headerLines(ofSubprogramNamed: "RUN").isEmpty) + } +} diff --git a/apple/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift b/apple/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift index 574236ec49..76cc8b6d8f 100644 --- a/apple/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift +++ b/apple/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift @@ -165,7 +165,10 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin { booleanLiteralStyle: .truefalse, likeEscapeStyle: .implicit, paginationStyle: .limit, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) static let explainVariants: [ExplainVariant] = [ diff --git a/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index 52750936e2..0be08c02fb 100644 --- a/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -106,7 +106,10 @@ internal final class CassandraPlugin: NSObject, TableProPlugin, DriverPlugin, Pl likeEscapeStyle: .explicit, paginationStyle: .limit, autoLimitStyle: .limit, - caseSensitivityStyle: .unsupported + caseSensitivityStyle: .unsupported, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) } diff --git a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index 231d727576..cff710c29a 100644 --- a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -28,6 +28,8 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { static let supportsSSL = false static let isDownloadable = true static let supportsImport = false + // D1 runs each request in a transaction of its own, where user queries cannot change foreign key enforcement. + static let supportsForeignKeyDisable = false static let supportsSchemaEditing = true static let supportsTriggers = true static let supportsDatabaseTriggerBrowse = true @@ -95,7 +97,10 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { booleanLiteralStyle: .numeric, likeEscapeStyle: .explicit, paginationStyle: .limit, - caseSensitivityStyle: .collationDefined + caseSensitivityStyle: .collationDefined, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) static let additionalConnectionFields: [ConnectionField] = [ diff --git a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 9a5f766972..1a6ba5844c 100644 --- a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -42,7 +42,6 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable [ .parameterizedQueries, .alterTableDDL, - .foreignKeyToggle, .truncateTable, .cancelQuery, .schemaCompare, @@ -545,16 +544,6 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable return "DROP VIEW IF EXISTS \(quoted);\nCREATE VIEW \(quoted) AS\nSELECT * FROM table_name;" } - // MARK: - Foreign Key Checks - - func foreignKeyDisableStatements() -> [String]? { - ["PRAGMA foreign_keys = OFF"] - } - - func foreignKeyEnableStatements() -> [String]? { - ["PRAGMA foreign_keys = ON"] - } - // MARK: - Table Operations func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { diff --git a/apple/Plugins/DamengDriverPlugin/DamengPlugin.swift b/apple/Plugins/DamengDriverPlugin/DamengPlugin.swift index 20b67e055f..f4a18bb052 100644 --- a/apple/Plugins/DamengDriverPlugin/DamengPlugin.swift +++ b/apple/Plugins/DamengDriverPlugin/DamengPlugin.swift @@ -99,7 +99,10 @@ final class DamengPlugin: NSObject, TableProPlugin, DriverPlugin { paginationStyle: .offsetFetch, offsetFetchOrderBy: "ORDER BY 1", autoLimitStyle: .fetchFirst, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 8067b85e00..ce3cd3ebc4 100644 --- a/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -165,7 +165,8 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin { operators: SQLEngineVocabulary.duckDB.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), // DuckDB has no LIKE or lower() for INTEGER, UUID or DATE: `~~(INTEGER, STRING_LITERAL)` is a binder error. textCastTypeName: "VARCHAR", - functionNamesAreCaseInsensitive: SQLEngineVocabulary.duckDB.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.duckDB.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/apple/Plugins/FormatPluginTests/MQLExportReadsRowsTests.swift b/apple/Plugins/FormatPluginTests/MQLExportReadsRowsTests.swift new file mode 100644 index 0000000000..f1337ac4d0 --- /dev/null +++ b/apple/Plugins/FormatPluginTests/MQLExportReadsRowsTests.swift @@ -0,0 +1,17 @@ +// +// MQLExportReadsRowsTests.swift +// FormatPluginTests +// + +import TableProPluginKit +import Testing + +struct MQLExportReadsRowsTests { + @Test("An MQL export reads a collection's documents only with Data checked") + func readsRowsOnlyWithData() { + let plugin = MQLExportPlugin() + #expect(plugin.readsRows(optionValues: [true, true, true], kind: .table)) + #expect(!plugin.readsRows(optionValues: [true, true, false], kind: .table)) + #expect(plugin.readsRows(optionValues: [], kind: .table)) + } +} diff --git a/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift b/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift index 2bb3b6da92..7f469d581c 100644 --- a/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift +++ b/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift @@ -268,4 +268,11 @@ struct SQLExportSessionDeclarationTests { #expect(parts.count == 1) #expect(!writer.didSplit) } + @Test("KingbaseES dumps declare PostgreSQL string and encoding semantics") + func kingbaseSessionDeclaration() { + #expect(SQLExportSessionDeclaration.forDatabaseType("KingbaseES") + == SQLExportSessionDeclaration.forDatabaseType("PostgreSQL")) + #expect(!SQLExportSessionDeclaration.forDatabaseType("KingbaseES").prologue.isEmpty) + } + } diff --git a/apple/Plugins/HanaDriverPlugin/HanaMetadata.swift b/apple/Plugins/HanaDriverPlugin/HanaMetadata.swift index 78143c4846..02d9ac6faf 100644 --- a/apple/Plugins/HanaDriverPlugin/HanaMetadata.swift +++ b/apple/Plugins/HanaDriverPlugin/HanaMetadata.swift @@ -70,6 +70,7 @@ enum HanaMetadata { caseSensitivityStyle: .caseFoldFunction, textCastTypeName: nil, functionNamesAreCaseInsensitive: true, - lexicalFeatures: [.dollarAndHashInIdentifiers] + lexicalFeatures: [.dollarAndHashInIdentifiers], + typeNamesAreCaseInsensitive: true ) } diff --git a/apple/Plugins/KingbaseESDriverPlugin/Info.plist b/apple/Plugins/KingbaseESDriverPlugin/Info.plist new file mode 100644 index 0000000000..2a09d82f10 --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/Info.plist @@ -0,0 +1,12 @@ + + + + + TableProPluginKitVersion + TABLEPRO_PLUGINKIT_VERSION + TableProProvidesDatabaseTypeIds + + KingbaseES + + + diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESCompatibilityMode.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESCompatibilityMode.swift new file mode 100644 index 0000000000..3dca8c1353 --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESCompatibilityMode.swift @@ -0,0 +1,37 @@ +import Foundation +import TableProPluginKit + +/// The SQL a KingbaseES server speaks, fixed when its cluster is initialized. +enum KingbaseESCompatibilityMode: Equatable, Sendable { + case postgreSQL + case oracle + + // The two-argument form answers NULL where the parameter does not exist, which reads as PostgreSQL mode. + static let probeQuery = "SELECT current_setting('database_mode', true)" + + static let lexicalFeaturesItDecides: SQLLexicalFeatures = [ + .alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers, + ] + + init(setting: String?) throws { + switch setting?.lowercased() { + case nil, "pg": + self = .postgreSQL + case "oracle": + self = .oracle + case let other?: + throw LibPQPluginError( + message: String( + format: String(localized: "KingbaseES in %@ mode is not supported. Connect to a server in PostgreSQL or Oracle mode."), + other + ), + sqlState: nil, + detail: nil + ) + } + } + + var lexicalFeatures: SQLLexicalFeatures { + self == .oracle ? Self.lexicalFeaturesItDecides : [] + } +} diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift new file mode 100644 index 0000000000..bbcf6caae8 --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift @@ -0,0 +1,72 @@ +import Foundation +import TableProPluginKit + +final class KingbaseESPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "KingbaseES Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "KingbaseES database support via libpq" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "KingbaseES" + static let databaseDisplayName = "KingbaseES" + static let iconName = "cylinder" + static let defaultPort = 54_321 + static let isDownloadable = true + static let urlSchemes = ["kingbase", "kingbase8"] + static let brandColorHex = "#C9141E" + static let systemDatabaseNames = PostgreSQLPlugin.systemDatabaseNames + static let systemSchemaNames = [ + "pg_catalog", "information_schema", "pg_toast", "sys_catalog", "sys", "anon", "dbms_job", "dbms_scheduler", + "dbms_sql", "kdb_schedule", "perf", "pg_bitmapindex", "src_restrict", "sys_hm", "sysaudit", "sysmac", "wmsys", + "xlog_record_read" + ] + + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "usePgpass", + label: String(localized: "Use ~/.pgpass"), + defaultValue: "false", + fieldType: .toggle, + section: .authentication, + hidesPassword: true + ), + ConnectionField( + id: "connectionOptions", + label: String(localized: "Connection Options"), + fieldType: .text, + section: .advanced + ) + ] + + static let supportsSchemaSwitching = PostgreSQLPlugin.supportsSchemaSwitching + static let postConnectActions = PostgreSQLPlugin.postConnectActions + static let explainVariants = PostgreSQLPlugin.explainVariants + static let databaseGroupingStrategy = PostgreSQLPlugin.databaseGroupingStrategy + static let columnTypesByCategory = PostgreSQLPlugin.columnTypesByCategory + static let supportsCascadeDrop = PostgreSQLPlugin.supportsCascadeDrop + static let supportsForeignKeyDisable = PostgreSQLPlugin.supportsForeignKeyDisable + static let requiresReconnectForDatabaseSwitch = PostgreSQLPlugin.requiresReconnectForDatabaseSwitch + static let parameterStyle = PostgreSQLPlugin.parameterStyle + static let supportsDropDatabase = PostgreSQLPlugin.supportsDropDatabase + static let supportsRenameTable = PostgreSQLPlugin.supportsRenameTable + static let supportsRenameDatabase = PostgreSQLPlugin.supportsRenameDatabase + static let supportsRenameSchema = PostgreSQLPlugin.supportsRenameSchema + static let supportsDropSchema = PostgreSQLPlugin.supportsDropSchema + static let supportsCreateSchema = PostgreSQLPlugin.supportsCreateSchema + static let supportsSchemaOwner = PostgreSQLPlugin.supportsSchemaOwner + static let supportsSchemaPrivileges = PostgreSQLPlugin.supportsSchemaPrivileges + static let supportsTriggers = PostgreSQLPlugin.supportsTriggers + static let supportsRoutines = PostgreSQLPlugin.supportsRoutines + static let supportsDatabaseTriggerBrowse = PostgreSQLPlugin.supportsDatabaseTriggerBrowse + static let supportsUserDefinedTypeBrowse = PostgreSQLPlugin.supportsUserDefinedTypeBrowse + static let supportsTriggerEditing = PostgreSQLPlugin.supportsTriggerEditing + static let structureColumnFields = PostgreSQLPlugin.structureColumnFields + static let supportsCheckConstraints = PostgreSQLPlugin.supportsCheckConstraints + static let supportsCheckConstraintEditing = PostgreSQLPlugin.supportsCheckConstraintEditing + static let supportsGeneratedColumns = PostgreSQLPlugin.supportsGeneratedColumns + static let sqlDialect = PostgreSQLPlugin.sqlDialect + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + KingbaseESPluginDriver(config: config) + } +} diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift new file mode 100644 index 0000000000..4a3d8b45ec --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift @@ -0,0 +1,23 @@ +import Foundation +import os +import TableProPluginKit + +final class KingbaseESPluginDriver: PostgreSQLPluginDriver, @unchecked Sendable { + private let mode = OSAllocatedUnfairLock(initialState: KingbaseESCompatibilityMode.postgreSQL) + + override var sessionLexicalState: PluginSessionLexicalState? { + guard let inherited = super.sessionLexicalState else { return nil } + let modeFeatures = mode.withLock { $0 }.lexicalFeatures + return PluginSessionLexicalState( + determined: inherited.determined.union(KingbaseESCompatibilityMode.lexicalFeaturesItDecides), + enabled: inherited.enabled.union(modeFeatures) + ) + } + + override func probeAfterConnect() async throws { + let result = try await execute(query: KingbaseESCompatibilityMode.probeQuery) + let detected = try KingbaseESCompatibilityMode(setting: result.rows.first?.first?.asText) + mode.withLock { $0 = detected } + try await super.probeAfterConnect() + } +} diff --git a/apple/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/apple/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift index 0fdb76f803..84c62073bc 100644 --- a/apple/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift +++ b/apple/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -99,7 +99,10 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin { booleanLiteralStyle: .numeric, likeEscapeStyle: .explicit, paginationStyle: .limit, - caseSensitivityStyle: .collationDefined + caseSensitivityStyle: .collationDefined, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) static let additionalConnectionFields: [ConnectionField] = [ diff --git a/apple/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/apple/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index 9860cdb4d4..e8bb9c6300 100644 --- a/apple/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/apple/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -45,7 +45,6 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var base: PluginCapabilities = [ .parameterizedQueries, .alterTableDDL, - .foreignKeyToggle, .truncateTable, .cancelQuery, .schemaCompare, @@ -54,6 +53,7 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { if isLocalMode { base.insert(.transactions) base.insert(.batchExecute) + base.insert(.foreignKeyToggle) } return base } @@ -581,12 +581,25 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Foreign Key Checks + // Remote runs every statement on a stream of its own, so a pragma is gone before the next statement runs. func foreignKeyDisableStatements() -> [String]? { - ["PRAGMA foreign_keys = OFF"] + isLocalMode ? ["PRAGMA foreign_keys = OFF"] : nil } func foreignKeyEnableStatements() -> [String]? { - ["PRAGMA foreign_keys = ON"] + isLocalMode ? ["PRAGMA foreign_keys = ON"] : nil + } + + // MARK: - Session Scope + + func openSessionScope(for purpose: PluginSessionPurpose) async throws -> PluginSessionScope? { + guard isLocalMode else { return nil } + return try await SQLiteSessionScope.open(for: purpose) { try await self.execute(query: $0) } + } + + func closeSessionScope(_ scope: PluginSessionScope) async throws { + guard isLocalMode else { return } + try await SQLiteSessionScope.close(scope) { try await self.execute(query: $0) } } // MARK: - Table Operations diff --git a/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift b/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift index 6074a27517..5577c1dee8 100644 --- a/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift +++ b/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift @@ -19,9 +19,10 @@ final class MQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugi static let supportedDatabaseTypeIds = ["MongoDB"] static let perTableOptionColumns: [PluginExportOptionColumn] = [ + // The DROP statement, left untranslated: the catalog's "Drop" is the verb that deletes a table. PluginExportOptionColumn(id: "drop", label: "Drop", width: 44), - PluginExportOptionColumn(id: "indexes", label: "Indexes", width: 44), - PluginExportOptionColumn(id: "data", label: "Data", width: 44) + PluginExportOptionColumn(id: "indexes", label: String(localized: "Indexes", bundle: .main), width: 44), + PluginExportOptionColumn(id: "data", label: String(localized: "Data", bundle: .main), width: 44) ] typealias Settings = MQLExportOptions @@ -41,6 +42,11 @@ final class MQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugi optionValues.contains(true) } + // Without it, Data off still counts every collection's rows and the progress sits at 0 of N. + func readsRows(optionValues: [Bool], kind: PluginExportObjectKind) -> Bool { + optionValues.count <= 2 || optionValues[2] + } + @MainActor func settingsView() -> AnyView? { AnyView(MQLExportOptionsView(plugin: self)) diff --git a/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 0eb1e86733..759a8528aa 100644 --- a/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -232,7 +232,8 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.sqlServer.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlServer.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlServer.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) static let supportsDropDatabase = true diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLColumnChecks.swift b/apple/Plugins/MySQLDriverPlugin/MySQLColumnChecks.swift new file mode 100644 index 0000000000..b573f175d0 --- /dev/null +++ b/apple/Plugins/MySQLDriverPlugin/MySQLColumnChecks.swift @@ -0,0 +1,250 @@ +// +// MySQLColumnChecks.swift +// MySQLDriverPlugin +// +// MariaDB's CHECK written on a column: where it is read from, which column owns it, and how a +// MODIFY restates it. +// + +import Foundation +import os +import TableProMySQLCore +import TableProPluginKit + +/// MariaDB lists a column's own CHECK in `CHECK_CONSTRAINTS` with `LEVEL = 'Column'` and drops it on any +/// `MODIFY` that does not restate it. Measured on 11.4: the constraint keeps the name the column had when +/// the check was written through a rename, while the clause follows the rename, so the owner is the +/// column whose `SHOW CREATE TABLE` line ends with the clause. A `CHANGE` that restates the clause with +/// the new name keeps exactly one check. +internal enum MySQLColumnChecks { + static func clausesQuery(schema: String, table: String) -> String { + """ + SELECT CHECK_CLAUSE + FROM INFORMATION_SCHEMA.CHECK_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = '\(mysqlEscapeStringLiteral(schema))' + AND TABLE_NAME = '\(mysqlEscapeStringLiteral(table))' + AND LEVEL = 'Column' + """ + } + + /// A name as the server prints it in `SHOW CREATE TABLE` and in a clause: backticked, or in double + /// quotes under `ANSI_QUOTES`. + static func printedNames(_ name: String) -> [String] { + identifierQuotes.map { quoted(name, with: $0) } + } + + /// Each clause given to the column whose line ends with ` CHECK ()`. A clause no line ends with + /// stays unplaced, and its column is left with none. + static func place(clauses: [String], columns: [String], createTable: String) -> [String: String] { + var unplaced = clauses + var placed: [String: String] = [:] + for column in columns { + guard let line = columnLine(column, in: createTable), + let index = unplaced.firstIndex(where: { line.hasSuffix(" CHECK (\($0))") }) + else { continue } + placed[column] = unplaced.remove(at: index) + } + return placed + } + + /// The clause as a `CHANGE` restates it: only a quoted identifier is renamed, never a literal such as a JSON path. + static func restated(_ clause: String?, renaming oldName: String, to newName: String) -> String? { + guard let clause = clause?.nilIfEmpty else { return nil } + guard oldName != newName else { return clause } + let scalars = Array(clause.unicodeScalars) + var text = String.UnicodeScalarView() + var copied = 0 + for identifier in quotedIdentifiers(in: scalars) where namesColumn(identifier.name, oldName) { + text.append(contentsOf: scalars[copied.. String? { + let modifications = operations.compactMap { operation -> (old: PluginColumnDefinition, new: PluginColumnDefinition)? in + guard case .modifyColumn(let old, let new) = operation else { return nil } + return (old, new) + } + let renamed = modifications.filter { $0.old.name != $0.new.name }.map { $0.old.name } + for modification in modifications { + guard let clause = restated( + modification.new.columnCheck, renaming: modification.old.name, to: modification.new.name + ) else { continue } + let identifiers = quotedIdentifiers(in: Array(clause.unicodeScalars)).map(\.name) + let named = renamed.first { name in + name != modification.old.name && identifiers.contains { namesColumn($0, name) } + } + guard let named else { continue } + return String( + format: String( + localized: "The CHECK on column %@ names column %@, which this save renames. Save the rename on its own first." + ), + modification.new.name, + named + ) + } + return nil + } + + private static let identifierQuotes: [Unicode.Scalar] = ["`", "\""] + + private struct QuotedIdentifier { + let quote: Unicode.Scalar + let name: String + let range: Range + } + + /// Column names compare without regard to case on MariaDB. + private static func namesColumn(_ identifier: String, _ column: String) -> Bool { + identifier.caseInsensitiveCompare(column) == .orderedSame + } + + private static func quoted(_ name: String, with quote: Unicode.Scalar) -> String { + var text = String.UnicodeScalarView([quote]) + for scalar in name.unicodeScalars { + text.append(scalar) + if scalar == quote { text.append(quote) } + } + text.append(quote) + return String(text) + } + + /// Scalars, not Characters: a combining mark after a quote would hide it inside one grapheme. + private static func quotedIdentifiers(in scalars: [Unicode.Scalar]) -> [QuotedIdentifier] { + var found: [QuotedIdentifier] = [] + var index = 0 + while index < scalars.count { + let scalar = scalars[index] + if scalar == "'" { + index = endOfStringLiteral(in: scalars, openingAt: index) + } else if identifierQuotes.contains(scalar) { + guard let identifier = quotedIdentifier(in: scalars, openingAt: index) else { break } + found.append(identifier) + index = identifier.range.upperBound + } else { + index += 1 + } + } + return found + } + + /// MariaDB prints a literal in single quotes, escaping a quote inside it as `''` or `\'`. + private static func endOfStringLiteral(in scalars: [Unicode.Scalar], openingAt start: Int) -> Int { + var index = start + 1 + while index < scalars.count { + switch scalars[index] { + case "\\": + index += 2 + case "'": + guard index + 1 < scalars.count, scalars[index + 1] == "'" else { return index + 1 } + index += 2 + default: + index += 1 + } + } + return scalars.count + } + + /// Nil for an identifier the clause never closes, which leaves the rest of it as read. + private static func quotedIdentifier(in scalars: [Unicode.Scalar], openingAt start: Int) -> QuotedIdentifier? { + let quote = scalars[start] + var name = String.UnicodeScalarView() + var index = start + 1 + while index < scalars.count { + guard scalars[index] == quote else { + name.append(scalars[index]) + index += 1 + continue + } + guard index + 1 < scalars.count, scalars[index + 1] == quote else { + return QuotedIdentifier(quote: quote, name: String(name), range: start..<(index + 1)) + } + name.append(quote) + index += 2 + } + return nil + } + + /// `SHOW CREATE TABLE` prints each column on a line of its own, two spaces in, with line breaks inside + /// strings escaped. + private static func columnLine(_ column: String, in createTable: String) -> Substring? { + for printed in printedNames(column) { + guard let head = createTable.range(of: "\n \(printed) ") else { continue } + let line = createTable[head.lowerBound...].dropFirst(3).prefix { $0 != "\n" } + return line.hasSuffix(",") ? line.dropLast() : line + } + return nil + } +} + +internal extension MySQLPluginDriver { + func reviewSchemaChange( + table: String, + schema: String?, + operations: [PluginSchemaOperation] + ) async throws -> PluginSchemaChangeReview { + guard flavor.isMariaDB else { return PluginSchemaChangeReview() } + return PluginSchemaChangeReview(refusal: MySQLColumnChecks.renameRefusal(operations: operations)) + } + + /// Each column's own check, by column name. Empty off MariaDB, before its catalog has `LEVEL`, and when + /// the catalog refuses: the columns then read as they did before checks were read at all. + func mariadbColumnChecks(table: String, schema: String?, columns: [String]) async throws -> [String: String] { + let identity = serverIdentity + guard identity.flavor.isMariaDB, + MySQLCheckConstraints.source(banner: identity.banner, flavor: identity.flavor) == .informationSchema, + catalogVisibility.visibility(of: effectiveSchema(schema)) != .blind, + try await catalogHasColumn(.checkConstraintLevel), + await !resolvesToTemporaryTable(table, schema: schema) + else { return [:] } + do { + let result = try await execute( + ownStatement: MySQLColumnChecks.clausesQuery(schema: effectiveSchema(schema), table: table) + ) + let clauses = result.rows.compactMap { $0[safe: 0]?.asText } + guard !clauses.isEmpty else { return [:] } + let ddl = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(table, schema: schema))") + guard let createTable = ddl.rows.first?[safe: 1]?.asText else { return [:] } + return MySQLColumnChecks.place(clauses: clauses, columns: columns, createTable: createTable) + } catch let error as MariaDBPluginError + where MySQLCatalogVisibilityRule.settlesBlindness(code: error.code) { + Self.logger.warning( + "column check read refused code=\(error.code, privacy: .public) message=\(error.message)" + ) + return [:] + } + } +} + +internal extension PluginColumnInfo { + func withColumnCheck(_ columnCheck: String?) -> PluginColumnInfo { + PluginColumnInfo( + name: name, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: isPrimaryKey, + defaultValue: defaultValue, + extra: extra, + charset: charset, + collation: collation, + comment: comment, + identityKind: identityKind, + isGenerated: isGenerated, + allowedValues: allowedValues, + generationExpression: generationExpression, + generationKind: generationKind, + ddlSpelling: ddlSpelling, + ddlDefault: ddlDefault, + ddlGenerationExpression: ddlGenerationExpression, + ddlCollation: ddlCollation, + classificationTypeName: classificationTypeName, + columnCheck: columnCheck + ) + } +} diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift b/apple/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift index 674c8bd1af..378ecb0af9 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift @@ -91,18 +91,72 @@ internal func mysqlIsNullLiteral(_ value: String) -> Bool { value.trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("NULL") == .orderedSame } +/// Which of `CHARACTER SET` and `COLLATE` a column type takes. Measured on MySQL 8.4 and MariaDB 11.4: +/// `CHARACTER SET` on `INT`, `VARBINARY`, `BLOB` or `JSON` is ERROR 1064, and so is `CHARACTER SET` on a +/// national spelling (`NVARCHAR`), which takes `COLLATE` alone. A type outside the list takes neither. +internal enum MySQLColumnTextType: Equatable { + case none + case collationOnly + case characterSetAndCollation + + init(dataType: String) { + let words = dataType.uppercased() + .prefix { $0 != "(" } + .split(whereSeparator: \.isWhitespace) + guard let first = words.first else { + self = .none + return + } + let second = words.dropFirst().first + switch first { + case "NATIONAL", "NCHAR", "NVARCHAR": + self = .collationOnly + case "CHAR", "CHARACTER": + self = second == "BYTE" ? .none : .characterSetAndCollation + case "LONG": + self = second == "VARBINARY" ? .none : .characterSetAndCollation + case "VARCHAR", "VARCHARACTER", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT", "ENUM", "SET": + self = .characterSetAndCollation + default: + self = .none + } + } + + var takesCharacterSet: Bool { self == .characterSetAndCollation } + var takesCollation: Bool { self != .none } +} + +/// A name the catalog lists is a bare word; anything else is backticked, so the server refuses it as +/// unknown rather than reading the rest of it as SQL. +internal func mysqlCharsetName(_ name: String) -> String { + mysqlIsSafeCharsetIdentifier(name) ? name : mysqlQuoteIdentifier(name) +} + +internal func mysqlIsSafeCharsetIdentifier(_ value: String) -> Bool { + guard !value.isEmpty else { return false } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) + return value.unicodeScalars.allSatisfy { allowed.contains($0) } +} + +internal func mysqlCharacterSetClauses(_ column: PluginColumnDefinition) -> String { + let textType = MySQLColumnTextType(dataType: column.dataType) + var clauses = "" + if textType.takesCharacterSet, let charset = column.charset?.nilIfEmpty { + clauses += " CHARACTER SET \(mysqlCharsetName(charset))" + } + if textType.takesCollation, let collation = column.collation?.nilIfEmpty { + clauses += " COLLATE \(mysqlCharsetName(collation))" + } + return clauses +} + internal func mysqlColumnAttributesSQL(_ column: PluginColumnDefinition, isMariaDB: Bool) -> String { var def = "" if column.unsigned { def += " UNSIGNED" } - if let charset = column.charset, !charset.isEmpty { - def += " CHARACTER SET \(charset)" - } - if let collation = column.collation, !collation.isEmpty { - def += " COLLATE \(collation)" - } + def += mysqlCharacterSetClauses(column) def += column.isNullable ? " NULL" : " NOT NULL" if let defaultValue = column.defaultValue { @@ -122,7 +176,18 @@ internal func mysqlColumnAttributesSQL(_ column: PluginColumnDefinition, isMaria return def } -internal func mysqlColumnDefinitionSQL(_ column: PluginColumnDefinition, isMariaDB: Bool = false) -> String { +/// `columnCheck` is the column's own `CHECK` as the statement restates it, printed last on the line as +/// MariaDB prints it. Only a `MODIFY` or a move of a column that already has one passes it. +internal func mysqlColumnDefinitionSQL( + _ column: PluginColumnDefinition, + isMariaDB: Bool = false, + columnCheck: String? = nil +) -> String { + let check = columnCheck?.nilIfEmpty.map { " CHECK (\($0))" } ?? "" + return mysqlColumnBodySQL(column, isMariaDB: isMariaDB) + check +} + +private func mysqlColumnBodySQL(_ column: PluginColumnDefinition, isMariaDB: Bool) -> String { let name = mysqlQuoteIdentifier(column.name) guard let expression = column.generationExpression?.nilIfEmpty else { return "\(name) \(column.dataType)" + mysqlColumnAttributesSQL(column, isMariaDB: isMariaDB) @@ -134,13 +199,7 @@ internal func mysqlColumnDefinitionSQL(_ column: PluginColumnDefinition, isMaria // out reset a generated string column to the table defaults, because MODIFY COLUMN replaces // the whole definition and a reorder restates it. let kind = (column.generationKind ?? .virtual).rawValue - var definition = "\(name) \(column.dataType)" - if let charset = column.charset, !charset.isEmpty { - definition += " CHARACTER SET \(charset)" - } - if let collation = column.collation, !collation.isEmpty { - definition += " COLLATE \(collation)" - } + var definition = "\(name) \(column.dataType)" + mysqlCharacterSetClauses(column) definition += " GENERATED ALWAYS AS (\(expression)) \(kind)" if !column.isNullable { definition += " NOT NULL" } if let comment = column.comment, !comment.isEmpty { diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift b/apple/Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift index 7d14afe536..1d4696393b 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift @@ -40,10 +40,10 @@ internal func mysqlCreateTableSQL( tableOptions.append("ENGINE=\(engine)") } if let charset = definition.charset, !charset.isEmpty { - tableOptions.append("DEFAULT CHARSET=\(charset)") + tableOptions.append("DEFAULT CHARSET=\(mysqlCharsetName(charset))") } if let collation = definition.collation, !collation.isEmpty { - tableOptions.append("COLLATE=\(collation)") + tableOptions.append("COLLATE=\(mysqlCharsetName(collation))") } if !tableOptions.isEmpty { sql += " " + tableOptions.joined(separator: " ") diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 19c02e1fec..e38685d0d2 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -94,7 +94,8 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.mySQL.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.mySQL.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.mySQL.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) static let supportsDropDatabase = true diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift index c8ab0ebfa6..37d1147aee 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift @@ -70,7 +70,7 @@ extension MySQLPluginDriver { ) } - guard isSafeCharsetIdentifier(charset) else { + guard mysqlIsSafeCharsetIdentifier(charset) else { throw MariaDBPluginError( code: 0, message: String(format: String(localized: "Invalid character set: %@"), charset), @@ -90,7 +90,7 @@ extension MySQLPluginDriver { let collationValue = request.values["collation"].flatMap { $0.isEmpty ? nil : $0 } if let collation = collationValue { - guard isSafeCharsetIdentifier(collation) else { + guard mysqlIsSafeCharsetIdentifier(collation) else { throw MariaDBPluginError( code: 0, message: String(format: String(localized: "Invalid collation: %@"), collation), @@ -130,61 +130,11 @@ extension MySQLPluginDriver { } private extension MySQLPluginDriver { - struct CharsetDefault { - let charset: String - let defaultCollation: String - } - - struct CollationEntry { - let collation: String - let charset: String - } - struct ServerCharsetDefaults { let charset: String? let collation: String? } - /// Before 5.0.2 the list comes from `SHOW CHARACTER SET`, whose columns are `Charset`, - /// `Description`, `Default collation` and `Maxlen`, in no promised order. - func fetchCharsetDefaults() async throws -> [CharsetDefault] { - let hasCatalog = serverHasInformationSchema - let query = hasCatalog ? """ - SELECT character_set_name, default_collate_name - FROM information_schema.character_sets - ORDER BY character_set_name - """ : "SHOW CHARACTER SET" - let collationColumn = hasCatalog ? 1 : 2 - let result = try await execute(query: query) - return result.rows.compactMap { row -> CharsetDefault? in - guard let charset = row[safe: 0]?.asText, - let collation = row[safe: collationColumn]?.asText else { - return nil - } - return CharsetDefault(charset: charset, defaultCollation: collation) - } - .sorted { $0.charset < $1.charset } - } - - /// Before 5.0.2 the list comes from `SHOW COLLATION`, whose first two columns are `Collation` and - /// `Charset`, in no promised order. - func fetchCollationCatalog() async throws -> [CollationEntry] { - let query = serverHasInformationSchema ? """ - SELECT collation_name, character_set_name - FROM information_schema.collations - ORDER BY collation_name - """ : "SHOW COLLATION" - let result = try await execute(query: query) - return result.rows.compactMap { row -> CollationEntry? in - guard let collation = row[safe: 0]?.asText, - let charset = row[safe: 1]?.asText else { - return nil - } - return CollationEntry(collation: collation, charset: charset) - } - .sorted { $0.collation < $1.collation } - } - enum SessionVariable: String { case characterSetDatabase = "character_set_database" case collationDatabase = "collation_database" @@ -210,10 +160,4 @@ private extension MySQLPluginDriver { return nil } } - - func isSafeCharsetIdentifier(_ value: String) -> Bool { - guard !value.isEmpty else { return false } - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) - return value.unicodeScalars.allSatisfy { allowed.contains($0) } - } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift index f12f13a6aa..e31c68c670 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift @@ -75,6 +75,8 @@ internal extension MySQLPluginDriver { } } + /// The character set is the collation's prefix (`MySQLTableOptions.characterSet(ofCollation:)`): MariaDB + /// 10.10+ reports `utf8mb4_uca1400_ai_ci`, which no `COLLATION_NAME` matches. func allTablesMetadataSQL(schema: String?) -> String? { guard !flavor.isDatabend else { return DatabendCatalog.allTablesMetadataSQL } guard serverHasInformationSchema else { return Self.tableStatusStatement(database: effectiveSchema(schema)) } @@ -83,7 +85,7 @@ internal extension MySQLPluginDriver { TABLE_SCHEMA as `schema`, TABLE_NAME as name, TABLE_TYPE as kind, - IFNULL(CCSA.CHARACTER_SET_NAME, '') as charset, + IFNULL(SUBSTRING_INDEX(TABLE_COLLATION, '_', 1), '') as charset, TABLE_COLLATION as collation, TABLE_ROWS as estimated_rows, CONCAT(ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2), ' MB') as total_size, @@ -91,8 +93,6 @@ internal extension MySQLPluginDriver { CONCAT(ROUND(INDEX_LENGTH / 1024 / 1024, 2), ' MB') as index_size, TABLE_COMMENT as comment FROM information_schema.TABLES - LEFT JOIN information_schema.COLLATION_CHARACTER_SET_APPLICABILITY CCSA - ON TABLE_COLLATION = CCSA.COLLATION_NAME WHERE TABLE_SCHEMA = '\(effectiveSchemaLiteral(schema))' ORDER BY TABLE_NAME """) diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift index 19e984f263..408b7a5a54 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift @@ -60,7 +60,7 @@ internal extension MySQLPluginDriver { table: table, schema: schema, showRows: result.rows, catalogDetails: catalogDetails ) - return result.rows.compactMap { row in + let columns = result.rows.compactMap { row -> PluginColumnInfo? in guard let name = row[safe: 0]?.asText, let dataType = row[safe: 1]?.asText else { return nil } @@ -109,6 +109,9 @@ internal extension MySQLPluginDriver { generationKind: mysqlGenerationKind(extra: extra) ) } + let checks = try await mariadbColumnChecks(table: table, schema: schema, columns: columns.map(\.name)) + guard !checks.isEmpty else { return columns } + return columns.map { $0.withColumnCheck(checks[$0.name]) } } /// What `INFORMATION_SCHEMA.COLUMNS` says about a table's columns that `SHOW FULL COLUMNS` cannot: @@ -292,10 +295,13 @@ internal extension MySQLPluginDriver { let safeTable = mysqlEscapeStringLiteral(table) let query: String if flavor.isMariaDB { + // A column's own check is the column's, restated by its MODIFY: as a constraint here, its + // DROP CONSTRAINT fails with ERROR 1091. + let tableLevel = try await catalogHasColumn(.checkConstraintLevel) ? " AND LEVEL <> \'Column\'" : "" query = """ SELECT CONSTRAINT_NAME, CHECK_CLAUSE FROM INFORMATION_SCHEMA.CHECK_CONSTRAINTS - WHERE CONSTRAINT_SCHEMA = \'\(database)\' AND TABLE_NAME = \'\(safeTable)\' + WHERE CONSTRAINT_SCHEMA = \'\(database)\' AND TABLE_NAME = \'\(safeTable)\'\(tableLevel) ORDER BY CONSTRAINT_NAME """ } else { diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+SessionScope.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+SessionScope.swift index 3478a2ec44..04c6eca032 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+SessionScope.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+SessionScope.swift @@ -9,7 +9,8 @@ import TableProMySQLCore import TableProPluginKit internal extension MySQLPluginDriver { - /// Databend has no `sql_mode` to hold, and a server before 5.0.3 cannot filter `SHOW` with `WHERE`. + /// Databend has no `sql_mode` or foreign key checks to hold, and a server before 5.0.3 cannot filter `SHOW` with + /// `WHERE`. func openSessionScope(for purpose: PluginSessionPurpose) async throws -> PluginSessionScope? { let names = MySQLSessionScopePlan.savedNames(for: purpose) guard !flavor.isDatabend, diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+TableOptions.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+TableOptions.swift new file mode 100644 index 0000000000..922a78ec8a --- /dev/null +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+TableOptions.swift @@ -0,0 +1,171 @@ +// +// MySQLPluginDriver+TableOptions.swift +// MySQLDriverPlugin +// +// The table options reads and write, and the character set, collation and engine catalog they share +// with Create Database. +// + +import Foundation +import os +import TableProMySQLCore +import TableProPluginKit + +internal enum MySQLCatalogColumn: String { + case checkConstraintLevel = "CHECK_CONSTRAINTS.LEVEL" + case fullCollationName = "COLLATION_CHARACTER_SET_APPLICABILITY.FULL_COLLATION_NAME" + + var presenceQuery: String { + let parts = rawValue.split(separator: ".", maxSplits: 1).map(String.init) + return """ + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'information_schema' + AND TABLE_NAME = '\(parts[0])' + AND COLUMN_NAME = '\(parts[1])' + """ + } +} + +extension MySQLPluginDriver { + func fetchTableOptions(table: String, schema: String?) async throws -> PluginTableOptions? { + let sessionFlavor = flavor + guard MySQLTableOptions.writesOptions(flavor: sessionFlavor), + serverHasInformationSchema, + await !resolvesToTemporaryTable(table, schema: schema) + else { return nil } + let result = try await execute( + ownStatement: MySQLTableOptions.tableQuery(schema: effectiveSchema(schema), table: table) + ) + guard let row = MySQLTableOptionsRow.row(named: table, in: result.rows), row.takesTableOptions else { + return nil + } + // MariaDB's catalog counter is live, and its NO_TABLE_OPTIONS blanks the options line. + var optionsLine: [String: String]? + if sessionFlavor == .mysql, row.hasAutoIncrementColumn { + optionsLine = try await createTableOptions(table: table, schema: schema) + } + return MySQLTableOptions.tableOptions(row: row, createTableOptions: optionsLine) + } + + func fetchTableOptionsCatalog() async throws -> PluginTableOptionsCatalog? { + guard MySQLTableOptions.readsCatalog(flavor: flavor) else { return nil } + let characterSets = try await fetchCharsetDefaults() + guard !characterSets.isEmpty else { return nil } + let collations = try await fetchCollationCatalog() + let engines = try await fetchStorageEngines() + return MySQLTableOptions.catalog(characterSets: characterSets, collations: collations, engines: engines) + } + + func fetchDefaultTableOptions(schema: String?) async throws -> PluginTableOptions? { + let database = effectiveSchema(schema) + guard MySQLTableOptions.readsCatalog(flavor: flavor), serverHasInformationSchema, !database.isEmpty else { + return nil + } + let result = try await execute(ownStatement: MySQLTableOptions.schemaDefaultsQuery(schema: database)) + return MySQLTableOptions.schemaDefaults(named: database, in: result.rows) + } + + func tableOptionsStatement(table: String, schema: String?, change: PluginTableOptionsChange) -> String? { + guard MySQLTableOptions.writesOptions(flavor: flavor) else { return nil } + return MySQLTableOptions.statement( + qualifiedTable: MySQLObjectQueries.qualifiedIdentifier(schema: schema, name: table), + change: change + ) + } + + /// Before 5.0.2 the list comes from `SHOW CHARACTER SET`, whose columns are `Charset`, + /// `Description`, `Default collation` and `Maxlen`. + func fetchCharsetDefaults() async throws -> [MySQLCharsetDefault] { + let hasCatalog = serverHasInformationSchema + let query = hasCatalog ? """ + SELECT CHARACTER_SET_NAME, DESCRIPTION, DEFAULT_COLLATE_NAME + FROM information_schema.CHARACTER_SETS + ORDER BY CHARACTER_SET_NAME + """ : "SHOW CHARACTER SET" + let result = try await execute(query: query) + return result.rows.compactMap { row -> MySQLCharsetDefault? in + guard let charset = row[safe: 0]?.asText, + let collation = row[safe: 2]?.asText else { + return nil + } + return MySQLCharsetDefault( + charset: charset, + defaultCollation: collation, + description: row[safe: 1]?.asText?.nilIfEmpty + ) + } + .sorted { $0.charset < $1.charset } + } + + /// MariaDB 10.10+ lists each UCA 14.0 collation in `COLLATIONS` once, with no character set, while the + /// server stores and reports the full name (`utf8mb4_uca1400_ai_ci`). Only `FULL_COLLATION_NAME` + /// lists those, so a server that has the column is read from it. Before 5.0.2 the list comes from + /// `SHOW COLLATION`, whose first two columns are `Collation` and `Charset`. + func fetchCollationCatalog() async throws -> [MySQLCollationEntry] { + let query: String + if !serverHasInformationSchema { + query = "SHOW COLLATION" + } else if flavor.isMariaDB, try await catalogHasColumn(.fullCollationName) { + query = """ + SELECT FULL_COLLATION_NAME, CHARACTER_SET_NAME + FROM information_schema.COLLATION_CHARACTER_SET_APPLICABILITY + ORDER BY FULL_COLLATION_NAME + """ + } else { + query = """ + SELECT COLLATION_NAME, CHARACTER_SET_NAME + FROM information_schema.COLLATIONS + ORDER BY COLLATION_NAME + """ + } + let result = try await execute(query: query) + var seen = Set() + return result.rows.compactMap { row -> MySQLCollationEntry? in + guard let collation = row[safe: 0]?.asText, + let charset = row[safe: 1]?.asText, + seen.insert(collation).inserted else { + return nil + } + return MySQLCollationEntry(collation: collation, charset: charset) + } + .sorted { $0.collation < $1.collation } + } + + /// An engine the server lists with `NO` or `DISABLED` fails `CREATE TABLE` with ERROR 1286. A refused + /// read leaves the list empty rather than failing the catalog with it. + func fetchStorageEngines() async throws -> [MySQLStorageEngine] { + let query = serverHasInformationSchema ? """ + SELECT ENGINE, SUPPORT + FROM information_schema.ENGINES + WHERE SUPPORT IN ('YES', 'DEFAULT') + """ : "SHOW ENGINES" + do { + return MySQLTableOptions.engines(fromRows: try await execute(query: query).rows) + } catch let error as MariaDBPluginError + where MySQLCatalogVisibilityRule.settlesBlindness(code: error.code) { + Self.logger.warning("storage engine read refused code=\(error.code, privacy: .public) message=\(error.message)") + return [] + } + } + + /// A server that refuses the question has no such column for this read, and is asked again next time. + func catalogHasColumn(_ column: MySQLCatalogColumn) async throws -> Bool { + if let known = cachedCatalogColumnPresence(column.rawValue) { return known } + let result: PluginQueryResult + do { + result = try await execute(ownStatement: column.presenceQuery) + } catch let error as MariaDBPluginError + where MySQLCatalogVisibilityRule.settlesBlindness(code: error.code) { + return false + } + let present = (result.rows.first?[safe: 0]?.asText.flatMap { Int($0) } ?? 0) > 0 + cacheCatalogColumnPresence(column.rawValue, present) + return present + } + + private func createTableOptions(table: String, schema: String?) async throws -> [String: String]? { + let result = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(table, schema: schema))") + return result.rows.first?[safe: 1]?.asText.flatMap(MySQLCreateTableScanner.tableOptions(fromCreateTable:)) + } +} diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 1ce1cf0d7f..d0312506b6 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -63,9 +63,22 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { set { sessionLock.withLock { _schemaNamesAreCaseSensitive = newValue } } } + /// Which `information_schema` columns this server has, keyed `TABLE.COLUMN`. Kept and cleared like + /// `_schemaNamesAreCaseSensitive`. + private var _catalogColumnPresence: [String: Bool] = [:] + + internal func cachedCatalogColumnPresence(_ key: String) -> Bool? { + sessionLock.withLock { _catalogColumnPresence[key] } + } + + internal func cacheCatalogColumnPresence(_ key: String, _ present: Bool) { + sessionLock.withLock { _catalogColumnPresence[key] = present } + } + /// Guards `_flavor`, `footprint`, `appliedQueryTimeoutSeconds`, `isReleased`, `sessionLoss`, - /// `_schemaNamesAreCaseSensitive` and `lastActivity`. The driver is `@unchecked Sendable` and the idle timer runs on its own task, so - /// the release decision and a query arriving would otherwise read and write them at the same time. + /// `_schemaNamesAreCaseSensitive`, `_catalogColumnPresence` and `lastActivity`. The driver is + /// `@unchecked Sendable` and the idle timer runs on its own task, so the release decision and a + /// query arriving would otherwise read and write them at the same time. /// Nothing calls into the connection while holding it: the connection asks the driver back under /// this lock from its deadline queue. private let sessionLock = NSLock() @@ -249,6 +262,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _serverVersion = nil _flavor = initialFlavor _schemaNamesAreCaseSensitive = nil + _catalogColumnPresence = [:] isReleased = false isDisconnected = true footprint.reset() @@ -1160,7 +1174,11 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return DatabendCatalog.modifyColumnSQL(table: table, oldColumn: oldColumn, newColumn: newColumn) } let tableName = quoteIdentifier(table) - let definition = mysqlColumnDefinitionSQL(newColumn, isMariaDB: flavor.isMariaDB) + let isMariaDB = flavor.isMariaDB + let check = isMariaDB + ? MySQLColumnChecks.restated(newColumn.columnCheck, renaming: oldColumn.name, to: newColumn.name) + : nil + let definition = mysqlColumnDefinitionSQL(newColumn, isMariaDB: isMariaDB, columnCheck: check) if oldColumn.name != newColumn.name { return literalSpelling.respelled( "ALTER TABLE \(tableName) CHANGE COLUMN \(quoteIdentifier(oldColumn.name)) \(definition)" @@ -1253,7 +1271,10 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// replaces the whole definition, and the attribute list does not carry /// `GENERATED ALWAYS AS`, so moving a generated column with it dropped the expression and /// left a plain column of stored defaults behind. - let definition = mysqlColumnDefinitionSQL(column, isMariaDB: flavor.isMariaDB) + let isMariaDB = flavor.isMariaDB + let definition = mysqlColumnDefinitionSQL( + column, isMariaDB: isMariaDB, columnCheck: isMariaDB ? column.columnCheck : nil + ) return literalSpelling.respelled("ALTER TABLE \(tableName) MODIFY COLUMN \(definition) \(position)") } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLSessionScopePlan.swift b/apple/Plugins/MySQLDriverPlugin/MySQLSessionScopePlan.swift index 291959a2dc..6bca4c79ea 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLSessionScopePlan.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLSessionScopePlan.swift @@ -35,6 +35,8 @@ internal enum MySQLSessionScopePlan { return preservedNames case .verbatimRead, .verbatimWrite: return verbatimNames + case .relaxedForeignKeys: + return ["foreign_key_checks"] @unknown default: return [] } @@ -57,6 +59,8 @@ internal enum MySQLSessionScopePlan { PluginSessionSetting(name: "sql_mode", value: writeMode(saved: saved)), PluginSessionSetting(name: "time_zone", value: "+00:00"), ] + case .relaxedForeignKeys: + return [PluginSessionSetting(name: "foreign_key_checks", value: "OFF")] @unknown default: return nil } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLTableOptions.swift b/apple/Plugins/MySQLDriverPlugin/MySQLTableOptions.swift new file mode 100644 index 0000000000..4835cc9544 --- /dev/null +++ b/apple/Plugins/MySQLDriverPlugin/MySQLTableOptions.swift @@ -0,0 +1,186 @@ +// +// MySQLTableOptions.swift +// MySQLDriverPlugin +// +// A table's character set, collation and AUTO_INCREMENT counter, the server's catalog of character +// sets, collations and engines, and the ALTER TABLE that changes them. +// + +import Foundation +import TableProMySQLCore +import TableProPluginKit + +internal struct MySQLCharsetDefault: Equatable { + let charset: String + let defaultCollation: String + let description: String? +} + +internal struct MySQLCollationEntry: Equatable { + let collation: String + let charset: String +} + +internal struct MySQLStorageEngine: Equatable { + let name: String + let isDefault: Bool +} + +/// One `information_schema.TABLES` row as `MySQLTableOptions.tableQuery` reads it. +internal struct MySQLTableOptionsRow: Equatable { + let type: String + let collation: String? + let autoIncrement: UInt64? + let hasAutoIncrementColumn: Bool + + /// MariaDB 11.4 lists a session's temporary table as `TEMPORARY` beside the base table it hides. + static func row(named table: String, in rows: [[PluginCellValue]]) -> MySQLTableOptionsRow? { + let permanent = rows.filter { $0[safe: 1]?.asText?.uppercased() != "TEMPORARY" } + guard let row = MySQLTableStatusRow.row(named: table, in: permanent), + let type = row[safe: 1]?.asText + else { return nil } + return MySQLTableOptionsRow( + type: type.uppercased(), + collation: row[safe: 2]?.asText?.nilIfEmpty, + autoIncrement: row[safe: 3]?.asText.flatMap { UInt64($0) }, + hasAutoIncrementColumn: (row[safe: 4]?.asText.flatMap { Int($0) } ?? 0) > 0 + ) + } + + // A view fails ALTER TABLE with ERROR 1347, and a sequence has no text column or counter. + var takesTableOptions: Bool { + type == "BASE TABLE" || type == "SYSTEM VERSIONED" + } +} + +internal enum MySQLTableOptions { + // TiDB needs FORCE to lower the counter and OceanBase is unmeasured, so both read the catalog only. + static func writesOptions(flavor: MySQLServerFlavor) -> Bool { + flavor == .mysql || flavor == .mariadb + } + + static func readsCatalog(flavor: MySQLServerFlavor) -> Bool { + !flavor.isDatabend + } + + /// A default change leaves existing columns on their own character set; a convert rewrites every text + /// column. Clauses join with commas, so one statement carries either one with the counter. + static func statement(qualifiedTable: String, change: PluginTableOptionsChange) -> String? { + let characterSet = change.characterSet?.nilIfEmpty.map(mysqlCharsetName) + let collation = change.collation?.nilIfEmpty.map(mysqlCharsetName) + var clauses: [String] = [] + if change.convertsColumns { + guard let characterSet else { return nil } + clauses.append("CONVERT TO CHARACTER SET \(characterSet)" + (collation.map { " COLLATE \($0)" } ?? "")) + } else if characterSet != nil || collation != nil { + let parts = [ + characterSet.map { "DEFAULT CHARACTER SET = \($0)" }, + collation.map { "COLLATE = \($0)" } + ] + clauses.append(parts.compactMap { $0 }.joined(separator: " ")) + } + if let autoIncrement = change.autoIncrement { + clauses.append("AUTO_INCREMENT = \(autoIncrement)") + } + guard !clauses.isEmpty else { return nil } + return "ALTER TABLE \(qualifiedTable) \(clauses.joined(separator: ", "))" + } + + /// `TABLE_COLLATION` changes the moment an ALTER does, unlike the statistics columns. The column count + /// compares names as bytes, because the catalog collates them case-insensitively. + static func tableQuery(schema: String, table: String) -> String { + let schemaLiteral = mysqlEscapeStringLiteral(schema) + let tableLiteral = mysqlEscapeStringLiteral(table) + return """ + SELECT t.TABLE_NAME, t.TABLE_TYPE, t.TABLE_COLLATION, t.AUTO_INCREMENT, + (SELECT COUNT(*) FROM information_schema.COLUMNS c + WHERE c.TABLE_SCHEMA = '\(schemaLiteral)' AND c.TABLE_NAME = '\(tableLiteral)' + AND CAST(c.TABLE_NAME AS BINARY) = CAST(t.TABLE_NAME AS BINARY) + AND c.EXTRA LIKE '%auto_increment%') + FROM information_schema.TABLES t + WHERE t.TABLE_SCHEMA = '\(schemaLiteral)' AND t.TABLE_NAME = '\(tableLiteral)' + """ + } + + static func schemaDefaultsQuery(schema: String) -> String { + """ + SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME + FROM information_schema.SCHEMATA + WHERE SCHEMA_NAME = '\(mysqlEscapeStringLiteral(schema))' + """ + } + + /// No character set name holds an underscore, so a collation's prefix names its character set. MariaDB + /// 10.10+ reports the full `utf8mb4_uca1400_ai_ci`, which `COLLATIONS` does not list. + static func characterSet(ofCollation collation: String?) -> String? { + guard let collation = collation?.nilIfEmpty else { return nil } + return collation.split(separator: "_", maxSplits: 1).first.map(String.init) + } + + /// The value the next row takes. MySQL 8 caches `TABLES.AUTO_INCREMENT` for up to a day, so it reads + /// the `SHOW CREATE TABLE` options, which omit the clause when the next value is 1. An options line + /// without `ENGINE=` was printed under `NO_TABLE_OPTIONS` and says nothing, so the catalog answers. + static func counter( + row: MySQLTableOptionsRow, + createTableOptions: [String: String]? + ) -> UInt64? { + guard row.hasAutoIncrementColumn else { return nil } + if let options = createTableOptions, options["ENGINE"] != nil { + return options["AUTO_INCREMENT"].flatMap { UInt64($0) } ?? 1 + } + return row.autoIncrement ?? 1 + } + + static func tableOptions(row: MySQLTableOptionsRow, createTableOptions: [String: String]?) -> PluginTableOptions? { + guard row.takesTableOptions else { return nil } + return PluginTableOptions( + characterSet: characterSet(ofCollation: row.collation), + collation: row.collation, + autoIncrement: counter(row: row, createTableOptions: createTableOptions) + ) + } + + static func schemaDefaults(named schema: String, in rows: [[PluginCellValue]]) -> PluginTableOptions? { + guard let row = MySQLTableStatusRow.row(named: schema, in: rows) ?? (rows.count == 1 ? rows.first : nil) + else { return nil } + let collation = row[safe: 2]?.asText?.nilIfEmpty + let characterSet = row[safe: 1]?.asText?.nilIfEmpty ?? characterSet(ofCollation: collation) + guard characterSet != nil || collation != nil else { return nil } + return PluginTableOptions(characterSet: characterSet, collation: collation) + } + + static func engines(fromRows rows: [[PluginCellValue]]) -> [MySQLStorageEngine] { + rows.compactMap { row -> MySQLStorageEngine? in + guard let name = row[safe: 0]?.asText?.nilIfEmpty, + let support = row[safe: 1]?.asText?.uppercased(), + support == "YES" || support == "DEFAULT" + else { return nil } + return MySQLStorageEngine(name: name, isDefault: support == "DEFAULT") + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + /// Each character set with the collations listed for it, its default always among them. + static func catalog( + characterSets: [MySQLCharsetDefault], + collations: [MySQLCollationEntry], + engines: [MySQLStorageEngine] + ) -> PluginTableOptionsCatalog { + let collationsByCharset = Dictionary(grouping: collations, by: \.charset) + let sets = characterSets.map { entry -> PluginCharacterSet in + var names = Set((collationsByCharset[entry.charset] ?? []).map(\.collation)) + names.insert(entry.defaultCollation) + return PluginCharacterSet( + name: entry.charset, + defaultCollation: entry.defaultCollation, + collations: names.sorted(), + description: entry.description + ) + } + return PluginTableOptionsCatalog( + characterSets: sets, + engines: engines.map(\.name), + defaultEngine: engines.first(where: \.isDefault)?.name + ) + } +} diff --git a/apple/Plugins/MySQLDriverTests/MySQLColumnChecksTests.swift b/apple/Plugins/MySQLDriverTests/MySQLColumnChecksTests.swift new file mode 100644 index 0000000000..311b7d71c5 --- /dev/null +++ b/apple/Plugins/MySQLDriverTests/MySQLColumnChecksTests.swift @@ -0,0 +1,213 @@ +// +// MySQLColumnChecksTests.swift +// TableProTests +// +// MariaDB drops a column's own CHECK on any MODIFY that does not restate it, the json_valid check +// on a JSON column included. +// + +import Foundation +import TableProMySQLCore +import TableProPluginKit +import Testing + +struct MySQLColumnChecksTests { + /// MariaDB 11.4's own statement after `CHANGE COLUMN n m ...`: the constraint is still named `n` in the + /// catalog, while the clause and the line say `m`. + private let createTable = """ + CREATE TABLE `implA_c` ( + `id` int(11) NOT NULL, + `j` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`j`)), + `m` int(11) DEFAULT NULL CHECK (`m` > 0), + `a` int(11) DEFAULT NULL, + `b` int(11) DEFAULT NULL CHECK (`b` > `a`), + `g` int(11) GENERATED ALWAYS AS (`a` + 1) VIRTUAL CHECK (`g` > 0), + PRIMARY KEY (`id`), + CONSTRAINT `tbl` CHECK (`a` < 100) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci + """ + + private func column(_ name: String, _ dataType: String = "INT", check: String? = nil) -> PluginColumnDefinition { + PluginColumnDefinition( + name: name, + dataType: dataType, + generationExpression: nil, + generationKind: nil, + ddlSpelling: nil, + ddlDefault: nil, + ddlGenerationExpression: nil, + ddlCollation: nil, + columnCheck: check + ) + } + + // MARK: - Placement + + @Test("Each clause goes to the column whose line ends with it, whatever the constraint is named") + func clausesArePlacedByLine() { + let placed = MySQLColumnChecks.place( + clauses: ["`b` > `a`", "`g` > 0", "json_valid(`j`)", "`m` > 0"], + columns: ["id", "j", "m", "a", "b", "g"], + createTable: createTable + ) + #expect(placed == ["j": "json_valid(`j`)", "m": "`m` > 0", "b": "`b` > `a`", "g": "`g` > 0"]) + } + + @Test("Under ANSI_QUOTES the names and the clauses are double-quoted") + func ansiQuotesArePlaced() { + let ansi = createTable.replacingOccurrences(of: "`", with: "\"") + let placed = MySQLColumnChecks.place( + clauses: ["\"m\" > 0", "json_valid(\"j\")"], + columns: ["id", "j", "m"], + createTable: ansi + ) + #expect(placed == ["j": "json_valid(\"j\")", "m": "\"m\" > 0"]) + } + + @Test("A clause no column line ends with is placed nowhere") + func unplacedClauseStaysUnplaced() { + let placed = MySQLColumnChecks.place(clauses: ["`zz` > 0"], columns: ["id", "m"], createTable: createTable) + #expect(placed.isEmpty) + } + + @Test("A table-level constraint is never a column's") + func tableLevelCheckIsNotPlaced() { + let placed = MySQLColumnChecks.place(clauses: ["`a` < 100"], columns: ["a"], createTable: createTable) + #expect(placed.isEmpty) + } + + @Test("The read asks for column-level checks only") + func clausesQueryFiltersLevel() { + let sql = MySQLColumnChecks.clausesQuery(schema: "shop", table: "orders") + #expect(sql.contains("CONSTRAINT_SCHEMA = 'shop'")) + #expect(sql.contains("TABLE_NAME = 'orders'")) + #expect(sql.contains("LEVEL = 'Column'")) + } + + // MARK: - Restatement + + @Test("A rename rewrites the column's own name in both quote styles") + func renameRewritesOwnName() { + #expect(MySQLColumnChecks.restated("`n` > 0 AND `n` < 10", renaming: "n", to: "m") == "`m` > 0 AND `m` < 10") + #expect(MySQLColumnChecks.restated("\"n\" > 0", renaming: "n", to: "m") == "\"m\" > 0") + #expect(MySQLColumnChecks.restated("`nn` > `n`", renaming: "n", to: "m") == "`nn` > `m`") + } + + @Test("A rename leaves string literals as written, a JSON path that spells the name included") + func renameKeepsStringLiterals() { + #expect(MySQLColumnChecks.restated(#"json_extract(`n`, '$."n"') IS NOT NULL"#, renaming: "n", to: "payload") + == #"json_extract(`payload`, '$."n"') IS NOT NULL"#) + #expect(MySQLColumnChecks.restated("`n` <> '`n`'", renaming: "n", to: "m") == "`m` <> '`n`'") + #expect(MySQLColumnChecks.restated(#"`n` <> 'it''s `n`' AND `n` <> 'it\'s `n`'"#, renaming: "n", to: "m") + == #"`m` <> 'it''s `n`' AND `m` <> 'it\'s `n`'"#) + #expect(MySQLColumnChecks.restated(#""n" <> '"n"'"#, renaming: "n", to: "m") == #""m" <> '"n"'"#) + } + + @Test("A rename rewrites whole identifiers only, never one holding the name or an escaped quote") + func renameMatchesWholeIdentifiers() { + #expect(MySQLColumnChecks.restated("`nn` > `n` AND `n_x` > 0", renaming: "n", to: "m") == "`nn` > `m` AND `n_x` > 0") + #expect(MySQLColumnChecks.restated("`x``n` > `n`", renaming: "n", to: "m") == "`x``n` > `m`") + #expect(MySQLColumnChecks.restated("`n``` > 0", renaming: "n", to: "m") == "`n``` > 0") + #expect(MySQLColumnChecks.restated(#""x""n" > "n""#, renaming: "n", to: "m") == #""x""n" > "m""#) + } + + @Test("A rename matches the name without regard to case, and quotes a name holding a backtick") + func renameIgnoresCaseAndEscapesTheNewName() { + #expect(MySQLColumnChecks.restated("`N` > 0", renaming: "n", to: "m") == "`m` > 0") + #expect(MySQLColumnChecks.restated("`a``b` > 0", renaming: "a`b", to: "c`d") == "`c``d` > 0") + } + + @Test("Without a rename the clause is restated as read") + func noRenameKeepsTheClause() { + #expect(MySQLColumnChecks.restated("json_valid(`j`)", renaming: "j", to: "j") == "json_valid(`j`)") + #expect(MySQLColumnChecks.restated(nil, renaming: "j", to: "k") == nil) + #expect(MySQLColumnChecks.restated("", renaming: "j", to: "k") == nil) + } + + @Test("The check is the last clause on the column, after the comment") + func checkIsRestatedLast() { + let definition = PluginColumnDefinition(name: "n", dataType: "INT", comment: "positive") + #expect(mysqlColumnDefinitionSQL(definition, isMariaDB: true, columnCheck: "`n` > 0") + == "`n` INT NULL COMMENT 'positive' CHECK (`n` > 0)") + } + + @Test("A JSON column's json_valid check is restated with its charset") + func jsonValidIsRestated() { + let definition = PluginColumnDefinition( + name: "j", dataType: "LONGTEXT", charset: "utf8mb4", collation: "utf8mb4_bin" + ) + #expect(mysqlColumnDefinitionSQL(definition, isMariaDB: true, columnCheck: "json_valid(`j`)") + == "`j` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL CHECK (json_valid(`j`))") + } + + @Test("A generated column restates its check after the storage keyword") + func generatedColumnCheck() { + let definition = PluginColumnDefinition( + name: "g", dataType: "INT", generationExpression: "`a` + 1", generationKind: .virtual + ) + #expect(mysqlColumnDefinitionSQL(definition, isMariaDB: true, columnCheck: "`g` > 0") + == "`g` INT GENERATED ALWAYS AS (`a` + 1) VIRTUAL CHECK (`g` > 0)") + } + + @Test("A MySQL driver never restates a column check, which MySQL keeps as a table constraint") + func mysqlDoesNotRestate() throws { + let driver = MySQLPluginDriver(config: DriverConnectionConfig( + host: "", port: 0, username: "", password: "", database: "" + )) + let sql = try #require(driver.generateModifyColumnSQL( + table: "t", oldColumn: column("n", check: "`n` > 0"), newColumn: column("n", "BIGINT", check: "`n` > 0") + )) + #expect(!sql.contains("CHECK")) + } + + // MARK: - Refusal + + @Test("A restated check naming another column this save renames is refused") + func checkNamingRenamedColumnIsRefused() throws { + let refusal = try #require(MySQLColumnChecks.renameRefusal(operations: [ + .modifyColumn(old: column("a"), new: column("a2")), + .modifyColumn(old: column("b", check: "`b` > `a`"), new: column("b", "BIGINT", check: "`b` > `a`")) + ])) + #expect(refusal.contains("column b names column a")) + } + + @Test("Under ANSI_QUOTES the renamed name is found in double quotes too") + func ansiQuotedNameIsRefused() { + #expect(MySQLColumnChecks.renameRefusal(operations: [ + .modifyColumn(old: column("b", check: "\"b\" > \"a\""), new: column("b", "BIGINT", check: "\"b\" > \"a\"")), + .modifyColumn(old: column("a"), new: column("a2")) + ]) != nil) + } + + @Test("A column renamed with its own check is restated under the new name, not refused") + func ownRenameIsAllowed() { + #expect(MySQLColumnChecks.renameRefusal(operations: [ + .modifyColumn(old: column("n", check: "`n` > 0"), new: column("m", check: "`n` > 0")) + ]) == nil) + } + + @Test("A rename next to a check that does not name it is allowed") + func unrelatedRenameIsAllowed() { + #expect(MySQLColumnChecks.renameRefusal(operations: [ + .modifyColumn(old: column("c"), new: column("c2")), + .modifyColumn(old: column("b", check: "`b` > `a`"), new: column("b", "BIGINT", check: "`b` > `a`")), + .addColumn(column("z")) + ]) == nil) + } + + @Test("A string literal spelling a renamed column's name does not name the column") + func literalSpellingARenamedColumnIsAllowed() { + #expect(MySQLColumnChecks.renameRefusal(operations: [ + .modifyColumn(old: column("a"), new: column("a2")), + .modifyColumn(old: column("b", check: "`b` <> '`a`'"), new: column("b", "BIGINT", check: "`b` <> '`a`'")) + ]) == nil) + } + + @Test("A column with no check of its own is never refused") + func columnWithoutCheckIsAllowed() { + #expect(MySQLColumnChecks.renameRefusal(operations: [ + .modifyColumn(old: column("a"), new: column("a2")), + .modifyColumn(old: column("b"), new: column("b", "BIGINT")) + ]) == nil) + } +} diff --git a/apple/Plugins/MySQLDriverTests/MySQLColumnDefinitionSQLTests.swift b/apple/Plugins/MySQLDriverTests/MySQLColumnDefinitionSQLTests.swift index 958e27f7d0..b10038bb3a 100644 --- a/apple/Plugins/MySQLDriverTests/MySQLColumnDefinitionSQLTests.swift +++ b/apple/Plugins/MySQLDriverTests/MySQLColumnDefinitionSQLTests.swift @@ -553,25 +553,86 @@ struct MySQLColumnDefinitionSQLTests { // MARK: - Other Attributes - @Test("Charset, collation, unsigned, and auto increment all render") + @Test("Unsigned and auto increment render on a numeric column") func attributesRender() { let column = PluginColumnDefinition( name: "id", dataType: "BIGINT", isNullable: false, autoIncrement: true, - unsigned: true, - charset: "utf8mb4", - collation: "utf8mb4_general_ci" + unsigned: true ) + #expect(mysqlColumnDefinitionSQL(column) == "`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT") + } + + @Test("A text column writes its character set and collation") + func textColumnWritesCharset() { + let column = PluginColumnDefinition( + name: "name", dataType: "VARCHAR(20)", charset: "latin1", collation: "latin1_swedish_ci" + ) + #expect(mysqlColumnDefinitionSQL(column) + == "`name` VARCHAR(20) CHARACTER SET latin1 COLLATE latin1_swedish_ci NULL") + } + + /// Measured on MySQL 8.4 and MariaDB 11.4: `MODIFY name INT CHARACTER SET latin1 ...` is ERROR 1064, + /// and a type change kept the old text type's charset. + @Test("A type that takes no character set writes neither clause", arguments: [ + "INT", "BIGINT UNSIGNED", "VARBINARY(32)", "BINARY(16)", "BLOB", "JSON", "DATETIME(6)", "GEOMETRY", + "DECIMAL(10,2)", "CHAR BYTE", "LONG VARBINARY", "UUID" + ]) + func nonTextTypeDropsCharset(dataType: String) { + let column = PluginColumnDefinition( + name: "c", dataType: dataType, charset: "latin1", collation: "latin1_swedish_ci" + ) + let sql = mysqlColumnDefinitionSQL(column) + #expect(!sql.contains("CHARACTER SET")) + #expect(!sql.contains("COLLATE")) + } + + @Test("Every text spelling writes both clauses", arguments: [ + "CHAR(1)", "VARCHAR (64)", "varchar(64)", "CHARACTER VARYING(10)", "CHAR VARYING(10)", "CHARACTER(3)", + "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT", "LONG", "LONG VARCHAR", "ENUM('a','b')", "SET('x')", + "ENUM ('a')", "VARCHAR(10) BINARY" + ]) + func textSpellingsWriteCharset(dataType: String) { + let column = PluginColumnDefinition(name: "c", dataType: dataType, charset: "latin1", collation: "latin1_bin") let sql = mysqlColumnDefinitionSQL(column) - #expect(sql.contains("`id` BIGINT")) - #expect(sql.contains("UNSIGNED")) - #expect(sql.contains("CHARACTER SET utf8mb4")) - #expect(sql.contains("COLLATE utf8mb4_general_ci")) - #expect(sql.contains("NOT NULL")) - #expect(sql.contains("AUTO_INCREMENT")) + #expect(sql.contains(" CHARACTER SET latin1 COLLATE latin1_bin ")) + } + + /// Measured: `NVARCHAR(10) CHARACTER SET utf8mb3` is ERROR 1064 on both servers, while + /// `NVARCHAR(10) COLLATE utf8mb3_bin` is accepted. + @Test("A national spelling writes the collation alone", arguments: [ + "NVARCHAR(10)", "NCHAR(5)", "NATIONAL VARCHAR(10)", "NATIONAL CHAR(3)", "NCHAR VARCHAR(4)", + "NATIONAL CHARACTER VARYING(10)" + ]) + func nationalSpellingWritesCollationOnly(dataType: String) { + let column = PluginColumnDefinition(name: "c", dataType: dataType, charset: "utf8mb3", collation: "utf8mb3_bin") + let sql = mysqlColumnDefinitionSQL(column) + #expect(!sql.contains("CHARACTER SET")) + #expect(sql.contains(" COLLATE utf8mb3_bin ")) + } + + @Test("A generated column of a numeric type writes no character set before its expression") + func generatedNumericColumnDropsCharset() { + let column = PluginColumnDefinition( + name: "total", + dataType: "INT", + charset: "latin1", + collation: "latin1_bin", + generationExpression: "`a` + 1", + generationKind: .stored + ) + #expect(mysqlColumnDefinitionSQL(column) == "`total` INT GENERATED ALWAYS AS (`a` + 1) STORED") + } + + @Test("A character set name no catalog lists is backticked, never spliced in bare") + func unsafeCharsetIsQuoted() { + let column = PluginColumnDefinition( + name: "c", dataType: "VARCHAR(5)", charset: "latin1 NOT NULL", collation: "latin1_bin" + ) + #expect(mysqlColumnDefinitionSQL(column).contains("CHARACTER SET `latin1 NOT NULL` COLLATE latin1_bin")) } @Test("A backtick in a column name is escaped") diff --git a/apple/Plugins/MySQLDriverTests/MySQLCreateTableTests.swift b/apple/Plugins/MySQLDriverTests/MySQLCreateTableTests.swift index ae149354c3..e9a01fa605 100644 --- a/apple/Plugins/MySQLDriverTests/MySQLCreateTableTests.swift +++ b/apple/Plugins/MySQLDriverTests/MySQLCreateTableTests.swift @@ -81,6 +81,19 @@ struct MySQLCreateTableTests { #expect(sql.contains("COLLATE=latin1_swedish_ci")) } + @Test("A table character set no catalog lists is backticked rather than spliced in") + func unsafeTableCharsetIsQuoted() { + let definition = PluginCreateTableDefinition( + tableName: "t", + columns: [PluginColumnDefinition(name: "id", dataType: "INT")], + charset: "latin1 ENGINE=x", + collation: "latin1_bin" + ) + + let sql = mysqlCreateTableSQL(definition: definition, isMariaDB: false)! + #expect(sql.contains("DEFAULT CHARSET=`latin1 ENGINE=x` COLLATE=latin1_bin")) + } + @Test("IF NOT EXISTS flag") func ifNotExists() { let definition = PluginCreateTableDefinition( diff --git a/apple/Plugins/MySQLDriverTests/MySQLSessionScopeTests.swift b/apple/Plugins/MySQLDriverTests/MySQLSessionScopeTests.swift index 67741e4379..ea60bb8367 100644 --- a/apple/Plugins/MySQLDriverTests/MySQLSessionScopeTests.swift +++ b/apple/Plugins/MySQLDriverTests/MySQLSessionScopeTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProMySQLCore import TableProPluginKit import Testing @@ -105,6 +106,21 @@ struct MySQLSessionScopeTests { #expect(MySQLSessionScopePlan.writeMode(saved: saved) == writeMode) } + /// `SET SESSION foreign_key_checks = 'OFF'` and `'ON'` measured on MySQL 8.4.13, inside a transaction too. + @Test("Relaxing foreign keys saves foreign_key_checks alone, turns it off, and closing turns it back on") + func relaxedForeignKeysPlan() { + let saved = [PluginSessionSetting(name: "foreign_key_checks", value: "ON")] + #expect(MySQLSessionScopePlan.savedNames(for: .relaxedForeignKeys) == ["foreign_key_checks"]) + let applied = MySQLSessionScopePlan.applied(for: .relaxedForeignKeys, saved: saved) + #expect(applied == [PluginSessionSetting(name: "foreign_key_checks", value: "OFF")]) + #expect(applied?.compactMap { MySQLSessionStatements.assignment(name: $0.name, value: $0.value) } == [ + "SET SESSION foreign_key_checks = 'OFF'", + ]) + let restore = MySQLSessionScopePlan.restore(saved: saved, current: applied ?? [], isTransactionOpen: true) + #expect(restore.statements == ["SET SESSION foreign_key_checks = 'ON'"]) + #expect(restore.isComplete) + } + @Test("A write scope with no saved mode adds only NO_AUTO_VALUE_ON_ZERO, and reading ignores the session's mode") func writeModeWithoutSavedMode() { #expect(MySQLSessionScopePlan.writeMode(saved: []) == "NO_AUTO_VALUE_ON_ZERO") @@ -216,7 +232,7 @@ struct MySQLSessionScopeTests { host: "", port: 0, username: "", password: "", database: "", additionalFields: ["driverVariant": MySQLServerFlavor.databendVariant] )) - for purpose in [PluginSessionPurpose.preserve, .verbatimRead, .verbatimWrite] { + for purpose in [PluginSessionPurpose.preserve, .verbatimRead, .verbatimWrite, .relaxedForeignKeys] { #expect(try await driver.openSessionScope(for: purpose) == nil) } let routine = PluginRoutineInfo(name: "cleanup", kind: .procedure) diff --git a/apple/Plugins/MySQLDriverTests/MySQLTableOptionsTests.swift b/apple/Plugins/MySQLDriverTests/MySQLTableOptionsTests.swift new file mode 100644 index 0000000000..92949b6748 --- /dev/null +++ b/apple/Plugins/MySQLDriverTests/MySQLTableOptionsTests.swift @@ -0,0 +1,284 @@ +// +// MySQLTableOptionsTests.swift +// TableProTests +// + +import TableProMySQLCore +import TableProPluginKit +import Testing + +struct MySQLTableOptionsTests { + private func statement(_ change: PluginTableOptionsChange) -> String? { + MySQLTableOptions.statement(qualifiedTable: "`shop`.`orders`", change: change) + } + + private func row(_ cells: String?...) -> [PluginCellValue] { + cells.map(PluginCellValue.fromOptional) + } + + private func driver(variant: String?) -> MySQLPluginDriver { + MySQLPluginDriver(config: DriverConnectionConfig( + host: "", port: 0, username: "", password: "", database: "", + additionalFields: variant.map { ["driverVariant": $0] } ?? [:] + )) + } + + // MARK: - Statement + + @Test("A default change names both clauses") + func defaultChange() { + #expect(statement(PluginTableOptionsChange(characterSet: "utf8mb4", collation: "utf8mb4_bin")) + == "ALTER TABLE `shop`.`orders` DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin") + } + + @Test("A convert rewrites the columns with CONVERT TO") + func convert() { + let change = PluginTableOptionsChange(characterSet: "latin1", collation: "latin1_bin", convertsColumns: true) + #expect(statement(change) == "ALTER TABLE `shop`.`orders` CONVERT TO CHARACTER SET latin1 COLLATE latin1_bin") + } + + @Test("A convert with no character set cannot be spelled") + func convertNeedsACharacterSet() { + #expect(statement(PluginTableOptionsChange(collation: "latin1_bin", convertsColumns: true)) == nil) + } + + @Test("The counter alone is its own statement") + func counterAlone() { + #expect(statement(PluginTableOptionsChange(autoIncrement: 500)) == "ALTER TABLE `shop`.`orders` AUTO_INCREMENT = 500") + } + + @Test("A default change and the counter share one statement, joined by a comma") + func defaultChangeWithCounter() { + let change = PluginTableOptionsChange(characterSet: "latin1", collation: "latin1_bin", autoIncrement: 500) + #expect(statement(change) + == "ALTER TABLE `shop`.`orders` DEFAULT CHARACTER SET = latin1 COLLATE = latin1_bin, AUTO_INCREMENT = 500") + } + + @Test("A convert and the counter share one statement") + func convertWithCounter() { + let change = PluginTableOptionsChange( + characterSet: "utf8mb4", collation: "utf8mb4_bin", convertsColumns: true, autoIncrement: 7 + ) + #expect(statement(change) + == "ALTER TABLE `shop`.`orders` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, AUTO_INCREMENT = 7") + } + + @Test("A change that names nothing is no statement") + func emptyChange() { + #expect(statement(PluginTableOptionsChange()) == nil) + #expect(statement(PluginTableOptionsChange(characterSet: "", collation: "")) == nil) + } + + @Test("A name the catalog could not have listed is backticked, never spliced in bare") + func unsafeNameIsQuoted() { + let change = PluginTableOptionsChange(characterSet: "utf8mb4; DROP TABLE x", collation: "a`b") + #expect(statement(change) + == "ALTER TABLE `shop`.`orders` DEFAULT CHARACTER SET = `utf8mb4; DROP TABLE x` COLLATE = `a``b`") + } + + // MARK: - Flavors + + @Test("Only MySQL and MariaDB write table options") + func writingFlavors() { + #expect(MySQLTableOptions.writesOptions(flavor: .mysql)) + #expect(MySQLTableOptions.writesOptions(flavor: .mariadb)) + #expect(!MySQLTableOptions.writesOptions(flavor: .tidb(version: nil))) + #expect(!MySQLTableOptions.writesOptions(flavor: .oceanbase(version: nil))) + #expect(!MySQLTableOptions.writesOptions(flavor: .databend)) + } + + @Test("Every flavor but Databend reads the catalog") + func catalogFlavors() { + #expect(MySQLTableOptions.readsCatalog(flavor: .mysql)) + #expect(MySQLTableOptions.readsCatalog(flavor: .mariadb)) + #expect(MySQLTableOptions.readsCatalog(flavor: .tidb(version: nil))) + #expect(MySQLTableOptions.readsCatalog(flavor: .oceanbase(version: nil))) + #expect(!MySQLTableOptions.readsCatalog(flavor: .databend)) + } + + @Test("The driver qualifies the table, and answers nil on OceanBase and Databend") + func driverStatementFollowsTheFlavor() { + let change = PluginTableOptionsChange(autoIncrement: 10) + #expect(driver(variant: nil).tableOptionsStatement(table: "orders", schema: "shop", change: change) + == "ALTER TABLE `shop`.`orders` AUTO_INCREMENT = 10") + #expect(driver(variant: nil).tableOptionsStatement(table: "orders", schema: nil, change: change) + == "ALTER TABLE `orders` AUTO_INCREMENT = 10") + #expect(driver(variant: "OceanBase").tableOptionsStatement(table: "orders", schema: "shop", change: change) == nil) + #expect(driver(variant: "Databend").tableOptionsStatement(table: "orders", schema: "shop", change: change) == nil) + } + + // MARK: - Reading a table + + @Test("The table read filters by schema and name and counts AUTO_INCREMENT columns by exact name") + func tableQueryShape() { + let sql = MySQLTableOptions.tableQuery(schema: "sh'op", table: "orders") + #expect(sql.contains("t.TABLE_SCHEMA = 'sh''op' AND t.TABLE_NAME = 'orders'")) + #expect(sql.contains("t.TABLE_COLLATION, t.AUTO_INCREMENT")) + #expect(sql.contains("CAST(c.TABLE_NAME AS BINARY) = CAST(t.TABLE_NAME AS BINARY)")) + #expect(sql.contains("c.EXTRA LIKE '%auto_increment%'")) + } + + @Test("A session's temporary row is skipped for the base table it hides") + func temporaryRowIsSkipped() throws { + let rows = [ + row("orders", "TEMPORARY", "utf8mb4_bin", nil, "0"), + row("orders", "BASE TABLE", "latin1_swedish_ci", "4", "1") + ] + let parsed = try #require(MySQLTableOptionsRow.row(named: "orders", in: rows)) + #expect(parsed == MySQLTableOptionsRow( + type: "BASE TABLE", collation: "latin1_swedish_ci", autoIncrement: 4, hasAutoIncrementColumn: true + )) + } + + @Test("The row naming the table exactly wins over a case variant") + func exactNameWins() throws { + let rows = [ + row("Orders", "BASE TABLE", "latin1_bin", nil, "0"), + row("orders", "BASE TABLE", "utf8mb4_bin", nil, "0") + ] + #expect(try #require(MySQLTableOptionsRow.row(named: "orders", in: rows)).collation == "utf8mb4_bin") + } + + @Test("A view or a sequence has no table options", arguments: ["VIEW", "SYSTEM VIEW", "SEQUENCE"]) + func nonTablesHaveNone(type: String) throws { + let parsed = try #require(MySQLTableOptionsRow.row(named: "v", in: [row("v", type, nil, nil, "0")])) + #expect(MySQLTableOptions.tableOptions(row: parsed, createTableOptions: nil) == nil) + } + + @Test("A base table and a system-versioned table read their collation's character set") + func tableReadsItsCharacterSet() throws { + for type in ["BASE TABLE", "SYSTEM VERSIONED"] { + let parsed = try #require(MySQLTableOptionsRow.row( + named: "t", in: [row("t", type, "utf8mb4_uca1400_ai_ci", nil, "0")] + )) + #expect(MySQLTableOptions.tableOptions(row: parsed, createTableOptions: nil) + == PluginTableOptions(characterSet: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", autoIncrement: nil)) + } + } + + @Test("A collation's character set is its prefix", arguments: [ + ("utf8mb4_uca1400_ai_ci", "utf8mb4"), + ("latin1_swedish_ci", "latin1"), + ("utf8mb3_general_ci", "utf8mb3"), + ("binary", "binary") + ]) + func characterSetOfCollation(collation: String, expected: String) { + #expect(MySQLTableOptions.characterSet(ofCollation: collation) == expected) + } + + @Test("No collation names no character set") + func noCollation() { + #expect(MySQLTableOptions.characterSet(ofCollation: nil) == nil) + #expect(MySQLTableOptions.characterSet(ofCollation: "") == nil) + } + + // MARK: - Counter + + private func tableRow(autoIncrement: UInt64?, hasColumn: Bool) -> MySQLTableOptionsRow { + MySQLTableOptionsRow( + type: "BASE TABLE", collation: "utf8mb4_bin", autoIncrement: autoIncrement, hasAutoIncrementColumn: hasColumn + ) + } + + @Test("A table without an AUTO_INCREMENT column has no counter, whatever the catalog says") + func noColumnNoCounter() { + #expect(MySQLTableOptions.counter(row: tableRow(autoIncrement: 50, hasColumn: false), createTableOptions: nil) == nil) + } + + @Test("MySQL reads the counter from SHOW CREATE TABLE over its stale catalog") + func mysqlReadsTheOptionsLine() { + let options = ["ENGINE": "InnoDB", "AUTO_INCREMENT": "9", "CHARSET": "utf8mb4"] + #expect(MySQLTableOptions.counter(row: tableRow(autoIncrement: 4, hasColumn: true), createTableOptions: options) == 9) + } + + @Test("An options line with no AUTO_INCREMENT clause means the next value is 1") + func missingClauseIsOne() { + let options = ["ENGINE": "InnoDB", "CHARSET": "utf8mb4"] + #expect(MySQLTableOptions.counter(row: tableRow(autoIncrement: 4, hasColumn: true), createTableOptions: options) == 1) + } + + @Test("An options line printed without ENGINE says nothing, so the catalog answers") + func blankOptionsLineFallsBackToTheCatalog() { + #expect(MySQLTableOptions.counter(row: tableRow(autoIncrement: 4, hasColumn: true), createTableOptions: [:]) == 4) + } + + @Test("MariaDB reads its live catalog, and an empty value there is 1") + func mariadbReadsTheCatalog() { + #expect(MySQLTableOptions.counter(row: tableRow(autoIncrement: 100, hasColumn: true), createTableOptions: nil) == 100) + #expect(MySQLTableOptions.counter(row: tableRow(autoIncrement: nil, hasColumn: true), createTableOptions: nil) == 1) + } + + // MARK: - Schema defaults + + @Test("A schema's defaults come from its own SCHEMATA row") + func schemaDefaults() { + let rows = [ + row("Shop", "latin1", "latin1_swedish_ci"), + row("shop", "utf8mb4", "utf8mb4_uca1400_ai_ci") + ] + #expect(MySQLTableOptions.schemaDefaults(named: "shop", in: rows) + == PluginTableOptions(characterSet: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci")) + #expect(MySQLTableOptions.schemaDefaults(named: "missing", in: []) == nil) + #expect(MySQLTableOptions.schemaDefaultsQuery(schema: "shop").contains("SCHEMA_NAME = 'shop'")) + } + + // MARK: - Catalog + + @Test("Engines the server cannot create are left out, and its default is named") + func engines() { + let engines = MySQLTableOptions.engines(fromRows: [ + row("MyISAM", "YES"), + row("FEDERATED", "NO"), + row("InnoDB", "DEFAULT"), + row("ndbcluster", "DISABLED"), + row("Aria", "YES") + ]) + let names = engines.map(\.name) + let defaults = engines.filter(\.isDefault).map(\.name) + #expect(names == ["Aria", "InnoDB", "MyISAM"]) + #expect(defaults == ["InnoDB"]) + } + + @Test("Each character set lists its own collations, sorted, its default among them") + func catalogGroupsCollations() { + let catalog = MySQLTableOptions.catalog( + characterSets: [ + MySQLCharsetDefault(charset: "latin1", defaultCollation: "latin1_swedish_ci", description: "cp1252 West European"), + MySQLCharsetDefault(charset: "utf8mb4", defaultCollation: "utf8mb4_uca1400_ai_ci", description: nil) + ], + collations: [ + MySQLCollationEntry(collation: "utf8mb4_general_ci", charset: "utf8mb4"), + MySQLCollationEntry(collation: "latin1_bin", charset: "latin1"), + MySQLCollationEntry(collation: "latin1_swedish_ci", charset: "latin1"), + MySQLCollationEntry(collation: "utf8mb4_bin", charset: "utf8mb4") + ], + engines: [MySQLStorageEngine(name: "Aria", isDefault: false), MySQLStorageEngine(name: "InnoDB", isDefault: true)] + ) + #expect(catalog.characterSets == [ + PluginCharacterSet( + name: "latin1", + defaultCollation: "latin1_swedish_ci", + collations: ["latin1_bin", "latin1_swedish_ci"], + description: "cp1252 West European" + ), + PluginCharacterSet( + name: "utf8mb4", + defaultCollation: "utf8mb4_uca1400_ai_ci", + collations: ["utf8mb4_bin", "utf8mb4_general_ci", "utf8mb4_uca1400_ai_ci"], + description: nil + ) + ]) + #expect(catalog.engines == ["Aria", "InnoDB"]) + #expect(catalog.defaultEngine == "InnoDB") + } + + @Test("The catalog column probes name information_schema's own tables") + func catalogColumnProbes() { + #expect(MySQLCatalogColumn.fullCollationName.presenceQuery.contains( + "TABLE_NAME = 'COLLATION_CHARACTER_SET_APPLICABILITY'" + )) + #expect(MySQLCatalogColumn.fullCollationName.presenceQuery.contains("COLUMN_NAME = 'FULL_COLLATION_NAME'")) + #expect(MySQLCatalogColumn.checkConstraintLevel.presenceQuery.contains("TABLE_NAME = 'CHECK_CONSTRAINTS'")) + #expect(MySQLCatalogColumn.checkConstraintLevel.presenceQuery.contains("COLUMN_NAME = 'LEVEL'")) + } +} diff --git a/apple/Plugins/OracleDriverPlugin/OracleDebugText.swift b/apple/Plugins/OracleDriverPlugin/OracleDebugText.swift new file mode 100644 index 0000000000..a9df97cc7b --- /dev/null +++ b/apple/Plugins/OracleDriverPlugin/OracleDebugText.swift @@ -0,0 +1,258 @@ +// +// OracleDebugText.swift +// OracleDriverPlugin +// + +import Foundation +import TableProOracleCore +import TableProPluginKit + +struct OracleDebugPluginError: Error, PluginDriverError { + let message: String + var detail: String? + + var pluginErrorMessage: String { message } + var pluginErrorDetail: String? { detail } +} + +// Everything the Oracle debugger says to a person, worded once. +enum OracleDebugText { + static let fallbackSuffix = String(localized: "Debugging through DBMS_DEBUG instead.") + + static func kindName(_ kind: OracleDebugUnitKind) -> String { + switch kind { + case .procedure: return String(localized: "Procedure") + case .function: return String(localized: "Function") + case .package, .packageBody: return String(localized: "Package") + case .trigger: return String(localized: "Trigger") + case .type, .typeBody: return String(localized: "Type") + } + } + + static func unitUnreadable(_ name: String) -> String { + String(format: String(localized: "TablePro can't read %@. Check that this account can see its source."), name) + } + + static func noEntryPoints(_ name: String) -> String { + String(format: String(localized: "%@ has no public procedure or function to start from."), name) + } + + static func invalidPorts(_ text: String) -> String { + String(format: String(localized: "“%@” isn't a port or a range of ports, such as 4000-4999."), text) + } + + static func line(for notice: OracleDebugNotice) -> String { + switch notice { + case let .connectedOverJDWP(host, port): + return String(format: String(localized: "Debugging through DBMS_DEBUG_JDWP. The database connected to TablePro at %@."), "\(host):\(port)") + case let .fellBack(failure, host, port): + return jdwpFailureSentence(failure, host: host, port: port) + " " + fallbackSuffix + case .usingDBMSDebug(.chosen): + return String(localized: "Debugging through DBMS_DEBUG.") + case .usingDBMSDebug(.noDebuggerHost): + return String(localized: "No debugger host is set, so the database can't connect back to TablePro. Debugging through DBMS_DEBUG.") + case let .breakpointHasNoCode(unit, line): + return String(format: String(localized: "Line %1$d of %2$@ has no code to stop at."), line, unit.name) + case let .breakpointMoved(unit, from, to): + return String( + format: String(localized: "The breakpoint on line %1$d of %2$@ moved to line %3$d, the next line with code."), + from, unit.name, to + ) + case .serverOutput(let text): + return text + } + } + + private static func jdwpFailureSentence(_ failure: OracleJDWPFailure, host: String, port: Int) -> String { + let address = port > 0 ? "\(host):\(port)" : host + switch failure { + case .accessControlList: + return String(format: String(localized: """ + The database isn't allowed to connect to TablePro at %@ (ORA-24247). A DBA can allow it with \ + DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE and the jdwp privilege. + """), address) + case .unreachable: + return String(format: String(localized: "The database couldn't reach TablePro at %@."), address) + case .packageUnavailable: + return String(localized: "This database doesn't provide DBMS_DEBUG_JDWP.") + case .timedOut: + return String(format: String(localized: "The database didn't connect to TablePro at %@ in time."), address) + case .noFreePort: + return String(localized: "No port in the Debugger Ports range is free on this Mac.") + case .handshakeFailed: + return String(localized: "The connection from the database didn't complete the debugger handshake.") + case .noDebuggerHost: + return String(localized: "DBMS_DEBUG_JDWP needs a Debugger Host for the database to connect back to.") + case .unexpectedPeer(let addresses): + return String( + format: String(localized: "A connection came from %@, which isn't the database server's address, so TablePro closed it."), + addresses.joined(separator: ", ") + ) + case .missingPrivilege: + return String(localized: "Debugging needs the DEBUG CONNECT SESSION privilege.") + case .other(let message): + return message + } + } + + static func error(for missing: OracleDebugStatements.MissingPrivilege, unit: OracleDebugUnit) -> OracleDebugPluginError { + switch missing { + case .debugConnectSession(let user): + return OracleDebugPluginError( + message: String(localized: "Debugging needs the DEBUG CONNECT SESSION privilege."), + detail: String(format: String(localized: "A DBA can grant it with GRANT DEBUG CONNECT SESSION TO %@."), user) + ) + case .debugOnObject(let user): + let qualified = "\(unit.owner).\(unit.name)" + return OracleDebugPluginError( + message: String(format: String(localized: "Debugging %@ needs the DEBUG privilege on it."), qualified), + detail: String( + format: String(localized: "A DBA can grant it with GRANT DEBUG ON %1$@ TO %2$@, or grant DEBUG ANY PROCEDURE."), + qualified, user + ) + ) + } + } + + static func error(for error: Error, host: String?) -> Error { + if error is OracleDebugPluginError || error is OraclePluginError || error is CancellationError { return error } + if let core = error as? OracleCoreError { return core.asPluginError } + if error is OracleJDWPError || error is OracleJDWPListenerError { + return OracleDebugPluginError(message: String(localized: "The database's debugger sent data TablePro can't read, so the session ended.")) + } + if error is OracleDebugPayload.ParseError { + return OracleDebugPluginError(message: String(localized: "The database answered the debugger with data TablePro can't read.")) + } + guard let debug = error as? OracleDebugError else { + return OracleDebugPluginError(message: String(localized: "The debugger couldn't finish this step."), detail: error.localizedDescription) + } + switch debug { + case .privilegeMissing(let message): + return OracleDebugPluginError( + message: String(localized: "Debugging needs the DEBUG CONNECT SESSION privilege."), + detail: message + ) + case .server(_, let message): + return OracleDebugPluginError(message: String(localized: "The debugger couldn't start."), detail: message) + case .jdwp(.missingPrivilege): + return OracleDebugPluginError( + message: String(localized: "Debugging needs the DEBUG CONNECT SESSION privilege."), + detail: nil + ) + case .jdwp(let failure): + return OracleDebugPluginError( + message: jdwpFailureSentence(failure, host: host ?? "", port: 0), + detail: jdwpFailureAdvice(failure) + ) + case .notPaused, .ended: + return OracleDebugPluginError(message: String(localized: "The debug session has ended.")) + case let .unsupportedArgument(name, typeName): + return OracleDebugPluginError( + message: String(format: String(localized: "TablePro can't turn text into a %2$@ value for %1$@."), name, typeName), + detail: String(localized: "Pass NULL or leave it at its default.") + ) + case let .undeclarableArgument(name, typeName): + return OracleDebugPluginError( + message: String(format: String(localized: "TablePro can't pass any value of type %2$@ to %1$@."), name, typeName), + detail: String(localized: "Debug it from a routine that calls it with that value.") + ) + case let .ambiguousDefaults(subprogram, parameters): + // String(localized:) does not inflect, so the plural is its own key. + let format = parameters.count == 1 + ? String(localized: "Give %1$@ a value. Left at its default, the call also matches another overload of %2$@.") + : String(localized: "Give %1$@ values. Left at their defaults, the call also matches another overload of %2$@.") + return OracleDebugPluginError( + message: String(format: format, ListFormatter.localizedString(byJoining: parameters), subprogram), + detail: String(localized: "Oracle can't tell the overloads apart without it (PLS-00307), and TablePro can't read the default to pass it.") + ) + case .ambiguousCall(let subprogram): + return OracleDebugPluginError( + message: String(format: String(localized: "This call matches more than one overload of %@, so Oracle can't run it (PLS-00307)."), subprogram), + detail: String(localized: "Debug it from a routine that calls it with arguments only this overload takes.") + ) + case .unitNotFound: + return OracleDebugPluginError(message: String(localized: "TablePro can't read this unit's source.")) + case .malformedReply: + return OracleDebugPluginError(message: String(localized: "The database's debugger sent data TablePro can't read, so the session ended.")) + case .debuggerStoppedAnswering: + return OracleDebugPluginError(message: String(localized: "The database's debugger stopped answering, so the session ended.")) + case .debuggerRefused(let code): + return OracleDebugPluginError( + message: String(localized: "The database's debugger refused the request."), + detail: String(format: String(localized: "JDWP error %d."), code) + ) + case .unreadableAnswer: + return OracleDebugPluginError(message: String(localized: "The database answered the debugger with data TablePro can't read.")) + } + } + + // What the person can do about each way the database failed to connect back. + static func jdwpFailureAdvice(_ failure: OracleJDWPFailure) -> String? { + switch failure { + case .accessControlList: + return String(localized: "Or choose DBMS_DEBUG as the protocol, which needs no ACL entry.") + case .unreachable(let message): + return message + "\n" + String(localized: "Set Debugger Host to an address the server can reach, or choose DBMS_DEBUG as the protocol.") + case .timedOut: + return String(localized: "Set Debugger Host to an address the server can reach, or choose DBMS_DEBUG as the protocol.") + case .packageUnavailable, .handshakeFailed: + return String(localized: "Choose DBMS_DEBUG as the protocol.") + case .noFreePort: + return String(localized: "Choose other Debugger Ports, or DBMS_DEBUG as the protocol.") + case .noDebuggerHost: + return String(localized: "Set Debugger Host to an address the database server can reach this Mac at, or choose DBMS_DEBUG as the protocol.") + case .unexpectedPeer: + return String(localized: "If the server reaches this Mac through NAT or a load balancer, choose DBMS_DEBUG as the protocol.") + case .missingPrivilege: + return nil + case .other(let message): + return message + } + } + + static func failure(_ failure: OracleDebugFailure) -> (message: String, detail: String?) { + switch failure { + case .routineFailed(let message): + return (String(localized: "The routine raised an error. Its changes were rolled back."), message.isEmpty ? nil : message) + case .stopped: + return (String(localized: "Stopped. The routine's changes were rolled back."), nil) + case .stillRunning(let identity): + let text = stillRunning(identity) + return (text.message, text.detail) + case .connectionLost(let message): + return (String(localized: "The debug session's connection closed."), message) + } + } + + static func stillRunning(_ identity: OracleDebugSessionIdentity?) -> (message: String, detail: String) { + let message = String(localized: "TablePro couldn't confirm that the routine ended. It may still be running on the server.") + let rollback = String(localized: "Its uncommitted work rolls back when it ends.") + guard let identity else { + return (message, rollback + " " + String(localized: "A user with ALTER SYSTEM can find its session in V$SESSION and end it with ALTER SYSTEM KILL SESSION.")) + } + guard let serial = identity.serial, let kill = OracleDBMSDebugStatements.killSession(identity) else { + return (message, String(format: String(localized: "Session SID %d. %@"), identity.sid, rollback)) + } + return (message, String( + format: String(localized: "Session SID %1$d, SERIAL# %2$d. %3$@ A user with ALTER SYSTEM can end it now with %4$@."), + identity.sid, serial, rollback, kill + )) + } + + // Nil is SQL NULL, so a value the debugger can't read is said in words. + static func text(for value: OracleDebugValue) -> String? { + switch value { + case .scalar(let text): + return text + case .record(let members): + return "(" + members.map { "\($0.name) => \($0.value ?? "NULL")" }.joined(separator: ", ") + ")" + case .collection(let count): + guard let count else { return String(localized: "Collection") } + // String(localized:) does not inflect, so the singular is its own key. + if count == 1 { return String(localized: "Collection of 1 element") } + return String(format: String(localized: "Collection of %d elements"), count) + case .unavailable: + return String(localized: "Not readable") + } + } +} diff --git a/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift b/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift index 38057bb611..1a7b758383 100644 --- a/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -165,7 +165,8 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost caseSensitivityStyle: .caseFoldFunction, operators: SQLEngineVocabulary.oracle.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.oracle.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.oracle.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { @@ -188,11 +189,12 @@ private extension SQLOperatorDescriptor { } final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { - private let config: DriverConnectionConfig + let config: DriverConnectionConfig private(set) var core: OracleCoreConnection? private var _currentSchema: String? private var _serverVersion: String? let principalState = OSAllocatedUnfairLock(initialState: OraclePrincipalState()) + let debugState = OSAllocatedUnfairLock(initialState: OracleDebugState()) private static let logger = Logger(subsystem: "com.TablePro", category: "OraclePluginDriver") @@ -703,7 +705,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchViewDefinition(view: String, schema: String?) async throws -> String { // ALL_VIEWS.TEXT is LONG (crashes OracleNIO). TEXT_VC is VARCHAR2(4000), safe. - // Do NOT use DBMS_METADATA.GET_DDL — wrong object type triggers ORA-31603 + // Do NOT use DBMS_METADATA.GET_DDL: the wrong object type triggers ORA-31603, // which corrupts OracleNIO's connection state machine. let owner = effectiveSchema(schema) let result = try await execute(query: OracleSchemaQueries.viewDefinition(schema: owner, view: view)) diff --git a/apple/Plugins/OracleDriverPlugin/OraclePluginDriver+Debugging.swift b/apple/Plugins/OracleDriverPlugin/OraclePluginDriver+Debugging.swift new file mode 100644 index 0000000000..aa0ed251b8 --- /dev/null +++ b/apple/Plugins/OracleDriverPlugin/OraclePluginDriver+Debugging.swift @@ -0,0 +1,279 @@ +// +// OraclePluginDriver+Debugging.swift +// OracleDriverPlugin +// + +import Foundation +import os +import TableProOracleCore +import TableProPluginKit + +extension OraclePluginDriver: PluginRoutineDebugging { + var canDebugRoutines: Bool { true } + + func debugTarget(for routine: PluginRoutineInfo) -> PluginDebugTarget? { + let kind: OracleDebugUnitKind + switch routine.kind { + case .procedure: kind = .procedure + case .function: kind = .function + @unknown default: return nil + } + if let language = routine.language, language.uppercased() != "PL/SQL" { return nil } + let owner = effectiveSchema(routine.schema?.isEmpty == false ? routine.schema : nil) + return Self.debugTarget(OracleDebugUnit(owner: owner, name: routine.name, kind: kind)) + } + + func debugTargets(schema: String?) async throws -> [PluginDebugTarget] { + let owner = effectiveSchema(schema?.isEmpty == false ? schema : nil) + let rows = try await rawQuery(OracleDebugStatements.targets(owner: owner)).rows + return rows.compactMap { row in + guard let name = row.first?.stringValue, + let kind = row.count > 1 ? row[1].stringValue.flatMap(OracleDebugUnitKind.init(rawValue:)) : nil + else { return nil } + return Self.debugTarget(OracleDebugUnit(owner: owner, name: name, kind: kind)) + } + } + + func prepareDebug(_ target: PluginDebugTarget) async throws -> PluginDebugPlan { + guard let unit = OracleDebugUnit(identity: target.identity) else { + throw OracleDebugPluginError(message: OracleDebugText.unitUnreadable(target.name)) + } + let privileges = try await rawQuery(OracleDebugStatements.privileges(of: unit)).rows.first?.map(\.stringValue) ?? [] + if let missing = OracleDebugStatements.missingPrivilege(fromRow: privileges, unit: unit) { + throw OracleDebugText.error(for: missing, unit: unit) + } + let readinessRow = try await rawQuery(OracleDebugStatements.readiness(of: unit)).rows.first?.map(\.stringValue) + let preparation: PluginDebugPreparation? + switch OracleDebugStatements.readiness(fromRow: readinessRow) { + case .notVisible: + throw OracleDebugPluginError(message: OracleDebugText.unitUnreadable(target.name)) + case .ready: + preparation = nil + case .needsDebugCompile: + preparation = PluginDebugPreparation( + message: String(format: String(localized: "%@ isn't compiled for debugging."), target.name), + informativeText: Self.compileForDebugText(unit.kind), + actionTitle: String(localized: "Compile for Debug"), + statements: [unit.kind.compileForDebugStatement(owner: unit.owner, name: unit.name)] + ) + } + let rows = try await rawQuery(OracleDebugStatements.entryPoints(of: unit)).rows.map { $0.map(\.stringValue) } + let entryPoints = OracleDebugStatements.parseEntryPoints(rows: rows, unit: unit) + guard !entryPoints.isEmpty else { + throw OracleDebugPluginError(message: OracleDebugText.noEntryPoints(target.name)) + } + debugState.withLock { state in + state.entryPoints = state.entryPoints.filter { $0.value.entry.unit != unit } + for entryPoint in entryPoints { + state.entryPoints[entryPoint.entry.identity] = entryPoint + } + } + return PluginDebugPlan( + target: target, + entryPoints: entryPoints.map(Self.pluginEntryPoint), + mayWrite: true, + preparation: preparation, + options: await debugOptions(), + note: String(localized: "Changes the routine makes are committed when it finishes. Stop rolls them back.") + ) + } + + func startDebug( + _ request: PluginDebugRequest, + sessions: any PluginDebugSessionSource + ) async throws -> any PluginDebugSession { + guard let entry = OracleDebugEntry(identity: request.entryPoint.identity) else { + throw OracleDebugPluginError(message: OracleDebugText.unitUnreadable(request.entryPoint.displayName)) + } + let opener = OracleDebugSessionOpener(source: sessions) + let entryPoint: OracleDebugEntryPoint + let overloads: [OracleDebugEntryPoint] + let cached = debugState.withLock { state in + state.entryPoints[entry.identity].map { found in + (found, state.entryPoints.values.filter { $0.entry.unit == entry.unit && $0.entry.subprogram == entry.subprogram }) + } + } + if let cached { + (entryPoint, overloads) = cached + } else { + (entryPoint, overloads) = try await opener.entryPoint(for: entry) + } + let values = request.optionValues + let preference = values[OracleDebugProtocolPreference.fieldID] + .flatMap(OracleDebugProtocolPreference.init(rawValue:)) ?? .automatic + let host: String + if let chosen = values[OracleDebugProtocolPreference.hostFieldID] { + host = chosen.trimmingCharacters(in: .whitespaces) + } else { + host = await defaultDebuggerHost() + } + let portsText = values[OracleDebugProtocolPreference.portsFieldID] ?? OracleDebugStatements.defaultPorts + guard let ports = OracleDebugStatements.ports(from: portsText.isEmpty ? OracleDebugStatements.defaultPorts : portsText) else { + throw OracleDebugPluginError(message: OracleDebugText.invalidPorts(portsText)) + } + var arguments: [String: OracleDebugArgumentValue] = [:] + for argument in request.arguments { + switch argument.value { + case .text(let text): arguments[argument.parameterName] = .text(text) + case .null: arguments[argument.parameterName] = .null + case .defaultValue: arguments[argument.parameterName] = .defaultValue + @unknown default: arguments[argument.parameterName] = .null + } + } + let breakpoints = request.breakpoints.compactMap { breakpoint -> OracleDebugBreakpointRequest? in + OracleDebugUnit(identity: breakpoint.unitIdentity).map { OracleDebugBreakpointRequest(unit: $0, line: breakpoint.line) } + } + let launch = OracleDebugLaunch( + entryPoint: entryPoint, + arguments: arguments, + breakpoints: breakpoints, + preference: preference, + debuggerHost: host, + ports: ports, + databaseHost: config.host, + databasePort: config.port, + otherDatabaseHosts: tunneledHost.map { [$0] } ?? [], + overloads: overloads + ) + do { + let engine = try await OracleDebugLauncher.launch(launch) { try await opener.open() } + return OracleRoutineDebugSession(engine: engine) + } catch { + throw OracleDebugText.error(for: error, host: host) + } + } + + // MARK: - Helpers + + // Recompiling a package body discards its state in every session using it; their next call fails once with + // ORA-04068. + static func compileForDebugText(_ kind: OracleDebugUnitKind) -> String { + let compile = String(localized: """ + TablePro compiles it again with debug information (PLSQL_OPTIMIZE_LEVEL=1) and keeps its other settings. \ + Compiling commits. + """) + guard kind.hasSubprograms else { return compile } + return compile + " " + String(localized: """ + Other sessions using the package lose its state, and their next call to it fails once with ORA-04068. + """) + } + + static func debugTarget(_ unit: OracleDebugUnit) -> PluginDebugTarget { + PluginDebugTarget( + identity: unit.identity, + name: unit.name, + schema: unit.owner, + kindDisplayName: OracleDebugText.kindName(unit.kind) + ) + } + + static func pluginEntryPoint(_ entryPoint: OracleDebugEntryPoint) -> PluginDebugEntryPoint { + PluginDebugEntryPoint( + identity: entryPoint.entry.identity, + displayName: entryPoint.displayName, + parameters: entryPoint.parameters.map { parameter in + let mode: PluginDebugParameterMode + switch parameter.mode { + case .input: mode = .input + case .output: mode = .output + case .inputOutput: mode = .inputOutput + } + return PluginDebugParameter( + name: parameter.name, + typeName: parameter.displayType, + mode: mode, + hasDefault: parameter.hasDefault + ) + } + ) + } + + // Asked in the start sheet. A direct connection suggests the address this Mac reaches the database from; through + // a tunnel or proxy the server sees another host, so the field starts empty and DBMS_DEBUG runs. + private func debugOptions() async -> [ConnectionField] { + let jdwpProtocols = [OracleDebugProtocolPreference.automatic.rawValue, OracleDebugProtocolPreference.jdwp.rawValue] + return [ + ConnectionField( + id: OracleDebugProtocolPreference.fieldID, + label: String(localized: "Protocol"), + defaultValue: OracleDebugProtocolPreference.automatic.rawValue, + fieldType: .dropdown(options: [ + ConnectionField.DropdownOption( + value: OracleDebugProtocolPreference.automatic.rawValue, + label: String(localized: "Automatic") + ), + ConnectionField.DropdownOption(value: OracleDebugProtocolPreference.jdwp.rawValue, label: "DBMS_DEBUG_JDWP"), + ConnectionField.DropdownOption(value: OracleDebugProtocolPreference.dbmsDebug.rawValue, label: "DBMS_DEBUG") + ]), + section: .connection + ), + ConnectionField( + id: OracleDebugProtocolPreference.hostFieldID, + label: String(localized: "Debugger Host"), + placeholder: String(localized: "Address the database server can reach this Mac at"), + defaultValue: await defaultDebuggerHost(), + section: .connection, + visibleWhen: FieldVisibilityRule(fieldId: OracleDebugProtocolPreference.fieldID, values: jdwpProtocols) + ), + ConnectionField( + id: OracleDebugProtocolPreference.portsFieldID, + label: String(localized: "Debugger Ports"), + placeholder: OracleDebugStatements.defaultPorts, + defaultValue: OracleDebugStatements.defaultPorts, + section: .connection, + visibleWhen: FieldVisibilityRule(fieldId: OracleDebugProtocolPreference.fieldID, values: jdwpProtocols) + ) + ] + } + + // Behind a tunnel the session dials the forward's loopback address; the server itself is this host. + private var tunneledHost: String? { + guard let host = config.additionalFields["preTunnelHost"], !host.isEmpty else { return nil } + return host + } + + private func defaultDebuggerHost() async -> String { + if tunneledHost != nil { return "" } + guard let route = await OracleDebugNetwork.localAddress(towardHost: config.host, port: config.port) else { return "" } + // `localhost` routes over ::1 first; a server on this Mac, or Docker Desktop's forward, reaches 127.0.0.1. + return OracleDebugNetwork.isLoopback(route) ? "127.0.0.1" : route + } +} + +struct OracleDebugState: Sendable { + var entryPoints: [String: OracleDebugEntryPoint] = [:] +} + +// Hands the launcher the sessions the host opens, keeping each one's Oracle connection. +final class OracleDebugSessionOpener: @unchecked Sendable { + private let source: any PluginDebugSessionSource + private let lock = NSLock() + private var opened: [OracleCoreConnection] = [] + + init(source: any PluginDebugSessionSource) { + self.source = source + } + + func open() async throws -> OracleCoreConnection { + if let reused = lock.withLock({ opened.isEmpty ? nil : opened.removeFirst() }) { + return reused + } + let driver = try await source.openSession() + guard let oracle = driver as? OraclePluginDriver, let core = oracle.core, core.isConnected else { + throw OraclePluginError(core: .notConnected) + } + return core + } + + // Read on the first debug session, which the launcher then takes as its own. + func entryPoint(for entry: OracleDebugEntry) async throws -> (OracleDebugEntryPoint, overloads: [OracleDebugEntryPoint]) { + let connection = try await open() + lock.withLock { opened.append(connection) } + let rows = try await connection.executeQuery(OracleDebugStatements.entryPoints(of: entry.unit)).rows + let entryPoints = OracleDebugStatements.parseEntryPoints(rows: rows.map { $0.map(\.stringValue) }, unit: entry.unit) + guard let entryPoint = entryPoints.first(where: { $0.entry == entry }) else { + throw OracleDebugPluginError(message: OracleDebugText.noEntryPoints(entry.subprogram)) + } + return (entryPoint, entryPoints.filter { $0.entry.subprogram == entry.subprogram }) + } +} diff --git a/apple/Plugins/OracleDriverPlugin/OracleRoutineDebugSession.swift b/apple/Plugins/OracleDriverPlugin/OracleRoutineDebugSession.swift new file mode 100644 index 0000000000..94855be020 --- /dev/null +++ b/apple/Plugins/OracleDriverPlugin/OracleRoutineDebugSession.swift @@ -0,0 +1,157 @@ +// +// OracleRoutineDebugSession.swift +// OracleDriverPlugin +// + +import Foundation +import TableProOracleCore +import TableProPluginKit + +final class OracleRoutineDebugSession: PluginDebugSession, @unchecked Sendable { + private let engine: any OracleDebugEngine + let output: AsyncStream + + // Step Out works on both: JDWP's OUT depth and DBMS_DEBUG's break_any_return each stop in the caller (measured). + let supportedCommands: Set = [.continueExecution, .stepOver, .stepInto, .stepOut] + + init(engine: any OracleDebugEngine) { + self.engine = engine + let notices = engine.notices + output = AsyncStream { continuation in + let task = Task { + for await notice in notices { + continuation.yield(OracleDebugText.line(for: notice)) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + func firstStop() async throws -> PluginDebugStop { + try await run { try await engine.firstStop() } + } + + func perform(_ command: PluginDebugCommand) async throws -> PluginDebugStop { + let mapped: OracleDebugCommand + switch command { + case .continueExecution: mapped = .continueExecution + case .stepOver: mapped = .stepOver + case .stepInto: mapped = .stepInto + case .stepOut: mapped = .stepOut + @unknown default: mapped = .continueExecution + } + return try await run { try await engine.perform(mapped) } + } + + private func run(_ body: () async throws -> OracleDebugStop) async throws -> PluginDebugStop { + do { + return Self.pluginStop(try await body()) + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func source(of unit: PluginDebugUnit) async throws -> String { + guard let oracleUnit = OracleDebugUnit(identity: unit.identity) else { + throw OracleDebugPluginError(message: OracleDebugText.unitUnreadable(unit.displayName)) + } + do { + return try await engine.source(of: oracleUnit) + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func stack() async throws -> [PluginDebugFrame] { + do { + return try await engine.stack().map { frame in + PluginDebugFrame(level: frame.level, location: Self.location(frame.location), arguments: frame.subprogram) + } + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func variables(inFrame level: Int) async throws -> [PluginDebugVariable] { + do { + return try await engine.variables(inFrame: level).map { variable in + let kind: PluginDebugVariableKind + switch variable.kind { + case .argument: kind = .argument + case .local: kind = .local + case .package: kind = .package + } + return PluginDebugVariable( + name: variable.name, + kind: kind, + typeName: variable.typeName, + value: OracleDebugText.text(for: variable.value), + isConstant: variable.isConstant + ) + } + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func setBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws -> PluginDebugBreakpoint? { + guard let unit = OracleDebugUnit(identity: breakpoint.unitIdentity) else { return nil } + do { + guard let line = try await engine.setBreakpoint(in: unit, line: breakpoint.line) else { return nil } + return PluginDebugBreakpoint(unitIdentity: breakpoint.unitIdentity, line: line) + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func removeBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws { + guard let unit = OracleDebugUnit(identity: breakpoint.unitIdentity) else { return } + do { + try await engine.removeBreakpoint(in: unit, line: breakpoint.line) + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func stop() async -> PluginDebugStopOutcome { + switch await engine.stop() { + case .ended: + return .ended + case .unconfirmed(let identity): + let text = OracleDebugText.stillRunning(identity) + return .unconfirmed(message: text.message, detail: text.detail) + } + } + + static func location(_ location: OracleDebugLocation) -> PluginDebugLocation { + PluginDebugLocation(unit: unit(location.unit), line: location.line) + } + + static func unit(_ unit: OracleDebugUnit) -> PluginDebugUnit { + let name = "\(unit.owner).\(unit.name)" + let displayName = unit.kind == .package || unit.kind == .type + ? String(format: String(localized: "%@ (specification)"), name) + : name + return PluginDebugUnit(identity: unit.identity, displayName: displayName) + } + + static func pluginStop(_ stop: OracleDebugStop) -> PluginDebugStop { + switch stop { + case .paused(let location): + return .paused(Self.location(location)) + case .finished(let outcome): + guard !outcome.values.isEmpty else { return .finished(nil) } + return .finished(PluginQueryResult( + columns: outcome.values.map(\.name), + columnTypeNames: outcome.values.map(\.typeName), + rows: [outcome.values.map { $0.value.map(PluginCellValue.text) ?? .null }], + rowsAffected: 0, + timing: PluginQueryTiming(total: 0) + )) + case .failed(let failure): + let text = OracleDebugText.failure(failure) + return .failed(message: text.message, detail: text.detail) + } + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index e459e63e6b..4ac2de19bd 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -25,7 +25,7 @@ final class LibPQDriverCore: @unchecked Sendable { var currentSchema: String = "public" private var selectedSchema: String? - var onPostConnect: (@Sendable () async -> Void)? + var onPostConnect: (@Sendable () async throws -> Void)? /// Set by `LibPQBackedDriver` for the span of one connect, so every driver built on this /// core reports its handshake steps without having to thread a parameter through its own @@ -124,7 +124,7 @@ final class LibPQDriverCore: @unchecked Sendable { currentSchema = selectedSchema } - await onPostConnect?() + try await onPostConnect?() try await pqConn.finishConnecting() try Task.checkCancellation() } onCancel: { @@ -260,6 +260,18 @@ final class LibPQDriverCore: @unchecked Sendable { libpqConnection?.cancelCurrentQuery() } + func observeServerNotices(_ observer: (@Sendable (LibPQServerNotice) -> Void)?) { + libpqConnection?.observeServerNotices(observer) + } + + func cancelQueryKeepingReply() { + libpqConnection?.requestServerCancelKeepingReply() + } + + func interruptBlockedCallOnDisconnect() { + libpqConnection?.interruptBlockedCallOnDisconnect() + } + func setPostgisOidMap(_ map: [UInt32: PostGISType]) { libpqConnection?.setPostgisOidMap(map) } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index 67a12ea64d..42f3e0fa84 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -72,6 +72,7 @@ final class LibPQPluginConnection: @unchecked Sendable { private var _isConnectCancelled: Bool = false private var _lastTransactionState: LibPQTransactionState = .idle private var _hasLostConnection = false + private var _interruptsBlockedCallOnDisconnect = false private var serverMessages: Unmanaged? private var _standardConformingStrings = true /// Queue-confined. Non-nil until every schema/flavor/catalog bootstrap probe has completed. @@ -173,11 +174,8 @@ final class LibPQPluginConnection: @unchecked Sendable { } func cancelConnect() { - stateLock.lock() - _isConnectCancelled = true - let currentConnection = conn - stateLock.unlock() - if let currentConnection { requestServerCancel(on: currentConnection, evenWhenSuppressed: true) } + stateLock.withLock { _isConnectCancelled = true } + requestServerCancelOnCurrentConnection(evenWhenSuppressed: true) } private var isConnectCancelled: Bool { @@ -537,6 +535,19 @@ final class LibPQPluginConnection: @unchecked Sendable { } } + // PQgetCancel copies what the request needs while the lock is held: disconnect() takes `conn` under that lock + // before the PQfinish it queues can free the connection. + private func requestServerCancelOnCurrentConnection(evenWhenSuppressed: Bool = false) { + let request: LibPQCancelRequest? = stateLock.withLock { + guard evenWhenSuppressed || !suppressServerSideCancel, let conn, let cancelObject = PQgetCancel(conn) else { + return nil + } + return LibPQCancelRequest(cancelObject) + } + guard let request else { return } + Self.cancellationQueue.async { request.perform() } + } + private func requestServerCancel(on connection: OpaquePointer, evenWhenSuppressed: Bool = false) { guard evenWhenSuppressed || !suppressServerSideCancel, let cancelObject = PQgetCancel(connection) else { return } @@ -584,11 +595,17 @@ final class LibPQPluginConnection: @unchecked Sendable { _isConnectCancelled = true let handle = conn let sink = serverMessages + let interruptsBlockedCall = _interruptsBlockedCallOnDisconnect conn = nil serverMessages = nil _cachedServerVersion = nil _cachedServerVersionNumber = 0 stateLock.unlock() + // The descriptor stays this connection's until the PQfinish queued below runs. + if interruptsBlockedCall, let handle { + let socket = PQsocket(handle) + if socket >= 0 { _ = Darwin.shutdown(socket, SHUT_RDWR) } + } queue.async { [self] in activeConnectDeadline = nil if let handle { @@ -612,13 +629,7 @@ final class LibPQPluginConnection: @unchecked Sendable { func cancelCurrentQuery() { guard cancellationGate.cancel() != nil else { return } - - stateLock.lock() - let currentConn = conn - stateLock.unlock() - - guard let currentConn else { return } - requestServerCancel(on: currentConn) + requestServerCancelOnCurrentConnection() } // MARK: - Query Execution @@ -1447,3 +1458,22 @@ final class LibPQPluginConnection: @unchecked Sendable { return LibPQPluginError(message: message, sqlState: nil, detail: nil) } } + +extension LibPQPluginConnection { + func observeServerNotices(_ observer: (@Sendable (LibPQServerNotice) -> Void)?) { + let sink = stateLock.withLock { serverMessages?.takeUnretainedValue() } + sink?.observeNotices(observer) + } + + // A debug session runs with no statement timeout, so a call blocked on a server that stopped answering would + // otherwise keep the socket, and the PQfinish queued behind it, for as long as TCP keeps the connection open. + func interruptBlockedCallOnDisconnect() { + stateLock.withLock { _interruptsBlockedCallOnDisconnect = true } + } + + // Leaves the cancellation gate alone, so the server's reply decides how the call ended: a cancel that lands + // after a routine returned must not turn its committed result into a cancellation. + func requestServerCancelKeepingReply() { + requestServerCancelOnCurrentConnection() + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQServerMessageSink.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQServerMessageSink.swift index 4f93b98886..be78bb57fa 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQServerMessageSink.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQServerMessageSink.swift @@ -6,12 +6,19 @@ import CLibPQ import Foundation +struct LibPQServerNotice: Sendable { + let message: String + let text: String +} + final class LibPQServerMessageSink: @unchecked Sendable { private static let severityField = Int32(UInt8(ascii: "V")) + private static let primaryMessageField = Int32(UInt8(ascii: "M")) private let lock = NSLock() private var sessionEnding: LibPQPluginError? private var forwardedReceiver: PQnoticeReceiver? + private var noticeObserver: (@Sendable (LibPQServerNotice) -> Void)? static func install(on connection: OpaquePointer) -> Unmanaged { let retained = Unmanaged.passRetained(LibPQServerMessageSink()) @@ -34,6 +41,12 @@ final class LibPQServerMessageSink: @unchecked Sendable { lock.withLock { sessionEnding = nil } } + // The observer runs on the connection's queue while a statement reads its reply, so it must not call back + // into the connection. + func observeNotices(_ observer: (@Sendable (LibPQServerNotice) -> Void)?) { + lock.withLock { noticeObserver = observer } + } + fileprivate func receive(_ result: OpaquePointer) { let message = PQresultErrorMessage(result).map { String(cString: $0).trimmingCharacters(in: .whitespacesAndNewlines) @@ -42,11 +55,15 @@ final class LibPQServerMessageSink: @unchecked Sendable { PQresultErrorField(result, field).map { String(cString: $0) } } let severity = PQresultErrorField(result, Self.severityField).map { String(cString: $0) } - let forward = lock.withLock { () -> PQnoticeReceiver? in + let (forward, observer) = lock.withLock { () -> (PQnoticeReceiver?, (@Sendable (LibPQServerNotice) -> Void)?) in if LibPQServerMessage.endsSession(severity: severity, sqlState: received.sqlState) { sessionEnding = received } - return forwardedReceiver + return (forwardedReceiver, noticeObserver) + } + if let observer { + let primary = PQresultErrorField(result, Self.primaryMessageField).map { String(cString: $0) } + observer(LibPQServerNotice(message: primary ?? message, text: message)) } forward?(nil, result) } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift b/apple/Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift index 86d8ce8ccc..9c29dd68d6 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift @@ -26,6 +26,9 @@ final class PGlitePluginDriver: PostgreSQLPluginDriver { super.capabilities.subtracting(.cancelQuery) } + // pldebugger is a server C extension and needs a second session; PGlite has neither. + override var canDebugRoutines: Bool { false } + override func connect() async throws { do { try await super.connect() diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDebugRendezvous.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDebugRendezvous.swift new file mode 100644 index 0000000000..8e9ffacc45 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDebugRendezvous.swift @@ -0,0 +1,134 @@ +// +// PostgreSQLDebugRendezvous.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import os + +// Where the target's notice receiver hands over the port it waits on, and where a stop learns the target's +// call is over. `abandon()` releases the waiters when the target could not be ended in time. +final class PostgreSQLDebugRendezvous: @unchecked Sendable { + private let lock = NSLock() + private var deliveredPort: Int? + private var isSettled = false + private var abandoned = false + private var portWaiters: [CheckedContinuation] = [] + private var settleWaiters: [Int: CheckedContinuation] = [:] + private var withdrawnWaiters: Set = [] + private var lastWaiter = 0 + + var hasPort: Bool { + lock.withLock { deliveredPort != nil } + } + + var hasSettled: Bool { + lock.withLock { isSettled } + } + + // True when the waiters were released without the target's call ending: it may still hold its locks. + var isAbandoned: Bool { + lock.withLock { abandoned } + } + + var waiterCount: Int { + lock.withLock { portWaiters.count + settleWaiters.count } + } + + func abandon() { + release(abandoning: true) + } + + func deliver(port: Int) { + let waiters = lock.withLock { () -> [CheckedContinuation] in + deliveredPort = port + defer { portWaiters.removeAll() } + return portWaiters + } + waiters.forEach { $0.resume(returning: port) } + } + + func settle() { + release(abandoning: false) + } + + private func release(abandoning: Bool) { + let released = lock.withLock { + () -> (Int?, [CheckedContinuation], [CheckedContinuation])? in + guard !isSettled else { return nil } + isSettled = true + abandoned = abandoning + defer { + portWaiters.removeAll() + settleWaiters.removeAll() + withdrawnWaiters.removeAll() + } + return (deliveredPort, portWaiters, Array(settleWaiters.values)) + } + guard let released else { return } + released.1.forEach { $0.resume(returning: released.0) } + released.2.forEach { $0.resume() } + } + + // The port once the target reaches the routine, or nil when its call ended first. + func port() async -> Int? { + await withCheckedContinuation { continuation in + let ready: Int?? = lock.withLock { + if let deliveredPort { return .some(deliveredPort) } + if isSettled { return .some(nil) } + portWaiters.append(continuation) + return .none + } + if let ready { continuation.resume(returning: ready) } + } + } + + // Returns when this settles, or at once when the waiting task is cancelled, which withdraws the waiter. + func settled() async { + let waiter = lock.withLock { () -> Int in + lastWaiter += 1 + return lastWaiter + } + await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let ready = lock.withLock { () -> Bool in + if isSettled || withdrawnWaiters.remove(waiter) != nil { return true } + settleWaiters[waiter] = continuation + return false + } + if ready { continuation.resume() } + } + } onCancel: { + let withdrawn = lock.withLock { () -> CheckedContinuation? in + if let continuation = settleWaiters.removeValue(forKey: waiter) { return continuation } + if !isSettled { withdrawnWaiters.insert(waiter) } + return nil + } + withdrawn?.resume() + } + } + + // The step's result, or nil when this settles first. The losing wait on this is withdrawn before the answer is + // given, so nothing waits on it past the call; only a step still running on its connection outlives a nil answer. + func reply(to step: Task) async -> Result? { + let claimed = OSAllocatedUnfairLock(initialState: false) + let claim = { @Sendable () -> Bool in + claimed.withLock { done -> Bool in + defer { done = true } + return !done + } + } + return await withCheckedContinuation { (continuation: CheckedContinuation?, Never>) in + let signal = Task { + await self.settled() + if claim() { continuation.resume(returning: nil) } + } + Task { + let result = await step.result + guard claim() else { return } + signal.cancel() + continuation.resume(returning: result) + } + } + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDialect.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDialect.swift index b4b950c780..510f7ce268 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDialect.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDialect.swift @@ -16,7 +16,8 @@ enum PostgreSQLDialect { caseSensitivityStyle: .ilikeOperator, operators: SQLEngineVocabulary.postgreSQL.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: "TEXT", - functionNamesAreCaseInsensitive: SQLEngineVocabulary.postgreSQL.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.postgreSQL.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) static let keywords: Set = reservedKeywords diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift index 4968b271b4..4a0e63f1e7 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift @@ -125,7 +125,9 @@ nonisolated enum PostgreSQLIndexQueries { /// `pg_get_expr` writes it, which is the same deparse with the same flags. When either end fails to /// match byte for byte, `method_and_keys` is NULL and the index falls back to its fields. Cutting /// at the first ` USING ` or the last ` WHERE ` instead would split an index named - /// `i ON s.t USING btree (` or a predicate holding the literal `' WHERE '`. + /// `i ON s.t USING btree (` or a predicate holding the literal `' WHERE '`. An index without a + /// predicate has a NULL suffix, never `''`: KingbaseES in Oracle mode reads `''` as NULL and + /// `' WHERE ' || NULL` as ` WHERE `. static func indexDDLQuery(schema: String, table: String?) -> String { let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) let tablePredicate = table.map { " AND t.relname = \(PostgreSQLObjectQueries.quoteLiteral($0))" } ?? "" @@ -134,15 +136,17 @@ nonisolated enum PostgreSQLIndexQueries { d.table_name, d.index_name, CASE - WHEN pg_catalog.length(d.definition) >= pg_catalog.length(d.prefix) + pg_catalog.length(d.suffix) + WHEN pg_catalog.length(d.definition) + >= pg_catalog.length(d.prefix) + COALESCE(pg_catalog.length(d.suffix), 0) AND pg_catalog.substr(d.definition, 1, pg_catalog.length(d.prefix)) = d.prefix - AND pg_catalog.substr( + AND (d.suffix IS NULL OR pg_catalog.substr( d.definition, pg_catalog.length(d.definition) - pg_catalog.length(d.suffix) + 1 - ) = d.suffix + ) = d.suffix) THEN pg_catalog.substr( d.definition, pg_catalog.length(d.prefix) + 1, - pg_catalog.length(d.definition) - pg_catalog.length(d.prefix) - pg_catalog.length(d.suffix) + pg_catalog.length(d.definition) - pg_catalog.length(d.prefix) + - COALESCE(pg_catalog.length(d.suffix), 0) ) END AS method_and_keys, d.predicate @@ -162,7 +166,9 @@ nonisolated enum PostgreSQLIndexQueries { END || pg_catalog.quote_ident(n.nspname) || '.' || pg_catalog.quote_ident(t.relname) || ' ' AS prefix, - COALESCE(' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid), '') AS suffix, + CASE WHEN ix.indpred IS NOT NULL + THEN ' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid) + END AS suffix, pg_catalog.pg_get_expr(ix.indpred, ix.indrelid) AS predicate FROM pg_catalog.pg_index ix JOIN pg_catalog.pg_class i ON i.oid = ix.indexrelid diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift index 6e50299834..260d95fe3b 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift @@ -137,7 +137,7 @@ extension PostgreSQLPluginDriver { JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND NOT t.tgisinternal - AND t.tgenabled <> 'O' + AND t.tgenabled IN ('D', 'R', 'A') ORDER BY t.tgname """) @@ -155,7 +155,7 @@ extension PostgreSQLPluginDriver { JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND a.attnum > 0 AND NOT a.attisdropped - AND \(caps.hasIdentityColumns ? "a.attidentity = ''" : "true") + AND \(caps.hasIdentityColumns ? "a.attidentity NOT IN ('a', 'd')" : "true") AND pg_get_serial_sequence( quote_ident(n.nspname) || '.' || quote_ident(c.relname), a.attname ) IS NOT NULL diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+RoutineDebugging.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+RoutineDebugging.swift new file mode 100644 index 0000000000..f304638350 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+RoutineDebugging.swift @@ -0,0 +1,122 @@ +// +// PostgreSQLPluginDriver+RoutineDebugging.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit +import TableProPostgreSQLCore + +// pldbgapi direct debugging. Redshift and CockroachDB are siblings of this class, not subclasses, so they never +// conform; PGlite inherits the conformance and turns it off through `canDebugRoutines`. +extension PostgreSQLPluginDriver: PluginRoutineDebugging { + func debugTarget(for routine: PluginRoutineInfo) -> PluginDebugTarget? { + guard canDebugRoutines else { return nil } + return PostgreSQLRoutineDebugProtocol.target(for: routine) + } + + func debugTargets(schema: String?) async throws -> [PluginDebugTarget] { + guard canDebugRoutines else { return [] } + let query = PostgreSQLRoutineDebugProtocol.targetListQuery( + schema: schema ?? currentSchema ?? "public", + hasProcedureKind: catalogCapabilities.hasProcedureKind + ) + let result = try await execute(query: query) + return result.rows.compactMap { PostgreSQLRoutineDebugProtocol.target(fromListRow: $0.map(\.asText)) } + } + + func prepareDebug(_ target: PluginDebugTarget) async throws -> PluginDebugPlan { + guard canDebugRoutines else { throw PostgreSQLRoutineDebugError.unavailable } + let routine = try await debugRoutine(identity: target.identity, name: target.name) + _ = try routine.debuggerSchema() + return PluginDebugPlan( + target: target, + entryPoints: [routine.entryPoint], + // Any routine can write: a STABLE label does not stop a default such as nextval() or a volatile callee. + mayWrite: true, + note: routine.runNote + ) + } + + func startDebug( + _ request: PluginDebugRequest, + sessions: any PluginDebugSessionSource + ) async throws -> any PluginDebugSession { + guard canDebugRoutines else { throw PostgreSQLRoutineDebugError.unavailable } + let target = try await Self.debugSession(from: sessions) + let proxy = try await Self.debugSession(from: sessions) + try await proxy.prepareForDebugging(isTarget: false) + + let routine = try await proxy.debugRoutine( + identity: request.entryPoint.identity, + name: request.entryPoint.displayName + ) + let extensionSchema = try routine.debuggerSchema() + try await target.prepareForDebugging(isTarget: true) + let targetProcess = try? await target.execute(query: "SELECT pg_catalog.pg_backend_pid()") + .rows.first?.first?.asText.flatMap { Int($0) } + let call = try PostgreSQLRoutineDebugProtocol.call(for: routine, arguments: request.arguments) + guard let oid = UInt32(routine.identity) else { + throw PostgreSQLRoutineDebugError.routineNotFound(routine.displayName) + } + do { + _ = try await target.execute( + query: PostgreSQLRoutineDebugProtocol.oidDebugStatement(extensionSchema: extensionSchema, oid: oid) + ) + } catch { + throw PostgreSQLRoutineDebugProtocol.startFailure( + error, + database: routine.database, + routineName: routine.displayName + ) + } + + // Nothing runs on the target yet, so a cancelled start leaves nothing behind. + try Task.checkCancellation() + let session = PostgreSQLRoutineDebugSession( + target: target, + proxy: proxy, + sessions: sessions, + routine: routine, + extensionSchema: extensionSchema, + targetProcess: targetProcess, + breakpoints: request.breakpoints + ) + try await session.start(call) + return session + } + + static func debugSession(from sessions: any PluginDebugSessionSource) async throws -> PostgreSQLPluginDriver { + guard let driver = try await sessions.openSession() as? PostgreSQLPluginDriver, driver.canDebugRoutines else { + throw PostgreSQLRoutineDebugError.unavailable + } + driver.core.interruptBlockedCallOnDisconnect() + return driver + } + + private func prepareForDebugging(isTarget: Bool) async throws { + // Before the settings, which a rollback would undo. + if PostgreSQLRoutineDebugProtocol.endsStartupTransaction(await sessionTransactionState()) { + _ = try await execute(query: "ROLLBACK") + } + let statements = PostgreSQLRoutineDebugProtocol.sessionSettings( + serverVersion: serverVersionNumber, + isTarget: isTarget + ) + for statement in statements { + _ = try await execute(query: statement) + } + } + + private func debugRoutine(identity: String, name: String) async throws -> PostgreSQLDebugRoutine { + guard let oid = PostgreSQLRoutineDebugProtocol.oid(inIdentity: identity) else { + throw PostgreSQLRoutineDebugError.routineNotFound(name) + } + let query = PostgreSQLRoutineDebugProtocol.routineQuery(oid: oid, capabilities: catalogCapabilities) + let result = try await execute(query: query) + guard let row = result.rows.first else { throw PostgreSQLRoutineDebugError.routineNotFound(name) } + let routine = PostgreSQLDebugRoutine(row: row.map(\.asText), identity: String(oid)) + guard routine.exists else { throw PostgreSQLRoutineDebugError.routineNotFound(name) } + return routine + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+SessionScope.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+SessionScope.swift index 2d30cbd3b3..73dd899b7d 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+SessionScope.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+SessionScope.swift @@ -24,6 +24,9 @@ internal extension PostgreSQLPluginDriver { case .verbatimWrite: // What the read pins reads back the same under any settings; nil would make the host drop the read too. return PluginSessionScope(purpose: .verbatimWrite, saved: [], applied: []) + case .relaxedForeignKeys: + // `session_replication_role = replica` would also stop the target's user triggers, and needs a superuser. + return nil @unknown default: return nil } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index f92bf287f3..32f401b440 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -21,6 +21,8 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { let sessionFacts = OSAllocatedUnfairLock(initialState: PostgreSQLSessionFacts.unknown) private let listingDegradeReported = OSAllocatedUnfairLock(initialState: false) + var sessionLexicalState: PluginSessionLexicalState? { core.sessionLexicalState } + var serverVersionNumber: Int32 { let reported = core.serverVersionNumber return sessionFacts.withLock { $0.resolvedServerVersion(reported: reported) } @@ -52,6 +54,9 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { ] } + // In the class body so PGlite, which inherits the debugger conformance, can turn it off. + var canDebugRoutines: Bool { true } + init(config: DriverConnectionConfig, singleConnectionMode: Bool = false) { self.core = LibPQDriverCore(config: config, singleConnectionMode: singleConnectionMode) } @@ -60,15 +65,20 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func connect() async throws { core.onPostConnect = { [weak self] in - await self?.probeSessionFacts() - await self?.probeCatalogPresence() - await self?.probePostgisOids() - await self?.probeEnumOids() - await self?.probeVectorTypes() + try await self?.probeAfterConnect() } try await core.connect() } + /// Runs inside the connect's deadline and cancellation, before the connection is handed out. + func probeAfterConnect() async throws { + await probeSessionFacts() + await probeCatalogPresence() + await probePostgisOids() + await probeEnumOids() + await probeVectorTypes() + } + private func probeCatalogPresence() async { do { let result = try await core.execute(query: PostgreSQLCatalogPresence.probeQuery) @@ -122,11 +132,17 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { // MARK: - Foreign Keys func foreignKeyDisableStatements() -> [String]? { - ["SET session_replication_role = replica"] + [Self.settingReplicationRole("replica")] } func foreignKeyEnableStatements() -> [String]? { - ["SET session_replication_role = DEFAULT"] + [Self.settingReplicationRole("DEFAULT")] + } + + /// Setting it takes a superuser or a `SET` grant, and a bare refusal aborts the transaction it runs in. The block + /// swallows the refusal and keeps the transaction, and an allowed `SET` outlives it (measured on 17.11 and 18.6). + private static func settingReplicationRole(_ value: String) -> String { + "DO $$ BEGIN SET session_replication_role = \(value); EXCEPTION WHEN insufficient_privilege THEN NULL; END $$" } /// A duplicated database arrives with `public` alone, so every other schema its tables are diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugError.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugError.swift new file mode 100644 index 0000000000..80acaaaad6 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugError.swift @@ -0,0 +1,98 @@ +// +// PostgreSQLRoutineDebugError.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +enum PostgreSQLRoutineDebugError: PluginDriverError, Equatable { + case extensionMissing(database: String) + case serverPackageMissing + case notOwner(routine: String) + case sharedMemoryUnavailable + case routineNotFound(String) + case notDebuggable + case pseudoTypeParameter + case hidesDebuggerNotice(value: String) + case limitsTransactionTime(value: String) + case targetAbandoned(process: Int?, port: Int?) + case unavailable + case notPaused + case unsupportedCommand + case noDefault(parameter: String) + case defaultNotReadable(parameter: String) + + var pluginErrorMessage: String { + switch self { + case .extensionMissing(let database): + return String(format: String(localized: "The PL/pgSQL debugger isn't installed in “%@”."), database) + case .serverPackageMissing: + return String(localized: "This server has no PL/pgSQL debugger.") + case .notOwner(let routine): + return String(format: String(localized: "Only the owner of %@ or a superuser can debug it."), routine) + case .sharedMemoryUnavailable: + return String(localized: "The server couldn't start the debugger.") + case .routineNotFound(let routine): + return String(format: String(localized: "%@ no longer exists"), routine) + case .notDebuggable: + return String(localized: "Only PL/pgSQL functions and procedures can be debugged.") + case .pseudoTypeParameter: + return String(localized: "Routines with polymorphic or record parameters can't be debugged.") + case .hidesDebuggerNotice(let value): + return String( + format: String( + localized: "This routine sets client_min_messages to %@, which hides the notice the debugger starts with." + ), + value + ) + case .limitsTransactionTime(let value): + return String( + format: String( + localized: "This routine sets transaction_timeout to %@, which would end it while it waits at a breakpoint." + ), + value + ) + case .targetAbandoned: + return PostgreSQLRoutineDebugProtocol.abandonedMessage + case .unavailable: + return String(localized: "This connection can't run the PL/pgSQL debugger.") + case .notPaused: + return String(localized: "The routine isn't paused.") + case .unsupportedCommand: + return String(localized: "The PL/pgSQL debugger can't step out of a routine.") + case .noDefault(let parameter): + return String(format: String(localized: "%@ has no default value."), parameter) + case .defaultNotReadable(let parameter): + return String( + format: String(localized: "This server can't show the default value of %@, so enter a value for it."), + parameter + ) + } + } + + var pluginErrorDetail: String? { + switch self { + case .extensionMissing: + return String(localized: "Ask a superuser to run CREATE EXTENSION pldbgapi in this database.") + case .serverPackageMissing: + return String( + localized: """ + Install the pldebugger package for this PostgreSQL version on the server, then run \ + CREATE EXTENSION pldbgapi in this database. + """ + ) + case .sharedMemoryUnavailable: + return String(localized: "Add plugin_debugger to shared_preload_libraries and restart the server.") + case .hidesDebuggerNotice: + return String(localized: "Remove SET client_min_messages from the routine to debug it.") + case .limitsTransactionTime: + return String(localized: "Remove SET transaction_timeout from the routine to debug it.") + case .targetAbandoned(let process, let port): + return PostgreSQLRoutineDebugProtocol.abandonedDetail(process: process, port: port) + case .notOwner, .routineNotFound, .notDebuggable, .pseudoTypeParameter, .unavailable, .notPaused, + .unsupportedCommand, .noDefault, .defaultNotReadable: + return nil + } + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugProtocol.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugProtocol.swift new file mode 100644 index 0000000000..8cbc8e5b34 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugProtocol.swift @@ -0,0 +1,752 @@ +// +// PostgreSQLRoutineDebugProtocol.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit +import TableProPostgreSQLCore + +struct PostgreSQLDebugCall: Equatable, Sendable { + let sql: String + let parameters: [PluginCellValue] +} + +enum PostgreSQLDebugProxyFailure: Equatable { + case targetEnded + // A signal reached the proxy while it waited: the target is still attached to it and its reply is unread. + case interrupted + case broken +} + +// How to end a target whose start was cancelled. +enum PostgreSQLDebugStartCancellation: Equatable { + // The call has not reached the routine: a cancel on the target ends it. + case cancelCall + // The target waits in accept() and ignores a cancel: only an attach and pldbg_abort_target end it. + case attachAndAbort + case nothing +} + +// What start does once the call reached the routine or ended. +enum PostgreSQLDebugStartStep: Equatable { + case attach(port: Int) + case attachAndAbort(port: Int) + case cancelled + case endedBeforeEntry +} + +struct PostgreSQLDebugRoutine: Equatable, Sendable { + let identity: String + let database: String + let extensionSchema: String? + let extensionAvailable: Bool + let exists: Bool + let mayDebug: Bool + let kind: String? + let volatility: String? + let language: String? + let schema: String + let name: String + let identityArguments: String + let returnsRecordType: Bool + let returnsTrigger: Bool + let parameters: [PluginDebugParameter] + let takesPseudoType: Bool + let configuration: [String] + // Each argument's type as its quoted, schema-qualified pg_type name ("pg_catalog"."bit"), parallel to + // `parameters`. It names exactly the declared type and no length: format_type's character and bit mean + // character(1) and bit(1) and cut the value, and bpchar or varbit pick an f(bit varying) overload over the + // f(bit) being debugged (both measured). + let argumentCastTypes: [String] + // Each argument's default expression from pg_get_function_arg_default, nil where it has none. + let defaultExpressions: [String?] + + // The columns of `PostgreSQLRoutineDebugProtocol.routineQuery`, in order. + init(row: [String?], identity: String) { + func text(_ index: Int) -> String? { row.indices.contains(index) ? row[index] : nil } + func flag(_ index: Int) -> Bool { PostgreSQLCatalogBoolean.isTrue(text(index)) } + self.identity = identity + database = text(0) ?? "" + extensionSchema = text(1) + extensionAvailable = flag(2) + exists = flag(3) + mayDebug = flag(4) + kind = text(5) + volatility = text(6) + language = text(7) + schema = text(8) ?? "" + name = text(9) ?? "" + identityArguments = text(10) ?? "" + returnsRecordType = flag(11) + returnsTrigger = flag(12) + parameters = PostgreSQLRoutineDebugProtocol.parameters( + types: PostgreSQLTextArray.values(text(13)), + names: PostgreSQLTextArray.elements(text(14)), + modes: PostgreSQLTextArray.values(text(15)), + defaultCount: text(16).flatMap { Int($0) } ?? 0 + ) + takesPseudoType = flag(17) + configuration = PostgreSQLTextArray.values(text(18)) + argumentCastTypes = PostgreSQLTextArray.values(text(19)) + defaultExpressions = PostgreSQLTextArray.elements(text(20)) + } + + var isProcedure: Bool { kind == "p" } + + var displayName: String { "\(name)(\(identityArguments))" } + + var returnsBareRecord: Bool { + returnsRecordType && !parameters.contains { $0.mode == .output || $0.mode == .inputOutput } + } + + var entryPoint: PluginDebugEntryPoint { + PluginDebugEntryPoint(identity: identity, displayName: displayName, parameters: parameters) + } + + var runNote: String { + isProcedure + ? String( + localized: """ + Changes the procedure makes are committed when it commits or finishes. Stop rolls back what it \ + hasn't committed. + """ + ) + : String(localized: "Changes the routine makes are committed when it finishes. Stop rolls them back.") + } + + // Never gates on shared_preload_libraries: a non-superuser cannot read it, and direct debugging works without it. + func debuggerSchema() throws -> String { + guard language == "plpgsql", kind == "f" || kind == "p", !returnsTrigger else { + throw PostgreSQLRoutineDebugError.notDebuggable + } + guard !takesPseudoType else { throw PostgreSQLRoutineDebugError.pseudoTypeParameter } + if let blocking = PostgreSQLRoutineDebugProtocol.blockingSetting(in: configuration) { throw blocking } + guard let extensionSchema, !extensionSchema.isEmpty else { + throw extensionAvailable + ? PostgreSQLRoutineDebugError.extensionMissing(database: database) + : PostgreSQLRoutineDebugError.serverPackageMissing + } + guard mayDebug else { throw PostgreSQLRoutineDebugError.notOwner(routine: displayName) } + return extensionSchema + } +} + +enum PostgreSQLRoutineDebugProtocol { + static let supportedCommands: Set = [.continueExecution, .stepOver, .stepInto] + + private static let breakNoticePrefix = "PLDBGBREAK:" + private static let targetEndedState = "08006" + private static let queryCanceledState = "57014" + private static let triggerReturnTypes: Set = ["trigger", "event_trigger"] + // A bound text argument stays unknown for a polymorphic parameter, and text cannot become a bare record, so a + // routine taking one of these cannot be called by the debugger. + private static let pseudoArgumentTypes: Set = [ + "\"any\"", "anyelement", "anyarray", "anynonarray", "anyenum", "anyrange", "anymultirange", + "anycompatible", "anycompatiblearray", "anycompatiblenonarray", "anycompatiblerange", + "anycompatiblemultirange", "record", "record[]" + ] + + static var stoppedMessage: String { + String(localized: "The routine was stopped, and its uncommitted changes were rolled back.") + } + + // A cancel reaching the proxy does this, and so does a server transaction_timeout on PostgreSQL 17 once a wait + // outlasts it, even with the session's own set to 0 (measured on 17.11). + static var interruptedMessage: String { + String( + localized: """ + The debugger's session was interrupted while it waited, so the routine was stopped and its uncommitted \ + changes were rolled back. A cancel or the server's transaction_timeout can cause this. + """ + ) + } + + static func abandonedStop(process: Int?, port: Int?) -> PluginDebugStop { + .failed(message: abandonedMessage, detail: abandonedDetail(process: process, port: port)) + } + + // Ended only when the target's call is known to have returned: then its transaction is over and its locks gone. + static func stopOutcome(callEnded: Bool, abandoned: Bool, process: Int?, port: Int?) -> PluginDebugStopOutcome { + guard callEnded, !abandoned else { + return .unconfirmed(message: abandonedMessage, detail: abandonedDetail(process: process, port: port)) + } + return .ended + } + + static var abandonedMessage: String { + String(localized: "The routine couldn't be stopped on the server.") + } + + // Names the backend so someone with access can end it: a target waiting for a debugger ignores cancel and + // terminate, and only an attach followed by pldbg_abort_target ends it. + static func abandonedDetail(process: Int?, port: Int?) -> String { + switch (process, port) { + case let (process?, port?): + return String( + format: String( + localized: """ + Server process %d may still be waiting for a debugger and holding its locks. To end it, run \ + SELECT pldbg_attach_to_port(%d) and pass the number it returns to pldbg_abort_target. + """ + ), + process, + port + ) + case let (process?, nil): + return String(format: String(localized: "Server process %d may still be running and holding its locks."), process) + case (nil, _): + return String(localized: "Its server session may still be running and holding its locks.") + } + } + + // MARK: - Session setup + + static func sessionSettings(serverVersion: Int32, isTarget: Bool) -> [String] { + // Each would end a target paused at a breakpoint, or the proxy while it waits for one. A paused target is + // active and the proxy idle outside a transaction, so idle_in_transaction_session_timeout never applies. + var statements = ["SET statement_timeout = 0"] + if serverVersion >= 140_000 { + statements.append("SET idle_session_timeout = 0") + } + if serverVersion >= 170_000 { + statements.append("SET transaction_timeout = 0") + } + // The target announces the port it waits on as a NOTICE, which a stricter level never sends. + if isTarget { + statements.append("SET client_min_messages = notice") + } + return statements + } + + // Startup commands can leave a transaction open; the routine's changes must neither join it nor be committed + // by it, so it is rolled back before the call. + static func endsStartupTransaction(_ state: PluginSessionTransactionState) -> Bool { + state != .idle + } + + // Every identity, the entry point's and each unit's, is the routine's oid. + static func oid(inIdentity identity: String) -> UInt32? { + UInt32(identity) + } + + // MARK: - Catalog + + static func routineQuery(oid: UInt32, capabilities: PostgreSQLCapabilities) -> String { + let kindColumn = capabilities.hasProcedureKind + ? "p.prokind" + : "CASE WHEN p.proisagg THEN 'a' WHEN p.proiswindow THEN 'w' ELSE 'f' END" + let argumentTypes = "COALESCE(p.proallargtypes, p.proargtypes::pg_catalog.oid[])" + // pg_get_function_arg_default (9.4) counts arguments from 1; proargtypes as an array starts at 0. + let defaultsColumn = capabilities.serverVersion >= 90_400 + ? """ + ARRAY( + SELECT pg_catalog.pg_get_function_arg_default( + p.oid, + (s.i - pg_catalog.array_lower(\(argumentTypes), 1) + 1)::pg_catalog.int4 + ) + FROM pg_catalog.generate_subscripts(\(argumentTypes), 1) AS s(i) + ORDER BY s.i + ) + """ + : "NULL::pg_catalog.text[]" + return """ + SELECT + pg_catalog.current_database(), + (SELECT en.nspname FROM pg_catalog.pg_extension e + JOIN pg_catalog.pg_namespace en ON en.oid = e.extnamespace + WHERE e.extname = 'pldbgapi'), + EXISTS (SELECT 1 FROM pg_catalog.pg_available_extensions a WHERE a.name = 'pldbgapi'), + p.oid IS NOT NULL, + COALESCE(p.proowner = r.oid OR r.rolsuper, false), + \(kindColumn), + p.provolatile, + l.lanname, + n.nspname, + p.proname, + pg_catalog.pg_get_function_identity_arguments(p.oid), + p.prorettype = 'pg_catalog.record'::pg_catalog.regtype, + \(returnsTriggerTest), + ARRAY( + SELECT pg_catalog.format_type((\(argumentTypes))[s.i], NULL) + FROM pg_catalog.generate_subscripts(\(argumentTypes), 1) AS s(i) + ORDER BY s.i + ), + p.proargnames, + p.proargmodes::pg_catalog.text[], + p.pronargdefaults, + \(pseudoTypeArgumentTest), + p.proconfig, + ARRAY( + SELECT '"' || pg_catalog.replace(tn.nspname, '"', '""') || '"."' + || pg_catalog.replace(ty.typname, '"', '""') || '"' + FROM pg_catalog.generate_subscripts(\(argumentTypes), 1) AS s(i) + JOIN pg_catalog.pg_type ty ON ty.oid = (\(argumentTypes))[s.i] + JOIN pg_catalog.pg_namespace tn ON tn.oid = ty.typnamespace + ORDER BY s.i + ), + \(defaultsColumn) + FROM (SELECT 1) AS one + LEFT JOIN pg_catalog.pg_proc p ON p.oid = \(oidLiteral(oid)) + LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + LEFT JOIN pg_catalog.pg_language l ON l.oid = p.prolang + LEFT JOIN pg_catalog.pg_roles r ON r.rolname = CURRENT_USER + """ + } + + static func targetListQuery(schema: String, hasProcedureKind: Bool) -> String { + let kindColumn = hasProcedureKind ? "p.prokind" : "'f'" + let kindFilter = hasProcedureKind ? "p.prokind IN ('f', 'p')" : "NOT p.proisagg AND NOT p.proiswindow" + return """ + SELECT + p.oid::pg_catalog.text, + p.proname, + n.nspname, + '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')', + \(kindColumn) + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_language l ON l.oid = p.prolang + WHERE n.nspname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + AND l.lanname = 'plpgsql' + AND \(kindFilter) + AND NOT \(returnsTriggerTest) + AND NOT \(pseudoTypeArgumentTest) + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_depend d + WHERE d.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass AND d.objid = p.oid AND d.deptype = 'e' + ) + ORDER BY p.proname, 4 + """ + } + + // proargtypes holds the arguments a call passes; a pseudo-type there (anyelement, record) cannot be bound. + // Named, never cast: event_trigger arrived in 9.3, and a cast to a type the server lacks fails the whole query. + private static let returnsTriggerTest = """ + EXISTS ( + SELECT 1 FROM pg_catalog.pg_type rt + JOIN pg_catalog.pg_namespace rn ON rn.oid = rt.typnamespace + WHERE rt.oid = p.prorettype AND rn.nspname = 'pg_catalog' AND rt.typname IN ('trigger', 'event_trigger') + ) + """ + + private static let pseudoTypeArgumentTest = """ + EXISTS ( + SELECT 1 FROM pg_catalog.unnest(p.proargtypes::pg_catalog.oid[]) AS a(t) + JOIN pg_catalog.pg_type ty ON ty.oid = a.t + WHERE ty.typtype = 'p' + ) + """ + + // The owner may signal and see its own backends, so no superuser or pg_signal_backend is needed. + static func cancelBackendStatement(process: Int) -> String { + "SELECT pg_catalog.pg_cancel_backend(\(process))" + } + + static func backendCountStatement(process: Int) -> String { + "SELECT pg_catalog.count(*) FROM pg_catalog.pg_stat_activity WHERE pid = \(process)" + } + + static func routineSourceStatement(oid: UInt32) -> String { + "SELECT p.prosrc FROM pg_catalog.pg_proc p WHERE p.oid = \(oidLiteral(oid))" + } + + // MARK: - pldbgapi calls + + static func oidDebugStatement(extensionSchema: String, oid: UInt32) -> String { + "SELECT \(function("pldbg_oid_debug", in: extensionSchema))(\(oidLiteral(oid)))" + } + + // The argument is the target's backend number from its PLDBGBREAK notice, not a TCP port. + static func attachStatement(extensionSchema: String, port: Int) -> String { + "SELECT \(function("pldbg_attach_to_port", in: extensionSchema))(\(port))" + } + + static func waitStatement(extensionSchema: String, session: Int) -> String { + breakpointRead("pldbg_wait_for_breakpoint", extensionSchema, "\(session)") + } + + static func stepStatement(_ command: PluginDebugCommand, extensionSchema: String, session: Int) -> String? { + let name: String + switch command { + case .continueExecution: + name = "pldbg_continue" + case .stepOver: + name = "pldbg_step_over" + case .stepInto: + name = "pldbg_step_into" + case .stepOut: + return nil + @unknown default: + return nil + } + return breakpointRead(name, extensionSchema, "\(session)") + } + + static func selectFrameStatement(extensionSchema: String, session: Int, level: Int) -> String { + breakpointRead("pldbg_select_frame", extensionSchema, "\(session), \(level)") + } + + static func sourceStatement(extensionSchema: String, session: Int, oid: UInt32) -> String { + "SELECT \(function("pldbg_get_source", in: extensionSchema))(\(session), \(oidLiteral(oid)))" + } + + static func stackStatement(extensionSchema: String, session: Int) -> String { + "SELECT level, targetname, func, linenumber, args FROM \(function("pldbg_get_stack", in: extensionSchema))(\(session))" + } + + static func variablesStatement(extensionSchema: String, session: Int) -> String { + """ + SELECT name, varclass, linenumber, isunique, isconst, isnotnull, pg_catalog.format_type(dtype, NULL), value \ + FROM \(function("pldbg_get_variables", in: extensionSchema))(\(session)) + """ + } + + static func setBreakpointStatement(extensionSchema: String, session: Int, oid: UInt32, line: Int) -> String { + "SELECT \(function("pldbg_set_breakpoint", in: extensionSchema))(\(session), \(oidLiteral(oid)), \(line))" + } + + static func dropBreakpointStatement(extensionSchema: String, session: Int, oid: UInt32, line: Int) -> String { + "SELECT \(function("pldbg_drop_breakpoint", in: extensionSchema))(\(session), \(oidLiteral(oid)), \(line))" + } + + static func abortStatement(extensionSchema: String, session: Int) -> String { + "SELECT * FROM \(function("pldbg_abort_target", in: extensionSchema))(\(session))" + } + + private static func breakpointRead(_ name: String, _ extensionSchema: String, _ arguments: String) -> String { + "SELECT func, linenumber, targetname FROM \(function(name, in: extensionSchema))(\(arguments))" + } + + // pldbgapi is relocatable, so every call names the schema it was created in. + private static func function(_ name: String, in extensionSchema: String) -> String { + "\(PostgreSQLObjectQueries.quoteIdentifier(extensionSchema)).\(name)" + } + + static func oidLiteral(_ oid: UInt32) -> String { + "\(PostgreSQLObjectQueries.quoteLiteral(String(oid)))::pg_catalog.oid" + } + + // MARK: - Replies + + static func debuggerPort(inNotice message: String) -> Int? { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix(breakNoticePrefix) else { return nil } + return Int(trimmed.dropFirst(breakNoticePrefix.count)) + } + + // The stop pldebugger reports is one past the statement's line (plpgsql_debugger.c sends lineno + 1); + // stack frames and breakpoints use the line itself. + static func location(fromBreakpointRow row: [String?]) -> PluginDebugLocation? { + guard let unit = cell(row, 0), !unit.isEmpty, + let reported = cell(row, 1).flatMap({ Int($0) }) + else { return nil } + return PluginDebugLocation( + unit: PluginDebugUnit(identity: unit, displayName: cell(row, 2) ?? unit), + line: max(reported - 1, 1) + ) + } + + static func frame(fromStackRow row: [String?]) -> PluginDebugFrame? { + guard let level = cell(row, 0).flatMap({ Int($0) }) else { return nil } + var location: PluginDebugLocation? + if let unit = cell(row, 2), let line = cell(row, 3).flatMap({ Int($0) }) { + location = PluginDebugLocation( + unit: PluginDebugUnit(identity: unit, displayName: cell(row, 1) ?? unit), + line: line + ) + } + return PluginDebugFrame(level: level, location: location, arguments: cell(row, 4)) + } + + static func variable(fromRow row: [String?]) -> PluginDebugVariable? { + guard let name = cell(row, 0) else { return nil } + let value = cell(row, 7) + return PluginDebugVariable( + name: name, + kind: variableKind(cell(row, 1)), + typeName: cell(row, 6), + // pldebugger sends SQL NULL as the text NULL, so it cannot be told from the string 'NULL'. + value: value == "NULL" ? nil : value, + isConstant: PostgreSQLCatalogBoolean.isTrue(cell(row, 4)) + ) + } + + static func variableKind(_ code: String?) -> PluginDebugVariableKind { + switch code { + case "A": return .argument + case "P": return .package + default: return .local + } + } + + private static func cell(_ row: [String?], _ index: Int) -> String? { + row.indices.contains(index) ? row[index] : nil + } + + // MARK: - Targets + + static func target(for routine: PluginRoutineInfo) -> PluginDebugTarget? { + guard routine.language?.lowercased() == "plpgsql", + let identity = routine.identity, UInt32(identity) != nil, + !triggerReturnTypes.contains(routine.returnType?.lowercased() ?? ""), + !signatureTakesPseudoType(routine.argumentSignature) + else { return nil } + return PluginDebugTarget( + identity: identity, + name: routine.name + (routine.argumentSignature ?? ""), + schema: routine.schema, + kindDisplayName: kindDisplayName(isProcedure: routine.kind == .procedure) + ) + } + + // The signature is pg_get_function_identity_arguments in parentheses: "(IN a integer, b anyelement)". A type is + // the last word of its argument, so a parameter named like a type does not count. + static func signatureTakesPseudoType(_ signature: String?) -> Bool { + guard let signature else { return false } + var body = Substring(signature.trimmingCharacters(in: .whitespaces)) + if body.hasPrefix("("), body.hasSuffix(")") { + body = body.dropFirst().dropLast() + } + return quoteAwareSplit(body, separator: ",").contains { argument in + guard let type = quoteAwareSplit(argument, separator: " ").last(where: { !$0.isEmpty }) else { return false } + return pseudoArgumentTypes.contains(String(type)) + } + } + + private static func quoteAwareSplit(_ text: Substring, separator: Character) -> [Substring] { + var parts: [Substring] = [] + var start = text.startIndex + var quoted = false + for index in text.indices { + if text[index] == "\"" { + quoted.toggle() + } else if text[index] == separator, !quoted { + parts.append(text[start.. PluginDebugTarget? { + guard let identity = cell(row, 0), UInt32(identity) != nil, let name = cell(row, 1) else { return nil } + return PluginDebugTarget( + identity: identity, + name: name + (cell(row, 3) ?? ""), + schema: cell(row, 2), + kindDisplayName: kindDisplayName(isProcedure: cell(row, 4) == "p") + ) + } + + private static func kindDisplayName(isProcedure: Bool) -> String { + isProcedure ? String(localized: "Procedure") : String(localized: "Function") + } + + // MARK: - Parameters and the call + + static func parameters(types: [String], names: [String?], modes: [String], defaultCount: Int) -> [PluginDebugParameter] { + let resolvedModes = types.indices.map { parameterMode(modes.indices.contains($0) ? modes[$0] : "i") } + let inputs = types.indices.filter { resolvedModes[$0] != .output } + let defaulted = Set(inputs.suffix(max(defaultCount, 0))) + return types.indices.map { index in + let declared = names.indices.contains(index) ? names[index] ?? "" : "" + return PluginDebugParameter( + name: declared.isEmpty ? "$\(index + 1)" : declared, + typeName: types[index], + mode: resolvedModes[index], + hasDefault: defaulted.contains(index) + ) + } + } + + static func parameterMode(_ code: String) -> PluginDebugParameterMode { + switch code { + case "o", "t": return .output + case "b": return .inputOutput + case "v": return .variadic + default: return .input + } + } + + static func call(for routine: PostgreSQLDebugRoutine, arguments: [PluginDebugArgument]) throws -> PostgreSQLDebugCall { + var values: [String: PluginDebugArgumentValue] = [:] + for argument in arguments { + values[argument.parameterName] = argument.value + } + var expressions: [String] = [] + var bound: [PluginCellValue] = [] + for (index, parameter) in routine.parameters.enumerated() { + let castType = routine.argumentCastTypes.indices.contains(index) + ? routine.argumentCastTypes[index] + : parameter.typeName + var expression: String + if parameter.mode == .output { + // CALL takes a placeholder for each OUT parameter; a function call takes none. + guard routine.isProcedure else { continue } + expression = "NULL::\(castType)" + } else { + switch values[parameter.name] ?? (parameter.hasDefault ? .defaultValue : .null) { + case .text(let text): + bound.append(.text(text)) + expression = "$\(bound.count)::\(castType)" + case .defaultValue: + expression = "(\(try defaultExpression(of: parameter, at: index, in: routine)))::\(castType)" + case .null: + bound.append(.null) + expression = "$\(bound.count)::\(castType)" + @unknown default: + bound.append(.null) + expression = "$\(bound.count)::\(castType)" + } + } + if parameter.mode == .variadic { + expression = "VARIADIC " + expression + } + expressions.append(expression) + } + + let routineName = PostgreSQLObjectQueries.qualifiedName(schema: routine.schema, name: routine.name) + let argumentList = expressions.joined(separator: ", ") + let sql: String + if routine.isProcedure { + sql = "CALL \(routineName)(\(argumentList))" + } else if routine.returnsBareRecord { + // SELECT * FROM a function returning a bare record needs a column definition list. + sql = "SELECT \(routineName)(\(argumentList))" + } else { + sql = "SELECT * FROM \(routineName)(\(argumentList))" + } + return PostgreSQLDebugCall(sql: sql, parameters: bound) + } + + // Never left out: overloads that differ only in defaulted parameters make f() ambiguous although the selected + // oid is known. The routine's own default expression, cast to the exact type, names that overload and is + // evaluated in the call just as an omitted argument's would be. + private static func defaultExpression( + of parameter: PluginDebugParameter, + at index: Int, + in routine: PostgreSQLDebugRoutine + ) throws -> String { + guard parameter.hasDefault else { + throw PostgreSQLRoutineDebugError.noDefault(parameter: parameter.name) + } + guard routine.defaultExpressions.indices.contains(index), let expression = routine.defaultExpressions[index] else { + throw PostgreSQLRoutineDebugError.defaultNotReadable(parameter: parameter.name) + } + return expression + } + + // MARK: - Starting + + static func startCancellation(portDelivered: Bool, callEnded: Bool) -> PostgreSQLDebugStartCancellation { + if portDelivered { return .attachAndAbort } + return callEnded ? .nothing : .cancelCall + } + + static func startStep(port: Int?, isCancelled: Bool) -> PostgreSQLDebugStartStep { + guard let port else { return isCancelled ? .cancelled : .endedBeforeEntry } + return isCancelled ? .attachAndAbort(port: port) : .attach(port: port) + } + + // MARK: - Outcomes + + static func startFailure(_ error: Error, database: String, routineName: String) -> Error { + guard let failure = error as? any PluginDriverError else { return error } + let state = failure.pluginSqlState + let message = failure.pluginErrorMessage + if isSharedMemoryFailure(sqlState: state, message: message) { + return PostgreSQLRoutineDebugError.sharedMemoryUnavailable + } + if state == "42883", message.contains("pldbg_") { + return PostgreSQLRoutineDebugError.extensionMissing(database: database) + } + if state == "42501", message.contains("must be owner or superuser") { + return PostgreSQLRoutineDebugError.notOwner(routine: routineName) + } + return error + } + + // Without the preload, pldebugger takes its slots from spare shared memory (dbgcomm.c) and can run out. + private static func isSharedMemoryFailure(sqlState: String?, message: String) -> Bool { + message.contains("out of shared memory") + || message.contains("Debugger Connection slots") + || (sqlState == "53200" && message.contains("shared memory")) + } + + // pldbgapi raises 08006 in two cases: "debugger connection terminated" when the target left the debugger, + // finished or not, and "select() failed waiting for target" when a signal reached the waiting proxy. + static func proxyFailure(_ error: Error) -> PostgreSQLDebugProxyFailure { + guard let failure = error as? LibPQPluginError, failure.sqlState == targetEndedState else { return .broken } + return failure.message.contains("select() failed") ? .interrupted : .targetEnded + } + + static func end( + of outcome: Result, + stopRequested: Bool, + interrupted: Bool = false + ) -> PluginDebugStop { + let ownMessage = interrupted ? interruptedMessage : stoppedMessage + switch outcome { + case .success(let result): + return .finished(result.columns.isEmpty ? nil : result) + case .failure(let error): + if error is CancellationError { + return .failed(message: ownMessage, detail: nil) + } + let failure = error as? any PluginDriverError + let parts = serverMessage(failure?.pluginErrorMessage ?? error.localizedDescription) + if stopRequested || interrupted, failure?.pluginSqlState == queryCanceledState { + return .failed(message: ownMessage, detail: parts.detail) + } + return .failed(message: parts.message, detail: parts.detail) + } + } + + // A routine's own SET overrides the session's on entry (measured): a stricter client_min_messages hides the + // PLDBGBREAK notice, so the start never sees the target, and a transaction_timeout ends a target that waits + // at a breakpoint. + static func blockingSetting(in configuration: [String]) -> PostgreSQLRoutineDebugError? { + for entry in configuration { + guard let separator = entry.firstIndex(of: "=") else { continue } + let name = entry[.. = ["warning", "error", "fatal", "panic"] + + private static func isZeroDuration(_ value: String) -> Bool { + let digits = value.prefix { $0.isNumber } + guard !digits.isEmpty, digits.allSatisfy({ $0 == "0" }) else { return false } + return ["", "us", "ms", "s", "min", "h", "d"].contains(value.dropFirst(digits.count).lowercased()) + } + + // libpq formats an error as "SEVERITY: message" followed by DETAIL, HINT and CONTEXT lines. + static func serverMessage(_ text: String) -> (message: String, detail: String?) { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard let first = lines.first else { return (text, nil) } + let detail = lines.dropFirst().joined(separator: "\n") + return (withoutSeverity(first), detail.isEmpty ? nil : detail) + } + + private static func withoutSeverity(_ line: String) -> String { + guard let separator = line.range(of: ": ") else { return line } + let label = line[.. + + private let target: PostgreSQLPluginDriver + private let proxy: PostgreSQLPluginDriver + private let sessions: any PluginDebugSessionSource + private let routine: PostgreSQLDebugRoutine + private let extensionSchema: String + private let targetProcess: Int? + private let initialBreakpoints: [PluginDebugBreakpoint] + private let outputContinuation: AsyncStream.Continuation + private let rendezvous = PostgreSQLDebugRendezvous() + // Settled the moment stop() is asked, so a read or breakpoint call it overlaps returns at once, and on every + // other way the run ends, so no wait on it outlives the session. + private let stopSignal = PostgreSQLDebugRendezvous() + + private var phase = Phase.starting + private var hasReportedFirstStop = false + private var lastStop: PluginDebugLocation? + private var selectedFrame = 0 + private var port: Int? + private var handle: Int? + private var targetCall: Task, Never>? + private var terminalStop: PluginDebugStop? + private var stopRequested = false + private var stopTask: Task? + private var stopDeadline: ContinuousClock.Instant? + private var abortSent = false + // The target read an abort: through the proxy, or through a session that attached to it. + private var abortDelivered = false + // Another session saw the target's backend gone from pg_stat_activity. + private var backendGone = false + private var proxyInterrupted = false + private var endedByInterruption = false + + init( + target: PostgreSQLPluginDriver, + proxy: PostgreSQLPluginDriver, + sessions: any PluginDebugSessionSource, + routine: PostgreSQLDebugRoutine, + extensionSchema: String, + targetProcess: Int?, + breakpoints: [PluginDebugBreakpoint] + ) { + self.target = target + self.proxy = proxy + self.sessions = sessions + self.routine = routine + self.extensionSchema = extensionSchema + self.targetProcess = targetProcess + self.initialBreakpoints = breakpoints + let (stream, continuation) = AsyncStream.makeStream() + output = stream + outputContinuation = continuation + } + + // Sends the call on the target, waits for the PLDBGBREAK notice it raises on entering the routine, and + // attaches the proxy to the backend number that notice names. + func start(_ call: PostgreSQLDebugCall) async throws { + let rendezvous = rendezvous + let continuation = outputContinuation + let target = target + target.core.observeServerNotices { notice in + if let port = PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: notice.message) { + rendezvous.deliver(port: port) + } else { + continuation.yield(notice.text) + } + } + targetCall = Task { + let outcome: Result + do { + outcome = .success(try await target.executeParameterized(query: call.sql, parameters: call.parameters)) + } catch { + outcome = .failure(error) + } + target.core.observeServerNotices(nil) + continuation.finish() + rendezvous.settle() + return outcome + } + + let delivered = await withTaskCancellationHandler { + await rendezvous.port() + } onCancel: { + Self.cancelUntilEntered(target: target, rendezvous: rendezvous, until: .now + Self.stopBudget) + } + switch PostgreSQLRoutineDebugProtocol.startStep(port: delivered, isCancelled: Task.isCancelled || stopRequested) { + case .endedBeforeEntry: + try await endBeforeEntry() + case .cancelled: + phase = .ended + stopSignal.settle() + if rendezvous.isAbandoned { + throw PostgreSQLRoutineDebugError.targetAbandoned(process: targetProcess, port: nil) + } + _ = await targetCall?.value + throw CancellationError() + case .attachAndAbort(let announced): + port = announced + throw await stopForStart(otherwise: CancellationError()) + case .attach(let announced): + port = announced + do { + handle = try await Self.attach(using: proxy, port: announced, extensionSchema: extensionSchema) + phase = .paused + } catch { + phase = .broken + Self.logger.error("Attaching to the debug target failed: \(error.localizedDescription, privacy: .private)") + throw await stopForStart(otherwise: PostgreSQLRoutineDebugProtocol.startFailure( + error, + database: routine.database, + routineName: routine.displayName + )) + } + // The attach does not observe cancellation, so a start cancelled meanwhile is ended here, as Stop would. + if Task.isCancelled { + throw await stopForStart(otherwise: CancellationError()) + } + } + } + + // A backend that may still hold its locks matters more than why the start failed. + private func stopForStart(otherwise error: Error) async -> Error { + guard case .unconfirmed = await stop() else { return error } + return PostgreSQLRoutineDebugError.targetAbandoned(process: targetProcess, port: port) + } + + // A cancel reaches only a call libpq has already sent, so it is repeated until the call ends or the target + // announces its port; from then on the target ignores a cancel and the attach and abort end it. A target that + // neither ends nor announces itself by the deadline is given up, so nothing waits on it forever. + private static func cancelUntilEntered( + target: PostgreSQLPluginDriver, + rendezvous: PostgreSQLDebugRendezvous, + until deadline: ContinuousClock.Instant + ) { + Task.detached { + while PostgreSQLRoutineDebugProtocol.startCancellation( + portDelivered: rendezvous.hasPort, + callEnded: rendezvous.hasSettled + ) == .cancelCall { + let remaining = ContinuousClock.now.duration(to: deadline) + guard remaining > .zero else { + logger.fault("The debug target neither ended nor reached the routine before the stop deadline") + rendezvous.abandon() + return + } + target.core.cancelQuery() + try? await Task.sleep(for: min(cancelRetryDelay, remaining)) + } + } + } + + // The call ended before the routine was entered: its arguments were refused, the debugger could not start, + // or the start was cancelled. + private func endBeforeEntry() async throws { + let outcome = await targetCall?.value ?? .failure(CancellationError()) + phase = .ended + stopSignal.settle() + hasReportedFirstStop = true + if case .failure(let error) = outcome { + if error is CancellationError { throw CancellationError() } + throw PostgreSQLRoutineDebugProtocol.startFailure( + error, + database: routine.database, + routineName: routine.displayName + ) + } + terminalStop = PostgreSQLRoutineDebugProtocol.end(of: outcome, stopRequested: false) + } + + func firstStop() async throws -> PluginDebugStop { + if let terminalStop { return terminalStop } + if stopRequested { return await outcomeAfterStop() } + if hasReportedFirstStop, phase == .paused, let lastStop { return .paused(lastStop) } + guard phase == .paused, let handle else { throw PostgreSQLRoutineDebugError.notPaused } + return try await advance( + PostgreSQLRoutineDebugProtocol.waitStatement(extensionSchema: extensionSchema, session: handle), + placing: initialBreakpoints + ) + } + + func perform(_ command: PluginDebugCommand) async throws -> PluginDebugStop { + if let terminalStop { return terminalStop } + if stopRequested { return await outcomeAfterStop() } + guard phase == .paused, hasReportedFirstStop, let handle else { throw PostgreSQLRoutineDebugError.notPaused } + guard let statement = PostgreSQLRoutineDebugProtocol.stepStatement( + command, + extensionSchema: extensionSchema, + session: handle + ) else { + throw PostgreSQLRoutineDebugError.unsupportedCommand + } + return try await advance(statement) + } + + private func advance( + _ statement: String, + placing breakpoints: [PluginDebugBreakpoint] = [] + ) async throws -> PluginDebugStop { + phase = .running + selectedFrame = 0 + let reply: PluginQueryResult + // The target keeps its debugger socket for the rest of its session, so when the routine returns without + // pausing again the proxy only hears of it once that session closes (measured: 8 s later, on disconnect). + // The target's own call ending is the signal instead. + let proxy = proxy + guard let answered = await rendezvous.reply(to: Task { try await proxy.execute(query: statement) }) else { + return await targetEnded() + } + do { + reply = try answered.get() + } catch { + switch PostgreSQLRoutineDebugProtocol.proxyFailure(error) { + case .targetEnded: + return await targetEnded() + case .interrupted: + // The target goes on to its next pause and waits for a command the session can no longer match to + // a reply, so it is ended the way Stop ends it. + noteInterruption() + endedByInterruption = !stopRequested + Self.logger.error("A signal interrupted the debugger session while it waited; ending the target") + await endTarget() + return await targetEnded() + case .broken: + phase = .broken + Self.logger.error("The debugger session failed: \(error.localizedDescription, privacy: .private)") + guard stopRequested else { throw error } + await endTarget() + return await outcomeAfterStop() + } + } + hasReportedFirstStop = true + guard let stop = PostgreSQLRoutineDebugProtocol.location( + fromBreakpointRow: reply.rows.first?.map(\.asText) ?? [] + ) else { + return await targetEnded() + } + phase = .paused + lastStop = stop + if stopRequested { + // The target paused before the cancel reached it, and a paused target holds a cancel until it resumes. + await endTarget() + return await targetEnded() + } + if let handle { + for breakpoint in breakpoints where !stopRequested { + do { + _ = try await place(breakpoint, session: handle) + } catch where PostgreSQLRoutineDebugProtocol.proxyFailure(error) == .interrupted { + noteInterruption() + endedByInterruption = !stopRequested + await endTarget() + return await targetEnded() + } catch { + continue + } + } + } + if stopRequested { return await outcomeAfterStop() } + return .paused(stop) + } + + // MARK: - Reading the paused target + + func source(of unit: PluginDebugUnit) async throws -> String { + guard let oid = PostgreSQLRoutineDebugProtocol.oid(inIdentity: unit.identity) else { + throw PostgreSQLRoutineDebugError.routineNotFound(unit.displayName) + } + guard !stopRequested else { throw CancellationError() } + switch phase { + case .starting, .running: + throw PostgreSQLRoutineDebugError.notPaused + case .broken: + throw PostgreSQLRoutineDebugError.unavailable + case .paused where hasReportedFirstStop: + let schema = extensionSchema + let reply = try await pausedRead { + PostgreSQLRoutineDebugProtocol.sourceStatement(extensionSchema: schema, session: $0, oid: oid) + } + if let text = reply?.rows.first?.first?.asText { return text } + case .paused: + break + case .ended: + // The proxy may still be waiting on a target that returned; the target's own session is free again. + guard !rendezvous.isAbandoned else { throw PostgreSQLRoutineDebugError.unavailable } + let reply = try await target.execute(query: PostgreSQLRoutineDebugProtocol.routineSourceStatement(oid: oid)) + guard let text = reply.rows.first?.first?.asText else { + throw PostgreSQLRoutineDebugError.routineNotFound(unit.displayName) + } + return text + } + // pldbg_get_source sends prosrc, which the catalog answers too while the proxy is idle. + let reply = try await proxyCall(PostgreSQLRoutineDebugProtocol.routineSourceStatement(oid: oid)) + guard let text = reply.rows.first?.first?.asText else { + throw PostgreSQLRoutineDebugError.routineNotFound(unit.displayName) + } + return text + } + + func stack() async throws -> [PluginDebugFrame] { + let schema = extensionSchema + guard let reply = try await pausedRead({ + PostgreSQLRoutineDebugProtocol.stackStatement(extensionSchema: schema, session: $0) + }) else { return [] } + return reply.rows.compactMap { PostgreSQLRoutineDebugProtocol.frame(fromStackRow: $0.map(\.asText)) } + } + + func variables(inFrame level: Int) async throws -> [PluginDebugVariable] { + let schema = extensionSchema + if level != selectedFrame { + guard try await pausedRead({ + PostgreSQLRoutineDebugProtocol.selectFrameStatement(extensionSchema: schema, session: $0, level: level) + }) != nil else { return [] } + selectedFrame = level + } + guard let reply = try await pausedRead({ + PostgreSQLRoutineDebugProtocol.variablesStatement(extensionSchema: schema, session: $0) + }) else { return [] } + return reply.rows.compactMap { PostgreSQLRoutineDebugProtocol.variable(fromRow: $0.map(\.asText)) } + } + + func setBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws -> PluginDebugBreakpoint? { + guard let handle = try pausedSession() else { return nil } + do { + return try await place(breakpoint, session: handle) + } catch is CancellationError { + throw CancellationError() + } catch where PostgreSQLRoutineDebugProtocol.proxyFailure(error) == .targetEnded { + return nil + } catch { + if PostgreSQLRoutineDebugProtocol.proxyFailure(error) == .interrupted { noteInterruption() } + throw error + } + } + + func removeBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws { + guard let oid = PostgreSQLRoutineDebugProtocol.oid(inIdentity: breakpoint.unitIdentity) else { return } + let schema = extensionSchema + _ = try await pausedRead { + PostgreSQLRoutineDebugProtocol.dropBreakpointStatement( + extensionSchema: schema, + session: $0, + oid: oid, + line: breakpoint.line + ) + } + } + + // pldbg_set_breakpoint accepts any line and answers t; a line with no statement never stops. + private func place(_ breakpoint: PluginDebugBreakpoint, session: Int) async throws -> PluginDebugBreakpoint? { + guard let oid = PostgreSQLRoutineDebugProtocol.oid(inIdentity: breakpoint.unitIdentity), breakpoint.line > 0 else { + return nil + } + let reply = try await proxyCall(PostgreSQLRoutineDebugProtocol.setBreakpointStatement( + extensionSchema: extensionSchema, + session: session, + oid: oid, + line: breakpoint.line + )) + return PostgreSQLCatalogBoolean.isTrue(reply.rows.first?.first?.asText) ? breakpoint : nil + } + + // A read or breakpoint edit that stop() overlaps throws at once. Its statement still finishes on the proxy + // first, and the abort queued behind it reaches the target after it: libpq runs one statement at a time per + // connection, and cancelling the read gains nothing (measured: the cancel lands only once the read's reply has + // crossed, while it can leave part of that reply unread). + private func proxyCall(_ statement: String) async throws -> PluginQueryResult { + let proxy = proxy + guard let answered = await stopSignal.reply(to: Task { try await proxy.execute(query: statement) }) else { + throw CancellationError() + } + return try answered.get() + } + + private func pausedRead(_ statement: (Int) -> String) async throws -> PluginQueryResult? { + guard let handle = try pausedSession() else { return nil } + do { + return try await proxyCall(statement(handle)) + } catch is CancellationError { + throw CancellationError() + } catch { + switch PostgreSQLRoutineDebugProtocol.proxyFailure(error) { + case .targetEnded: + return nil + case .interrupted: + noteInterruption() + throw error + case .broken: + throw error + } + } + } + + // The interrupted call's reply stays unread on the proxy, so no later call could be matched to its own reply. + private func noteInterruption() { + phase = .broken + proxyInterrupted = true + } + + // pldbgapi answers only while the target waits for a command; the session is nil once the target is gone. + private func pausedSession() throws -> Int? { + if stopRequested { return nil } + switch phase { + case .paused where hasReportedFirstStop: + return handle + case .ended, .broken: + return nil + case .starting, .running, .paused: + throw PostgreSQLRoutineDebugError.notPaused + } + } + + // MARK: - Stop + + func stop() async -> PluginDebugStopOutcome { + if stopTask == nil { + stopRequested = true + stopSignal.settle() + if stopDeadline == nil { stopDeadline = .now + Self.stopBudget } + stopTask = Task { await performStop() } + } + await stopTask?.value + // A call that failed on its own connection ended only on this side; the backend may still hold its locks. + let callEnded = rendezvous.hasSettled && (abortDelivered || backendGone || !target.hasLostConnection) + return PostgreSQLRoutineDebugProtocol.stopOutcome( + callEnded: callEnded, + abandoned: rendezvous.isAbandoned, + process: targetProcess, + port: port + ) + } + + private func performStop() async { + switch phase { + case .starting: + if port == nil { + let deadline = currentDeadline() + Self.cancelUntilEntered(target: target, rendezvous: rendezvous, until: deadline) + let rendezvous = rendezvous + port = await Self.bounded(by: deadline) { await rendezvous.port() } ?? nil + } + if port != nil { + await endTarget() + } else { + await awaitTargetEnd() + } + case .paused, .broken: + await endTarget() + case .running: + // Never the proxy: cancelling a waiting proxy call desynchronizes the session. A target that paused + // before this cancel arrived holds it, and the step in flight aborts the target instead. + target.core.cancelQueryKeepingReply() + await awaitTargetEnd() + case .ended: + break + } + if target.hasLostConnection, !abortDelivered { + await endBackendWithoutItsConnection() + } + _ = await targetEnded() + } + + // A call whose own connection dropped ended only on this side: its backend may still run a statement, or wait at + // a pause for the proxy, which still holds it. The proxy's abort reaches it paused, a cancel from another session + // reaches it running, and only its absence from pg_stat_activity confirms the end. + private func endBackendWithoutItsConnection() async { + Task { await self.abortTarget() } + guard let process = targetProcess else { return } + let sessions = sessions + let deadline = currentDeadline() + guard case .success(let rescue)? = await Self.boundedResult(by: deadline, { + try await PostgreSQLPluginDriver.debugSession(from: sessions) + }) else { return } + _ = await Self.boundedResult(by: deadline) { + try await rescue.execute(query: PostgreSQLRoutineDebugProtocol.cancelBackendStatement(process: process)) + } + while !abortDelivered { + let counted = await Self.boundedResult(by: deadline) { + try await rescue.execute(query: PostgreSQLRoutineDebugProtocol.backendCountStatement(process: process)) + } + guard case .success(let reply)? = counted else { return } + if reply.rows.first?.first?.asText == "0" { + backendGone = true + return + } + let remaining = ContinuousClock.now.duration(to: deadline) + guard remaining > .zero else { return } + try? await Task.sleep(for: min(Self.attachRetryDelay, remaining)) + } + } + + private func currentDeadline() -> ContinuousClock.Instant { + if let stopDeadline { return stopDeadline } + let fresh = ContinuousClock.now + Self.stopBudget + stopDeadline = fresh + return fresh + } + + // The abort is sent, not waited on: an abort to a target that already returned waits on a socket that stays + // open for the target's session. The target's call ending is what counts. Without a working proxy the target may + // still be running a statement, which only a cancel ends before it pauses and waits in accept() for good; a + // paused target holds the cancel and a waiting one ignores it. + private func endTarget() async { + if proxyInterrupted || phase == .broken { target.core.cancelQueryKeepingReply() } + Task { await self.abortTarget() } + await awaitTargetEnd() + } + + // Past the deadline the target is given up rather than waited on: the host closes the drivers once stop() + // returns, and the answer then says the backend may still hold its locks. + private func awaitTargetEnd() async { + let rendezvous = rendezvous + guard await Self.bounded(by: currentDeadline(), { await rendezvous.settled() }) == nil, !rendezvous.hasSettled else { + return + } + Self.logger.fault("The debug target was not confirmed ended before the stop deadline") + rendezvous.abandon() + } + + private func abortTarget() async { + guard !abortSent else { return } + abortSent = true + // After an interruption the same proxy still holds the target, which reads the abort at its next pause. + if let handle, phase != .broken || proxyInterrupted { + let proxy = proxy + let statement = PostgreSQLRoutineDebugProtocol.abortStatement(extensionSchema: extensionSchema, session: handle) + switch await Self.boundedResult(by: currentDeadline(), { try await proxy.execute(query: statement) }) { + case .success?: + abortDelivered = true + return + case .failure(let error)?: + // An interrupted abort was still written; the target reads it at its next pause while the proxy + // stays connected (measured), and an attach cannot reach a target the proxy still holds. + guard PostgreSQLRoutineDebugProtocol.proxyFailure(error) == .broken else { return } + Self.logger.error("Abort through the debugger session failed: \(error.localizedDescription, privacy: .private)") + case nil: + return + } + } + // With the target's own connection gone its call has already settled, which ends no backend. + await abortByAttaching(reusingProxy: handle == nil && phase != .broken, evenAfterTheCallEnded: target.hasLostConnection) + } + + // A target whose proxy is gone waits in accept() and ignores cancel and terminate. Attaching to its backend + // number and aborting is the only way to end it. The old proxy can hold the target for a moment after its + // client goes, so the attach is retried until the deadline. + private func abortByAttaching(reusingProxy: Bool, evenAfterTheCallEnded: Bool = false) async { + guard let port else { return } + let driver: PostgreSQLPluginDriver + if reusingProxy { + driver = proxy + } else { + let sessions = sessions + switch await Self.boundedResult(by: currentDeadline(), { + try await PostgreSQLPluginDriver.debugSession(from: sessions) + }) { + case .success(let opened)?: + driver = opened + case .failure(let error)?: + Self.logger.error("No session to reach the debug target: \(error.localizedDescription, privacy: .private)") + return + case nil: + Self.logger.error("A session to reach the debug target did not open before the stop deadline") + return + } + } + let schema = extensionSchema + let deadline = currentDeadline() + while !abortDelivered, evenAfterTheCallEnded || !rendezvous.hasSettled { + let ended = await Self.boundedResult(by: deadline) { + let session = try await Self.attach(using: driver, port: port, extensionSchema: schema) + _ = try await driver.execute( + query: PostgreSQLRoutineDebugProtocol.abortStatement(extensionSchema: schema, session: session) + ) + } + switch ended { + case .success?: + abortDelivered = true + return + case nil: + return + case .failure: + let remaining = ContinuousClock.now.duration(to: deadline) + guard remaining > .zero else { return } + try? await Task.sleep(for: min(Self.attachRetryDelay, remaining)) + } + } + } + + private static func attach(using driver: PostgreSQLPluginDriver, port: Int, extensionSchema: String) async throws -> Int { + let reply = try await driver.execute( + query: PostgreSQLRoutineDebugProtocol.attachStatement(extensionSchema: extensionSchema, port: port) + ) + guard let session = reply.rows.first?.first?.asText.flatMap({ Int($0) }) else { + throw PostgreSQLRoutineDebugError.unavailable + } + return session + } + + // The operation keeps running past the deadline; its result is then dropped. + private static func bounded( + by deadline: ContinuousClock.Instant, + _ operation: @escaping @Sendable () async -> T + ) async -> T? { + let resumed = OSAllocatedUnfairLock(initialState: false) + return await withCheckedContinuation { (continuation: CheckedContinuation) in + let resume = { @Sendable (value: T?) in + let first = resumed.withLock { done -> Bool in + defer { done = true } + return !done + } + if first { continuation.resume(returning: value) } + } + Task { resume(await operation()) } + Task { + try? await Task.sleep(until: deadline, clock: .continuous) + resume(nil) + } + } + } + + private static func boundedResult( + by deadline: ContinuousClock.Instant, + _ operation: @escaping @Sendable () async throws -> T + ) async -> Result? { + await bounded(by: deadline) { () async -> Result in + do { + return .success(try await operation()) + } catch { + return .failure(error) + } + } + } + + private func targetEnded() async -> PluginDebugStop { + await rendezvous.settled() + phase = .ended + stopSignal.settle() + if let terminalStop { return terminalStop } + let stop: PluginDebugStop + if rendezvous.isAbandoned { + stop = PostgreSQLRoutineDebugProtocol.abandonedStop(process: targetProcess, port: port) + } else { + let outcome = await targetCall?.value ?? .failure(CancellationError()) + stop = PostgreSQLRoutineDebugProtocol.end( + of: outcome, + stopRequested: stopRequested, + interrupted: endedByInterruption + ) + } + if terminalStop == nil { terminalStop = stop } + return terminalStop ?? stop + } + + private func outcomeAfterStop() async -> PluginDebugStop { + await stopTask?.value + return terminalStop ?? PostgreSQLRoutineDebugProtocol.end(of: .failure(CancellationError()), stopRequested: true) + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift index cffd92b8b9..ed0db78a91 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift @@ -58,7 +58,7 @@ enum PostgreSQLSchemaManagementQueries { ) s LEFT JOIN pg_catalog.pg_roles gte ON gte.oid = (s.acl).grantee LEFT JOIN pg_catalog.pg_roles gtr ON gtr.oid = (s.acl).grantor - ORDER BY 2, 3 + ORDER BY gte.rolname NULLS FIRST, 3 """ } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 8509c276a1..b38f26b455 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -329,16 +329,23 @@ enum PostgreSQLSchemaQueries { /// inherits `C` from its domain and one declared `COLLATE "C"` over `text` report `C` in /// `information_schema`, and only the second declares it. Reads the attribute as `a`, which every /// column read and column DDL builder in this plugin aliases it to. - static let columnCollation = """ + static let columnCollation = declaredCollation(PostgreSQLObjectQueries.collationName("a.attcollation")) + + /// `columnCollation` as the text a column definition appends after its type: ` COLLATE `, or + /// an empty string. + // The keyword sits inside the THEN: KingbaseES in Oracle mode reads `' COLLATE ' || NULL` as ` COLLATE `. + static let columnCollateClause = declaredCollation( + "' COLLATE ' || \(PostgreSQLObjectQueries.collationName("a.attcollation"))", otherwise: "''" + ) + + private static func declaredCollation(_ value: String, otherwise fallback: String? = nil) -> String { + """ CASE WHEN a.attcollation <> 0 AND a.attcollation <> (SELECT ty.typcollation FROM pg_catalog.pg_type ty WHERE ty.oid = a.atttypid) - THEN \(PostgreSQLObjectQueries.collationName("a.attcollation")) + THEN \(value)\(fallback.map { "\n ELSE \($0)" } ?? "") END """ - - /// `columnCollation` as the text a column definition appends after its type: ` COLLATE `, or - /// an empty string. - static let columnCollateClause = "COALESCE(' COLLATE ' || \(columnCollation), '')" + } /// One array over the sequences this column's default reads in the table's own schema, ordered by /// oid so the qualified and relative arrays pair element for element. @@ -431,8 +438,8 @@ enum PostgreSQLSchemaQueries { } static func rebuildColumnsQuery(schema: String, table: String, capabilities: PostgreSQLCapabilities) -> String { - let identityFlag = capabilities.hasIdentityColumns ? "a.attidentity <> ''" : "false" - let generatedFlag = capabilities.hasGeneratedColumns ? "a.attgenerated <> ''" : "false" + let identityFlag = capabilities.hasIdentityColumns ? "a.attidentity IN ('a', 'd')" : "false" + let generatedFlag = capabilities.hasGeneratedColumns ? "a.attgenerated IN ('s', 'v')" : "false" return """ SELECT a.attname, @@ -458,9 +465,10 @@ enum PostgreSQLSchemaQueries { ELSE '' END || """ : "" + // The kinds are named because KingbaseES in Oracle mode reads `''` as NULL, so `= ''` is never true there. let defaultGuard = [ - capabilities.hasIdentityColumns ? "AND a.attidentity = ''" : "", - capabilities.hasGeneratedColumns ? "AND a.attgenerated = ''" : "" + capabilities.hasIdentityColumns ? "AND a.attidentity NOT IN ('a', 'd')" : "", + capabilities.hasGeneratedColumns ? "AND a.attgenerated NOT IN ('s', 'v')" : "" ].filter { !$0.isEmpty }.joined(separator: " ") return """ quote_ident(a.attname) || ' ' || format_type(a.atttypid, a.atttypmod) || \(columnCollateClause) || @@ -523,8 +531,15 @@ enum PostgreSQLSchemaQueries { let includesTableName = table == nil let identityProjection = capabilities.hasIdentityColumns ? "a.attidentity" : "NULL::text" let generatedProjection = capabilities.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" + // Not `c.generation_expression`: KingbaseES's Oracle-mode view compares `attgenerated <> NULL`, so it is always NULL. let generationExpressionProjection = capabilities.hasGeneratedColumns - ? "c.generation_expression" + ? """ + CASE WHEN a.attgenerated IN ('s', 'v') + THEN (SELECT pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) + FROM pg_catalog.pg_attrdef ad + WHERE ad.adrelid = a.attrelid AND ad.adnum = a.attnum) + END + """ : "NULL::text" let attributeJoin = """ diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift index 4ee26ebffe..b3f682d6a6 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift @@ -277,7 +277,11 @@ struct PostgreSQLCatalogSQLPinTests { a.attidentity AS identity_kind, a.attgenerated AS generated_kind, c.udt_schema AS udt_schema, - c.generation_expression AS generation_expression, + CASE WHEN a.attgenerated IN ('s', 'v') + THEN (SELECT pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) + FROM pg_catalog.pg_attrdef ad + WHERE ad.adrelid = a.attrelid AND ad.adnum = a.attnum) + END AS generation_expression, CASE WHEN EXISTS ( SELECT 1 FROM pg_catalog.pg_type dt diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift index 7b21c6c397..cba6ac1f90 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift @@ -51,12 +51,18 @@ struct PostgreSQLColumnDDLQuerySequenceCollationTests { )) } - @Test("The DDL builders' clause is the same collation behind a COLLATE keyword, or nothing") + @Test("The DDL builders' clause reads the same collation once, behind a COLLATE keyword, or nothing") func collateClauseWrapsTheSameCollation() { - #expect( - PostgreSQLSchemaQueries.columnCollateClause - == "COALESCE(' COLLATE ' || \(PostgreSQLSchemaQueries.columnCollation), '')" - ) + let clause = PostgreSQLSchemaQueries.columnCollateClause + let name = PostgreSQLObjectQueries.collationName("a.attcollation") + #expect(clause.contains("a.attcollation <> 0")) + #expect(clause.contains( + "a.attcollation <> (SELECT ty.typcollation FROM pg_catalog.pg_type ty WHERE ty.oid = a.atttypid)" + )) + #expect(clause.contains("THEN ' COLLATE ' || \(name)")) + #expect(clause.contains("ELSE ''")) + #expect(clause.components(separatedBy: name).count - 1 == 1) + #expect(clause.components(separatedBy: "pg_catalog.pg_type ty").count - 1 == 1) } } diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift index 2c5539a140..2d97d2f31c 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift @@ -80,7 +80,9 @@ struct PostgreSQLColumnsQueryTests { let query = singleTable(schema: "s2", table: "orders") #expect(query.contains("a.attidentity")) #expect(query.contains("a.attgenerated")) - #expect(query.contains("c.generation_expression")) + #expect(query.contains("CASE WHEN a.attgenerated IN ('s', 'v')")) + #expect(query.contains("THEN (SELECT pg_catalog.pg_get_expr(ad.adbin, ad.adrelid)")) + #expect(!query.contains("c.generation_expression")) #expect(query.contains("ON a.attrelid = rel.oid")) #expect(query.contains("AND a.attnum = c.ordinal_position")) } @@ -223,11 +225,13 @@ struct PostgreSQLMaterializedViewColumnsQueryTests { } @Test("a materialized view column has no primary key and no default") - func armSkipsConstraintsAndDefaults() { + func armSkipsConstraintsAndDefaults() throws { let rendered = query() + let arm = try #require(rendered.components(separatedBy: "UNION ALL").last) #expect(rendered.contains("'NO' AS is_pk")) #expect(rendered.contains("NULL::text AS column_default")) - #expect(!rendered.contains("pg_attrdef")) + #expect(arm.contains("mvc.relkind = 'm'")) + #expect(!arm.contains("pg_attrdef")) #expect(rendered.components(separatedBy: "FROM pg_catalog.pg_constraint con").count - 1 == 1) } diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift index 9a5556b840..6fe21b80db 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift @@ -173,9 +173,13 @@ struct PostgreSQLIndexDDLQueryTests { @Test("The suffix is the predicate as pg_get_expr writes it, and the keys are cut only when both ends match") func suffixAndCut() throws { let sql = PostgreSQLIndexQueries.indexDDLQuery(schema: "src", table: nil) - #expect(sql.contains("COALESCE(' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid), '') AS suffix")) + #expect(sql.contains("CASE WHEN ix.indpred IS NOT NULL")) + #expect(sql.contains("THEN ' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid)")) + #expect(sql.contains("END AS suffix")) + #expect(!sql.contains("COALESCE(' WHERE '")) #expect(sql.contains("AND pg_catalog.substr(d.definition, 1, pg_catalog.length(d.prefix)) = d.prefix")) - #expect(sql.contains(") = d.suffix")) + #expect(sql.contains("AND (d.suffix IS NULL OR pg_catalog.substr(")) + #expect(sql.contains(") = d.suffix)")) let cutStart = try #require(sql.range(of: "CASE")) let cutEnd = try #require(sql.range(of: "END AS method_and_keys")) let cut = sql[cutStart.upperBound.. PostgreSQLDebugRoutine { + PostgreSQLDebugRoutine(row: row, identity: identity) + } + + private static let orderTotal = routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "order_total", + "qty integer, unit_price numeric", "f", "f", "{integer,numeric}", "{qty,unit_price}", nil, "0" + ]) + + private static let logNote = routine([ + "postgres", "public", "t", "t", "t", "p", "v", "plpgsql", "public", "log_note", + "IN msg text, INOUT n integer, IN label text", "t", "f", "{text,integer,text}", "{msg,n,label}", + "{i,b,i}", "2", "f", nil, + #"{"\"pg_catalog\".\"text\"","\"pg_catalog\".\"int4\"","\"pg_catalog\".\"text\""}"#, + "{NULL,NULL::integer,'x'::text}" + ], identity: "16434") + + private static let pair = routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "pair", + "a integer, OUT x integer, OUT y text", "t", "f", "{integer,integer,text}", "{a,x,y}", "{i,o,o}", "0" + ], identity: "16435") + + private static let anonymous = routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "anon", + "integer, integer", "t", "f", "{integer,integer}", nil, nil, "1", "f", nil, + #"{"\"pg_catalog\".\"int4\"","\"pg_catalog\".\"int4\""}"#, "{NULL,5}" + ], identity: "16436") + + private static let totalOf = routine([ + "postgres", "public", "t", "t", "t", "f", "s", "plpgsql", "public", "total_of", + "VARIADIC nums integer[]", "f", "f", "{integer[]}", "{nums}", "{v}", "0" + ], identity: "16437") + + private static func argument(_ name: String, _ value: PluginDebugArgumentValue) -> PluginDebugArgument { + PluginDebugArgument(parameterName: name, value: value) + } + + private static func result(columns: [String]) -> PluginQueryResult { + PluginQueryResult( + columns: columns, + columnTypeNames: columns.map { _ in "numeric" }, + rows: columns.isEmpty ? [] : [["33.0"]], + rowsAffected: 0, + executionTime: 0 + ) + } + + // MARK: - Notices + + @Test("The PLDBGBREAK notice names the backend the proxy attaches to") + func breakNoticeParses() { + #expect(PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: "PLDBGBREAK:12") == 12) + #expect(PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: " PLDBGBREAK:3\n") == 3) + } + + @Test("Any other notice is routine output, not a port") + func otherNoticesAreOutput() { + #expect(PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: "rows now 2") == nil) + #expect(PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: "PLDBGBREAK:") == nil) + #expect(PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: "said PLDBGBREAK:4") == nil) + } + + // MARK: - Lines + + @Test("A stop's reported line is one past the source line") + func stopLineIsShifted() { + let stop = PostgreSQLRoutineDebugProtocol.location( + fromBreakpointRow: ["16431", "9", "order_total(integer,numeric)"] + ) + #expect(stop?.line == 8) + #expect(stop?.unit.identity == "16431") + #expect(stop?.unit.displayName == "order_total(integer,numeric)") + } + + @Test("A stack frame's line is the source line as is") + func stackLineIsNotShifted() { + let frame = PostgreSQLRoutineDebugProtocol.frame( + fromStackRow: ["1", "order_total(integer,numeric)", "16431", "8", "qty=3, unit_price=10"] + ) + #expect(frame?.level == 1) + #expect(frame?.location?.line == 8) + #expect(frame?.location?.unit.identity == "16431") + #expect(frame?.arguments == "qty=3, unit_price=10") + } + + @Test("A reply without a stop means the target left the debugger") + func emptyStopIsNoLocation() { + #expect(PostgreSQLRoutineDebugProtocol.location(fromBreakpointRow: []) == nil) + #expect(PostgreSQLRoutineDebugProtocol.location(fromBreakpointRow: [nil, nil, nil]) == nil) + } + + @Test("The catalog reads name the trigger types rather than cast to one a 9.1 or 9.2 server lacks") + func triggerTypesAreNamedNotCast() { + let statements = [ + PostgreSQLRoutineDebugProtocol.routineQuery(oid: 16_431, capabilities: PostgreSQLCapabilities(serverVersion: 90_100)), + PostgreSQLRoutineDebugProtocol.targetListQuery(schema: "public", hasProcedureKind: false) + ] + for statement in statements { + #expect(!statement.contains("event_trigger'::")) + #expect(statement.contains("rt.typname IN ('trigger', 'event_trigger')")) + } + } + + @Test("Breakpoints are set on the source line, unshifted") + func breakpointLineIsNotShifted() { + let statement = PostgreSQLRoutineDebugProtocol.setBreakpointStatement( + extensionSchema: "public", + session: 1, + oid: 16_433, + line: 12 + ) + #expect(statement == "SELECT \"public\".pldbg_set_breakpoint(1, '16433'::pg_catalog.oid, 12)") + } + + // MARK: - Variables + + @Test("Variable classes map to argument, local and package") + func variableKinds() { + let argument = PostgreSQLRoutineDebugProtocol.variable( + fromRow: ["qty", "A", "0", "t", "f", "f", "integer", "3"] + ) + let local = PostgreSQLRoutineDebugProtocol.variable( + fromRow: ["subtotal", "L", "3", "t", "f", "f", "numeric", "0"] + ) + let package = PostgreSQLRoutineDebugProtocol.variable( + fromRow: ["counter", "P", "0", "t", "t", "f", "integer", "1"] + ) + #expect(argument?.kind == .argument) + #expect(argument?.typeName == "integer") + #expect(argument?.value == "3") + #expect(local?.kind == .local) + #expect(package?.kind == .package) + #expect(package?.isConstant == true) + #expect(local?.isConstant == false) + } + + @Test("The text NULL pldebugger sends for a null value reads as SQL NULL") + func nullTextIsNull() { + let variable = PostgreSQLRoutineDebugProtocol.variable( + fromRow: ["total", "L", "4", "t", "f", "f", "numeric", "NULL"] + ) + #expect(variable != nil) + #expect(variable?.value == nil) + } + + // MARK: - The call + + @Test("A function runs as SELECT * FROM with each argument bound and cast to its declared type") + func functionCall() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.orderTotal, + arguments: [Self.argument("qty", .text("3")), Self.argument("unit_price", .text("10"))] + ) + #expect(call.sql == "SELECT * FROM \"public\".\"order_total\"($1::integer, $2::numeric)") + #expect(call.parameters == [.text("3"), .text("10")]) + } + + @Test("A procedure runs as CALL") + func procedureCall() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.logNote, + arguments: [ + Self.argument("msg", .text("hello")), + Self.argument("n", .defaultValue), + Self.argument("label", .defaultValue) + ] + ) + #expect( + call.sql + == #"CALL "public"."log_note"($1::"pg_catalog"."text", (NULL::integer)::"pg_catalog"."int4", ('x'::text)::"pg_catalog"."text")"# + ) + #expect(call.parameters == [.text("hello")]) + } + + @Test("An argument after one left to its default stays positional, the default spelled out") + func argumentAfterDefaultStaysPositional() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.logNote, + arguments: [ + Self.argument("msg", .text("hello")), + Self.argument("n", .defaultValue), + Self.argument("label", .text("-lbl")) + ] + ) + #expect( + call.sql + == #"CALL "public"."log_note"($1::"pg_catalog"."text", (NULL::integer)::"pg_catalog"."int4", $2::"pg_catalog"."text")"# + ) + #expect(call.parameters == [.text("hello"), .text("-lbl")]) + } + + @Test("Overloads that differ only in a defaulted parameter each get their own default, so the call is not ambiguous") + func defaultedOverloadsStaySelected() throws { + let integer = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "dflt", "a integer", "f", "f", + "{integer}", "{a}", nil, "1", "f", nil, #"{"\"pg_catalog\".\"int4\""}"#, "{1}" + ]) + let text = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "dflt", "a text", "f", "f", + "{text}", "{a}", nil, "1", "f", nil, #"{"\"pg_catalog\".\"text\""}"#, #"{"'x, y'::text"}"# + ]) + let first = try PostgreSQLRoutineDebugProtocol.call(for: integer, arguments: [Self.argument("a", .defaultValue)]) + let second = try PostgreSQLRoutineDebugProtocol.call(for: text, arguments: [Self.argument("a", .defaultValue)]) + #expect(first.sql == #"SELECT * FROM "public"."dflt"((1)::"pg_catalog"."int4")"#) + #expect(second.sql == #"SELECT * FROM "public"."dflt"(('x, y'::text)::"pg_catalog"."text")"#) + #expect(first.parameters.isEmpty) + } + + @Test("A VARIADIC parameter left at its default keeps VARIADIC in front of the spelled-out default") + func variadicDefault() throws { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "dvar", "a integer, VARIADIC rest integer[]", + "f", "f", "{integer,integer[]}", "{a,rest}", "{i,v}", "1", "f", nil, + #"{"\"pg_catalog\".\"int4\"","\"pg_catalog\".\"_int4\""}"#, #"{NULL,"'{}'::integer[]"}"# + ]) + let call = try PostgreSQLRoutineDebugProtocol.call( + for: routine, + arguments: [Self.argument("a", .text("3")), Self.argument("rest", .defaultValue)] + ) + #expect( + call.sql == #"SELECT * FROM "public"."dvar"($1::"pg_catalog"."int4", VARIADIC ('{}'::integer[])::"pg_catalog"."_int4")"# + ) + } + + @Test("A default the server could not show is refused rather than left out") + func unreadableDefaultIsRefused() { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "f", "a integer", "f", "f", + "{integer}", "{a}", nil, "1", "f", nil, #"{"\"pg_catalog\".\"int4\""}"#, nil + ]) + #expect(throws: PostgreSQLRoutineDebugError.defaultNotReadable(parameter: "a")) { + try PostgreSQLRoutineDebugProtocol.call(for: routine, arguments: [Self.argument("a", .defaultValue)]) + } + } + + @Test("A function returning a bare record is selected as a value") + func recordFunctionCall() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.anonymous, + arguments: [Self.argument("$1", .text("7")), Self.argument("$2", .defaultValue)] + ) + #expect(call.sql == #"SELECT "public"."anon"($1::"pg_catalog"."int4", (5)::"pg_catalog"."int4")"#) + } + + @Test("A function with OUT parameters keeps SELECT * FROM and passes only its inputs") + func outParametersAreNotPassed() throws { + let call = try PostgreSQLRoutineDebugProtocol.call(for: Self.pair, arguments: [Self.argument("a", .text("4"))]) + #expect(call.sql == "SELECT * FROM \"public\".\"pair\"($1::integer)") + } + + @Test("A variadic parameter takes its array with VARIADIC") + func variadicCall() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.totalOf, + arguments: [Self.argument("nums", .text("{1,2,3}"))] + ) + #expect(call.sql == "SELECT * FROM \"public\".\"total_of\"(VARIADIC $1::integer[])") + } + + @Test("NULL is bound as a null parameter, not as text") + func nullArgument() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.orderTotal, + arguments: [Self.argument("qty", .null), Self.argument("unit_price", .text("1"))] + ) + #expect(call.parameters == [.null, .text("1")]) + } + + @Test("DEFAULT on a parameter without a default is refused") + func defaultWithoutDefaultIsRefused() { + #expect(throws: PostgreSQLRoutineDebugError.noDefault(parameter: "qty")) { + try PostgreSQLRoutineDebugProtocol.call( + for: Self.orderTotal, + arguments: [Self.argument("qty", .defaultValue), Self.argument("unit_price", .text("1"))] + ) + } + } + + @Test("An unnamed parameter after one left to its default is passed by position") + func unnamedAfterDefault() throws { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "f", + "integer, integer", "f", "f", "{integer,integer}", nil, nil, "2", "f", nil, + #"{"\"pg_catalog\".\"int4\"","\"pg_catalog\".\"int4\""}"#, "{1,2}" + ]) + let call = try PostgreSQLRoutineDebugProtocol.call( + for: routine, + arguments: [Self.argument("$1", .defaultValue), Self.argument("$2", .text("7"))] + ) + #expect(call.sql == #"SELECT * FROM "public"."f"((1)::"pg_catalog"."int4", $1::"pg_catalog"."int4")"#) + #expect(call.parameters == [.text("7")]) + } + + @Test("A procedure's OUT parameter gets a typed NULL placeholder") + func procedureOutParameter() throws { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "p", "v", "plpgsql", "public", "p", + "IN a integer, OUT b text", "t", "f", "{integer,text}", "{a,b}", "{i,o}", "0" + ]) + let call = try PostgreSQLRoutineDebugProtocol.call(for: routine, arguments: [Self.argument("a", .text("1"))]) + #expect(call.sql == "CALL \"public\".\"p\"($1::integer, NULL::text)") + } + + // MARK: - The routine + + @Test("Catalog arrays become parameters with modes and trailing defaults") + func parametersFromCatalog() { + let parameters = Self.logNote.parameters + let names = parameters.map { $0.name } + let modes = parameters.map { $0.mode } + let defaults = parameters.map { $0.hasDefault } + let types = parameters.map { $0.typeName } + #expect(names == ["msg", "n", "label"]) + #expect(modes == [.input, .inputOutput, .input]) + #expect(defaults == [false, true, true]) + #expect(types == ["text", "integer", "text"]) + } + + @Test("An unnamed parameter is named by its position") + func unnamedParameters() { + let names = Self.anonymous.parameters.map { $0.name } + let defaults = Self.anonymous.parameters.map { $0.hasDefault } + #expect(names == ["$1", "$2"]) + #expect(defaults == [false, true]) + } + + @Test("The entry point's identity is the routine's oid") + func entryIdentityIsTheOid() { + #expect(Self.orderTotal.entryPoint.identity == "16433") + #expect(PostgreSQLRoutineDebugProtocol.oid(inIdentity: "16433") == 16_433) + #expect(PostgreSQLRoutineDebugProtocol.oid(inIdentity: "16433:w") == nil) + #expect(PostgreSQLRoutineDebugProtocol.oid(inIdentity: "order_total") == nil) + } + + @Test("A transaction startup commands left open is rolled back before the call") + func startupTransactionIsRolledBack() { + #expect(!PostgreSQLRoutineDebugProtocol.endsStartupTransaction(.idle)) + #expect(PostgreSQLRoutineDebugProtocol.endsStartupTransaction(.inTransaction)) + #expect(PostgreSQLRoutineDebugProtocol.endsStartupTransaction(.abortedTransaction)) + #expect(PostgreSQLRoutineDebugProtocol.endsStartupTransaction(.unknown)) + } + + @Test("A missing extension the server offers asks for CREATE EXTENSION in this database") + func extensionMissing() { + let routine = Self.routine([ + "noext", nil, "t", "t", "t", "f", "v", "plpgsql", "public", "f", "", "f", "f", "{}", nil, nil, "0" + ]) + #expect(throws: PostgreSQLRoutineDebugError.extensionMissing(database: "noext")) { + try routine.debuggerSchema() + } + } + + @Test("A server without the pldebugger package says so") + func serverPackageMissing() { + let routine = Self.routine([ + "postgres", nil, "f", "t", "t", "f", "v", "plpgsql", "public", "f", "", "f", "f", "{}", nil, nil, "0" + ]) + #expect(throws: PostgreSQLRoutineDebugError.serverPackageMissing) { + try routine.debuggerSchema() + } + } + + @Test("Only the owner or a superuser may debug") + func notOwner() { + let routine = Self.routine([ + "postgres", "public", "t", "t", "f", "f", "v", "plpgsql", "public", "order_total", + "qty integer, unit_price numeric", "f", "f", "{integer,numeric}", "{qty,unit_price}", nil, "0" + ]) + #expect(throws: PostgreSQLRoutineDebugError.notOwner(routine: "order_total(qty integer, unit_price numeric)")) { + try routine.debuggerSchema() + } + } + + @Test("A trigger function or another language cannot be debugged") + func notDebuggable() { + let trigger = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "trg_fn", "", "f", "t", "{}", nil, nil, + "0" + ]) + let sql = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "sql", "public", "f", "", "f", "f", "{}", nil, nil, "0" + ]) + #expect(throws: PostgreSQLRoutineDebugError.notDebuggable) { try trigger.debuggerSchema() } + #expect(throws: PostgreSQLRoutineDebugError.notDebuggable) { try sql.debuggerSchema() } + } + + @Test("A routine taking a polymorphic or record argument is refused before the start") + func pseudoTypeArgumentIsRefused() { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "poly_elem", "a anyelement", "f", "f", + "{anyelement}", "{a}", nil, "0", "t" + ]) + #expect(throws: PostgreSQLRoutineDebugError.pseudoTypeParameter) { try routine.debuggerSchema() } + } + + @Test("Polymorphic, \"any\" and record arguments are found in a signature; a parameter named like one is not") + func signatureClassification() { + let refused = [ + "(a anyelement)", "(VARIADIC \"any\")", "(x anyarray, y integer)", "(anycompatible, anycompatible)", + "(a anycompatiblearray, b integer)", "(r record)", "(IN v anynonarray)", "(e anyenum)", "(r anyrange)", + "(m anymultirange)", "(rs record[])" + ] + let offered = [ + "(qty integer, unit_price numeric)", "(anyelement integer)", "(\"my, record\" integer)", + "(IN msg text, INOUT n integer, IN label text)", "()", "(VARIADIC nums integer[])", + "(b timestamp with time zone)" + ] + for signature in refused { + #expect(PostgreSQLRoutineDebugProtocol.signatureTakesPseudoType(signature), "\(signature)") + } + for signature in offered { + #expect(!PostgreSQLRoutineDebugProtocol.signatureTakesPseudoType(signature), "\(signature)") + } + #expect(!PostgreSQLRoutineDebugProtocol.signatureTakesPseudoType(nil)) + } + + @Test("A routine whose own client_min_messages hides notices, or whose transaction_timeout is set, is refused") + func blockingRoutineSettings() { + #expect( + PostgreSQLRoutineDebugProtocol.blockingSetting(in: ["client_min_messages=warning"]) + == .hidesDebuggerNotice(value: "warning") + ) + #expect( + PostgreSQLRoutineDebugProtocol.blockingSetting(in: ["search_path=app", "client_min_messages=ERROR"]) + == .hidesDebuggerNotice(value: "ERROR") + ) + #expect( + PostgreSQLRoutineDebugProtocol.blockingSetting(in: ["transaction_timeout=2s"]) + == .limitsTransactionTime(value: "2s") + ) + for allowed in [ + "client_min_messages=notice", "client_min_messages=log", "client_min_messages=debug1", + "transaction_timeout=0", "transaction_timeout=0ms", "search_path=app, public", "work_mem=64MB" + ] { + #expect(PostgreSQLRoutineDebugProtocol.blockingSetting(in: [allowed]) == nil, "\(allowed)") + } + } + + @Test("prepareDebug refuses a routine whose settings would stop the debugger") + func blockingSettingRefusesPrepare() { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "quiet", "x integer", "f", "f", + "{integer}", "{x}", nil, "0", "f", "{client_min_messages=warning}" + ]) + #expect(throws: PostgreSQLRoutineDebugError.hidesDebuggerNotice(value: "warning")) { + try routine.debuggerSchema() + } + } + + @Test("Each argument is cast to its exact pg_type name, so the selected overload runs and nothing is cut") + func argumentsCastToTheirExactType() throws { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "pick", + "v bit, w character, x character[], y bit varying[], z app.\"Code3\"", "f", "f", + "{bit,character,character[],\"bit varying[]\",\"app.\\\"Code3\\\"\"}", "{v,w,x,y,z}", nil, "0", "f", nil, + #"{"\"pg_catalog\".\"bit\"","\"pg_catalog\".\"bpchar\"","\"pg_catalog\".\"_bpchar\"","\"pg_catalog\".\"_varbit\"","\"app\".\"Code3\""}"# + ]) + #expect(routine.argumentCastTypes == [ + #""pg_catalog"."bit""#, #""pg_catalog"."bpchar""#, #""pg_catalog"."_bpchar""#, #""pg_catalog"."_varbit""#, + #""app"."Code3""# + ]) + let call = try PostgreSQLRoutineDebugProtocol.call( + for: routine, + arguments: ["v", "w", "x", "y", "z"].map { Self.argument($0, .text("1")) } + ) + let expected = #"SELECT * FROM "public"."pick"($1::"pg_catalog"."bit", $2::"pg_catalog"."bpchar", "# + + #"$3::"pg_catalog"."_bpchar", $4::"pg_catalog"."_varbit", $5::"app"."Code3")"# + #expect(call.sql == expected) + } + + @Test("A procedure's OUT placeholder uses the exact type too") + func outPlaceholderUsesTheExactType() throws { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "p", "v", "plpgsql", "public", "p", + "IN a integer, OUT b character", "t", "f", "{integer,character}", "{a,b}", "{i,o}", "0", "f", nil, + #"{"\"pg_catalog\".\"int4\"","\"pg_catalog\".\"bpchar\""}"# + ]) + let call = try PostgreSQLRoutineDebugProtocol.call(for: routine, arguments: [Self.argument("a", .text("1"))]) + #expect(call.sql == #"CALL "public"."p"($1::"pg_catalog"."int4", NULL::"pg_catalog"."bpchar")"#) + } + + @Test("A polymorphic routine is not offered for debugging") + func polymorphicRoutineIsNotATarget() { + let routine = PluginRoutineInfo( + name: "poly_elem", + kind: .function, + returnType: "anyelement", + language: "plpgsql", + argumentSignature: "(a anyelement)", + identity: "16440" + ) + #expect(PostgreSQLRoutineDebugProtocol.target(for: routine) == nil) + } + + @Test("The extension's schema qualifies every pldbgapi call") + func extensionSchemaQualifiesCalls() throws { + let schema = try Self.orderTotal.debuggerSchema() + #expect(schema == "public") + #expect( + PostgreSQLRoutineDebugProtocol.stepStatement(.continueExecution, extensionSchema: "dbg\"x", session: 1) + == "SELECT func, linenumber, targetname FROM \"dbg\"\"x\".pldbg_continue(1)" + ) + } + + @Test("Step Out has no pldbgapi call") + func noStepOut() { + #expect(PostgreSQLRoutineDebugProtocol.stepStatement(.stepOut, extensionSchema: "public", session: 1) == nil) + #expect(!PostgreSQLRoutineDebugProtocol.supportedCommands.contains(.stepOut)) + } + + // MARK: - Targets + + @Test("A PL/pgSQL function or procedure is a debug target named with its arguments") + func plpgsqlRoutineIsTarget() { + let routine = PluginRoutineInfo( + name: "order_total", + kind: .function, + schema: "public", + returnType: "numeric", + language: "plpgsql", + argumentSignature: "(qty integer, unit_price numeric)", + identity: "16433" + ) + let target = PostgreSQLRoutineDebugProtocol.target(for: routine) + #expect(target?.identity == "16433") + #expect(target?.name == "order_total(qty integer, unit_price numeric)") + #expect(target?.schema == "public") + } + + @Test("A routine in another language, a trigger function or one without an oid is not a target") + func otherRoutinesAreNotTargets() { + let sql = PluginRoutineInfo(name: "f", kind: .function, language: "sql", identity: "1") + let trigger = PluginRoutineInfo( + name: "trg_fn", kind: .function, returnType: "trigger", language: "plpgsql", identity: "2" + ) + let noOid = PluginRoutineInfo(name: "f", kind: .function, language: "plpgsql", identity: nil) + #expect(PostgreSQLRoutineDebugProtocol.target(for: sql) == nil) + #expect(PostgreSQLRoutineDebugProtocol.target(for: trigger) == nil) + #expect(PostgreSQLRoutineDebugProtocol.target(for: noOid) == nil) + } + + // MARK: - Sessions + + @Test("Both sessions turn their timeouts off, and the target lets the port notice through") + func sessionSettings() { + #expect( + PostgreSQLRoutineDebugProtocol.sessionSettings(serverVersion: 170_011, isTarget: true) + == [ + "SET statement_timeout = 0", + "SET idle_session_timeout = 0", + "SET transaction_timeout = 0", + "SET client_min_messages = notice" + ] + ) + #expect( + PostgreSQLRoutineDebugProtocol.sessionSettings(serverVersion: 130_000, isTarget: false) + == ["SET statement_timeout = 0"] + ) + } + + @Test("transaction_timeout, new in PostgreSQL 17, is turned off only on servers that have it") + func transactionTimeoutVersionGate() { + let seventeen = PostgreSQLRoutineDebugProtocol.sessionSettings(serverVersion: 170_000, isTarget: false) + let sixteen = PostgreSQLRoutineDebugProtocol.sessionSettings(serverVersion: 160_004, isTarget: false) + #expect(seventeen == ["SET statement_timeout = 0", "SET idle_session_timeout = 0", "SET transaction_timeout = 0"]) + #expect(sixteen == ["SET statement_timeout = 0", "SET idle_session_timeout = 0"]) + } + + // MARK: - Cancelling a start + + @Test("A start cancelled before the routine is entered cancels the target's call") + func cancelBeforeEntryCancelsTheCall() { + #expect(PostgreSQLRoutineDebugProtocol.startCancellation(portDelivered: false, callEnded: false) == .cancelCall) + } + + @Test("A start cancelled after PLDBGBREAK attaches and aborts, since the target ignores a cancel in accept()") + func cancelAfterNoticeAttachesAndAborts() { + #expect(PostgreSQLRoutineDebugProtocol.startCancellation(portDelivered: true, callEnded: false) == .attachAndAbort) + } + + @Test("A start whose call already ended has nothing left to end") + func cancelAfterTheCallEndedDoesNothing() { + #expect(PostgreSQLRoutineDebugProtocol.startCancellation(portDelivered: false, callEnded: true) == .nothing) + } + + @Test("Once the wait ends, a cancelled start aborts a target that announced its port and attaches otherwise") + func startStepAfterTheWait() { + #expect(PostgreSQLRoutineDebugProtocol.startStep(port: 7, isCancelled: true) == .attachAndAbort(port: 7)) + #expect(PostgreSQLRoutineDebugProtocol.startStep(port: 7, isCancelled: false) == .attach(port: 7)) + #expect(PostgreSQLRoutineDebugProtocol.startStep(port: nil, isCancelled: true) == .cancelled) + #expect(PostgreSQLRoutineDebugProtocol.startStep(port: nil, isCancelled: false) == .endedBeforeEntry) + } + + // MARK: - Errors + + @Test("A missing pldbg function maps to the extension being missing") + func undefinedFunctionMapsToExtensionMissing() { + let error = LibPQPluginError( + message: "ERROR: function public.pldbg_oid_debug(oid) does not exist", + sqlState: "42883", + detail: nil + ) + let mapped = PostgreSQLRoutineDebugProtocol.startFailure(error, database: "noext", routineName: "f()") + #expect(mapped as? PostgreSQLRoutineDebugError == .extensionMissing(database: "noext")) + } + + @Test("pldebugger's owner check maps to the owner message") + func ownerCheckMapsToNotOwner() { + let error = LibPQPluginError( + message: "ERROR: must be owner or superuser to create a breakpoint", + sqlState: "42501", + detail: nil + ) + let mapped = PostgreSQLRoutineDebugProtocol.startFailure(error, database: "postgres", routineName: "f()") + #expect(mapped as? PostgreSQLRoutineDebugError == .notOwner(routine: "f()")) + } + + @Test("Running out of shared memory names the preload fix") + func sharedMemoryMapsToPreload() { + let ownCheck = LibPQPluginError(message: "ERROR: out of shared memory", sqlState: "XX000", detail: nil) + let core = LibPQPluginError( + message: "ERROR: not enough shared memory for data structure \"Debugger Connection slots\" (64 bytes requested)", + sqlState: "53200", + detail: nil + ) + for error in [ownCheck, core] { + let mapped = PostgreSQLRoutineDebugProtocol.startFailure(error, database: "postgres", routineName: "f()") + #expect(mapped as? PostgreSQLRoutineDebugError == .sharedMemoryUnavailable) + } + } + + @Test("Any other start error passes through unchanged") + func otherStartErrorsPassThrough() { + let error = LibPQPluginError( + message: "ERROR: invalid input syntax for type integer: \"abc\"", + sqlState: "22P02", + detail: nil + ) + let mapped = PostgreSQLRoutineDebugProtocol.startFailure(error, database: "postgres", routineName: "f()") + #expect((mapped as? LibPQPluginError)?.sqlState == "22P02") + } + + @Test("The proxy's 08006 means the target finished, not that the proxy failed") + func proxyTerminationIsFinished() { + let terminated = LibPQPluginError( + message: "ERROR: debugger connection terminated", + sqlState: "08006", + detail: nil + ) + let lost = LibPQConnectionLostError( + loss: .afterSending, + underlying: LibPQPluginError(message: "server closed the connection unexpectedly", sqlState: nil, detail: nil) + ) + #expect(PostgreSQLRoutineDebugProtocol.proxyFailure(terminated) == .targetEnded) + #expect(PostgreSQLRoutineDebugProtocol.proxyFailure(lost) == .broken) + #expect(PostgreSQLRoutineDebugProtocol.proxyFailure(CancellationError()) == .broken) + } + + @Test("A signal that interrupts the waiting proxy is told apart from the target leaving") + func interruptedProxyIsNotTheEnd() { + let interrupted = LibPQPluginError( + message: "ERROR: select() failed waiting for target", + sqlState: "08006", + detail: nil + ) + #expect(PostgreSQLRoutineDebugProtocol.proxyFailure(interrupted) == .interrupted) + } + + @Test("A target ended after an interruption says so, with the server's CONTEXT as the detail") + func interruptedEndSaysSo() { + let error = LibPQPluginError( + message: """ + ERROR: canceling statement due to user request + CONTEXT: PL/pgSQL function two_stops() line 6 at assignment + """, + sqlState: "57014", + detail: nil + ) + guard case .failed(let message, let detail) = PostgreSQLRoutineDebugProtocol.end( + of: .failure(error), + stopRequested: false, + interrupted: true + ) else { + Issue.record("expected a failed stop") + return + } + #expect(message == PostgreSQLRoutineDebugProtocol.interruptedMessage) + #expect(detail == "CONTEXT: PL/pgSQL function two_stops() line 6 at assignment") + } + + @Test("A target that could not be ended is named, with the SQL that ends it") + func abandonedTargetIsNamed() { + let both = PostgreSQLRoutineDebugProtocol.abandonedDetail(process: 4_242, port: 11) + #expect(both.contains("4242")) + #expect(both.contains("pldbg_attach_to_port(11)")) + #expect(both.contains("pldbg_abort_target")) + #expect(PostgreSQLRoutineDebugProtocol.abandonedDetail(process: 4_242, port: nil).contains("4242")) + #expect(!PostgreSQLRoutineDebugProtocol.abandonedDetail(process: nil, port: 11).isEmpty) + guard case .failed(let message, let detail) = PostgreSQLRoutineDebugProtocol.abandonedStop(process: 4_242, port: 11) else { + Issue.record("expected a failed stop") + return + } + #expect(message == PostgreSQLRoutineDebugProtocol.abandonedMessage) + #expect(detail == both) + } + + @Test("Stop reports the target ended only when its call returned") + func stopOutcomeEnded() { + guard case .ended = PostgreSQLRoutineDebugProtocol.stopOutcome( + callEnded: true, + abandoned: false, + process: 4_242, + port: 11 + ) else { + Issue.record("expected ended") + return + } + } + + @Test("A stop that could not confirm the end names the server process and the SQL that ends it") + func stopOutcomeUnconfirmed() { + for (callEnded, abandoned) in [(true, true), (false, false), (false, true)] { + guard case .unconfirmed(let message, let detail) = PostgreSQLRoutineDebugProtocol.stopOutcome( + callEnded: callEnded, + abandoned: abandoned, + process: 4_242, + port: 11 + ) else { + Issue.record("expected unconfirmed for \(callEnded), \(abandoned)") + continue + } + #expect(message == PostgreSQLRoutineDebugProtocol.abandonedMessage) + #expect(detail == PostgreSQLRoutineDebugProtocol.abandonedDetail(process: 4_242, port: 11)) + } + } + + @Test("Many replies that win against a signal that never settles leave no waiter behind") + func winningRepliesLeaveNoWaiter() async { + let signal = PostgreSQLDebugRendezvous() + for value in 0..<200 { + let answer = await signal.reply(to: Task { value }) + if case .success(let got)? = answer { + #expect(got == value) + } else { + Issue.record("expected the reply for \(value)") + } + } + #expect(signal.waiterCount == 0) + } + + @Test("A signal that settles first answers nil and keeps no waiter") + func settledSignalWins() async { + let signal = PostgreSQLDebugRendezvous() + let step = Task { () throws -> Int in + try await Task.sleep(for: .seconds(60)) + return 1 + } + signal.settle() + let answer = await signal.reply(to: step) + #expect(answer == nil) + #expect(signal.waiterCount == 0) + step.cancel() + } + + @Test("A cancelled wait for the signal returns and withdraws its waiter") + func cancelledWaitIsWithdrawn() async { + let signal = PostgreSQLDebugRendezvous() + let waiting = Task { await signal.settled() } + waiting.cancel() + await waiting.value + #expect(signal.waiterCount == 0) + #expect(!signal.hasSettled) + } + + @Test("Giving up on a target releases its waiters and marks it abandoned, unless its call already ended") + func rendezvousAbandon() async { + let waiting = PostgreSQLDebugRendezvous() + async let port = waiting.port() + waiting.abandon() + #expect(await port == nil) + #expect(waiting.isAbandoned) + #expect(waiting.hasSettled) + + let ended = PostgreSQLDebugRendezvous() + ended.settle() + ended.abandon() + #expect(!ended.isAbandoned) + } + + @Test("The target's 57014 after Stop reads as stopped, keeping where it stopped") + func cancelAfterStopIsStopped() { + let error = LibPQPluginError( + message: """ + ERROR: canceling statement due to user request + CONTEXT: PL/pgSQL function order_total(integer,numeric) line 13 at assignment + """, + sqlState: "57014", + detail: nil + ) + guard case .failed(let message, let detail) = PostgreSQLRoutineDebugProtocol.end( + of: .failure(error), + stopRequested: true + ) else { + Issue.record("expected a failed stop") + return + } + #expect(message == PostgreSQLRoutineDebugProtocol.stoppedMessage) + #expect(detail == "CONTEXT: PL/pgSQL function order_total(integer,numeric) line 13 at assignment") + } + + @Test("The plugin's own cancel of the target reads as stopped") + func cancellationIsStopped() { + guard case .failed(let message, _) = PostgreSQLRoutineDebugProtocol.end( + of: .failure(CancellationError()), + stopRequested: true + ) else { + Issue.record("expected a failed stop") + return + } + #expect(message == PostgreSQLRoutineDebugProtocol.stoppedMessage) + } + + @Test("A routine error carries the server's message, with its CONTEXT lines as the detail") + func routineErrorKeepsContext() { + let error = LibPQPluginError( + message: """ + ERROR: canceling statement due to user request + CONTEXT: SQL statement "SELECT pg_sleep(4)" + PL/pgSQL function slow_fn() line 4 at PERFORM + """, + sqlState: "57014", + detail: nil + ) + guard case .failed(let message, let detail) = PostgreSQLRoutineDebugProtocol.end( + of: .failure(error), + stopRequested: false + ) else { + Issue.record("expected a failed stop") + return + } + #expect(message == "canceling statement due to user request") + #expect(detail == "CONTEXT: SQL statement \"SELECT pg_sleep(4)\"\nPL/pgSQL function slow_fn() line 4 at PERFORM") + } + + @Test("A finished routine carries its result, or none when it returned no columns") + func finishedCarriesResult() { + if case .finished(let result) = PostgreSQLRoutineDebugProtocol.end( + of: .success(Self.result(columns: ["order_total"])), + stopRequested: false + ) { + #expect(result?.columns == ["order_total"]) + } else { + Issue.record("expected a finished stop") + } + if case .finished(let result) = PostgreSQLRoutineDebugProtocol.end( + of: .success(Self.result(columns: [])), + stopRequested: false + ) { + #expect(result == nil) + } else { + Issue.record("expected a finished stop") + } + } +} diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLSessionScopeSQLTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLSessionScopeSQLTests.swift index 1829120d69..b39c229f05 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLSessionScopeSQLTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLSessionScopeSQLTests.swift @@ -175,4 +175,26 @@ struct PostgreSQLSessionScopeSQLTests { func readScopeRefusesOtherwise(state: PluginSessionTransactionState) { #expect(!PostgreSQLSessionScopeSQL.canOpenReadScope(in: state)) } + + private static func unconnectedDriver() -> PostgreSQLPluginDriver { + PostgreSQLPluginDriver(config: DriverConnectionConfig(host: "", port: 0, username: "", password: "", database: "")) + } + + /// Measured on PostgreSQL 17.11 and 18.6: for a role without the privilege the block answers DO and the + /// transaction keeps its rows, where the bare `SET` aborts it. + @Test("The foreign key switch swallows a refused SET, both ways") + func foreignKeySwitchSwallowsRefusal() { + let driver = Self.unconnectedDriver() + #expect(driver.foreignKeyDisableStatements() == [ + "DO $$ BEGIN SET session_replication_role = replica; EXCEPTION WHEN insufficient_privilege THEN NULL; END $$", + ]) + #expect(driver.foreignKeyEnableStatements() == [ + "DO $$ BEGIN SET session_replication_role = DEFAULT; EXCEPTION WHEN insufficient_privilege THEN NULL; END $$", + ]) + } + + @Test("Relaxing foreign keys opens no scope, since replica would also stop the target's user triggers") + func relaxedForeignKeysOpensNoScope() async throws { + #expect(try await Self.unconnectedDriver().openSessionScope(for: .relaxedForeignKeys) == nil) + } } diff --git a/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift index 832efd4521..a7bcae450a 100644 --- a/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -20,9 +20,10 @@ final class SQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugi static let excludedDatabaseTypeIds = ["MongoDB", "Redis"] static let perTableOptionColumns: [PluginExportOptionColumn] = [ - PluginExportOptionColumn(id: "structure", label: "Structure", width: 56), + PluginExportOptionColumn(id: "structure", label: String(localized: "Structure", bundle: .main), width: 56), + // The DROP statement, left untranslated: the catalog's "Drop" is the verb that deletes a table. PluginExportOptionColumn(id: "drop", label: "Drop", width: 44), - PluginExportOptionColumn(id: "data", label: "Data", width: 44) + PluginExportOptionColumn(id: "data", label: String(localized: "Data", bundle: .main), width: 44) ] static let supportedObjectKinds: [PluginExportObjectKind] = [ diff --git a/apple/Plugins/SQLExportPlugin/SQLExportSessionDeclaration.swift b/apple/Plugins/SQLExportPlugin/SQLExportSessionDeclaration.swift index 32c977f5e8..17229d5ea6 100644 --- a/apple/Plugins/SQLExportPlugin/SQLExportSessionDeclaration.swift +++ b/apple/Plugins/SQLExportPlugin/SQLExportSessionDeclaration.swift @@ -13,7 +13,7 @@ internal struct SQLExportSessionDeclaration: Equatable { let epilogue: String private static let typesAcceptingSetClientEncoding: Set = [ - "PostgreSQL", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite" + "PostgreSQL", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite", "KingbaseES" ] /// `readTimeZone` is the zone the rows were read in, from the export's read scope. Only a MySQL dump declares diff --git a/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 3fb5146cd4..09956c3069 100644 --- a/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -94,7 +94,8 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.sqlite.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlite.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlite.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { @@ -289,6 +290,16 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { ["PRAGMA foreign_keys = ON"] } + // MARK: - Session Scope + + func openSessionScope(for purpose: PluginSessionPurpose) async throws -> PluginSessionScope? { + try await SQLiteSessionScope.open(for: purpose) { try await self.execute(query: $0) } + } + + func closeSessionScope(_ scope: PluginSessionScope) async throws { + try await SQLiteSessionScope.close(scope) { try await self.execute(query: $0) } + } + // MARK: - User Query func executeBoundedQuery(query: String, rowCap: Int) async throws -> PluginQueryResult? { diff --git a/apple/Plugins/SQLiteDriverTests/SQLiteSessionScopeTests.swift b/apple/Plugins/SQLiteDriverTests/SQLiteSessionScopeTests.swift new file mode 100644 index 0000000000..67f9456c1f --- /dev/null +++ b/apple/Plugins/SQLiteDriverTests/SQLiteSessionScopeTests.swift @@ -0,0 +1,96 @@ +// +// SQLiteSessionScopeTests.swift +// SQLiteDriverTests +// + +import Foundation +import TableProPluginKit +import Testing + +struct SQLiteSessionScopeTests { + private func withDriver(_ body: (SQLitePluginDriver) async throws -> Void) async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("sqlite-scope-\(UUID().uuidString).sqlite") + defer { try? FileManager.default.removeItem(at: url) } + let driver = SQLitePluginDriver(config: DriverConnectionConfig( + host: "", port: 0, username: "", password: "", database: url.path + )) + try await driver.connect() + defer { driver.disconnect() } + try await body(driver) + } + + private func foreignKeys(_ driver: SQLitePluginDriver) async throws -> String? { + try await driver.execute(query: "PRAGMA foreign_keys").rows.first?.first?.asText + } + + @Test("Closing a preserve scope turns foreign keys back off after the statements inside turned them on") + func preserveRestoresForeignKeys() async throws { + try await withDriver { driver in + #expect(try await foreignKeys(driver) == "0") + let scope = try #require(try await driver.openSessionScope(for: .preserve)) + #expect(scope.applied.isEmpty) + _ = try await driver.execute(query: "PRAGMA foreign_keys = ON") + try await driver.closeSessionScope(scope) + #expect(try await foreignKeys(driver) == "0") + } + } + + @Test("A preserve scope that cannot put foreign keys back inside a transaction says so") + func preserveCloseInsideTransactionThrows() async throws { + try await withDriver { driver in + let scope = try #require(try await driver.openSessionScope(for: .preserve)) + _ = try await driver.execute(query: "PRAGMA foreign_keys = ON") + _ = try await driver.execute(query: "BEGIN") + await #expect(throws: (any Error).self) { try await driver.closeSessionScope(scope) } + _ = try await driver.execute(query: "ROLLBACK") + try await driver.closeSessionScope(scope) + #expect(try await foreignKeys(driver) == "0") + } + } + + @Test("Relaxing turns foreign keys off for the writes and closing turns them back on") + func relaxTurnsForeignKeysOffAndBack() async throws { + try await withDriver { driver in + _ = try await driver.execute(query: "CREATE TABLE parent (id INTEGER PRIMARY KEY)") + _ = try await driver.execute(query: "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent (id))") + _ = try await driver.execute(query: "PRAGMA foreign_keys = ON") + let scope = try #require(try await driver.openSessionScope(for: .relaxedForeignKeys)) + #expect(scope.applied == [PluginSessionSetting(name: "foreign_keys", value: "0")]) + #expect(try await foreignKeys(driver) == "0") + _ = try await driver.execute(query: "INSERT INTO child VALUES (1, 7)") + try await driver.closeSessionScope(scope) + #expect(try await foreignKeys(driver) == "1") + } + } + + /// sqlite.org/pragma.html#pragma_foreign_keys: the pragma is a no-op while a transaction is open. + @Test("Relaxing inside a transaction opens no scope and leaves foreign keys on") + func relaxInsideTransactionIsNil() async throws { + try await withDriver { driver in + _ = try await driver.execute(query: "PRAGMA foreign_keys = ON") + _ = try await driver.execute(query: "BEGIN") + #expect(try await driver.openSessionScope(for: .relaxedForeignKeys) == nil) + #expect(try await foreignKeys(driver) == "1") + _ = try await driver.execute(query: "ROLLBACK") + } + } + + @Test("Relaxing a connection whose foreign keys are already off opens no scope") + func relaxWhenAlreadyOffIsNil() async throws { + try await withDriver { driver in + let scope = try await driver.openSessionScope(for: .relaxedForeignKeys) + let value = try await foreignKeys(driver) + #expect(scope == nil) + #expect(value == "0") + } + } + + @Test("The verbatim purposes open no scope", arguments: [PluginSessionPurpose.verbatimRead, .verbatimWrite]) + func verbatimPurposesAreNil(_ purpose: PluginSessionPurpose) async throws { + try await withDriver { driver in + let scope = try await driver.openSessionScope(for: purpose) + #expect(scope == nil) + } + } +} diff --git a/apple/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift b/apple/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift index 9dfee3f480..95a9c631ce 100644 --- a/apple/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift +++ b/apple/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift @@ -266,7 +266,10 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { likeEscapeStyle: .explicit, paginationStyle: .limit, requiresBackslashEscaping: true, - caseSensitivityStyle: .ilikeOperator + caseSensitivityStyle: .ilikeOperator, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) static let explainVariants: [ExplainVariant] = [ diff --git a/apple/Plugins/SpannerDriverPlugin/SpannerPlugin.swift b/apple/Plugins/SpannerDriverPlugin/SpannerPlugin.swift index 78f2c2dfd2..51a6bbcd6d 100644 --- a/apple/Plugins/SpannerDriverPlugin/SpannerPlugin.swift +++ b/apple/Plugins/SpannerDriverPlugin/SpannerPlugin.swift @@ -201,6 +201,9 @@ internal enum SpannerSQLDialect { booleanLiteralStyle: .truefalse, likeEscapeStyle: .implicit, paginationStyle: .limit, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) } diff --git a/apple/Plugins/TableProPluginKit/PluginColumnInfo.swift b/apple/Plugins/TableProPluginKit/PluginColumnInfo.swift index d1dfa625d6..6db5aaba70 100644 --- a/apple/Plugins/TableProPluginKit/PluginColumnInfo.swift +++ b/apple/Plugins/TableProPluginKit/PluginColumnInfo.swift @@ -89,6 +89,11 @@ public struct PluginColumnInfo: Codable, Sendable { /// geometry. This is the classified name for exactly those columns, `ENUM`, `INTEGER`, /// `geometry`, and nil wherever the declared spelling classifies the same. public let classificationTypeName: String? + /// The expression of a `CHECK` written on the column itself, or nil for none. + /// + /// MariaDB keeps such a check apart from the table's constraints and drops it on any `MODIFY` that + /// does not restate it, the `json_valid` check on a `JSON` column included, so a driver restates it. + public let columnCheck: String? public var isIdentity: Bool { identityKind != nil } @@ -133,6 +138,7 @@ public struct PluginColumnInfo: Codable, Sendable { self.ddlGenerationExpression = nil self.ddlCollation = nil self.classificationTypeName = nil + self.columnCheck = nil } /// The signature published before the DDL spellings existed, kept byte-identical and disfavoured @@ -173,6 +179,7 @@ public struct PluginColumnInfo: Codable, Sendable { self.ddlGenerationExpression = nil self.ddlCollation = nil self.classificationTypeName = nil + self.columnCheck = nil } /// The signature published before `ddlCollation` existed, kept byte-identical and disfavoured for @@ -216,6 +223,7 @@ public struct PluginColumnInfo: Codable, Sendable { self.ddlGenerationExpression = ddlGenerationExpression self.ddlCollation = nil self.classificationTypeName = nil + self.columnCheck = nil } /// The signature published before `classificationTypeName` existed, kept byte-identical and @@ -260,8 +268,12 @@ public struct PluginColumnInfo: Codable, Sendable { self.ddlGenerationExpression = ddlGenerationExpression self.ddlCollation = ddlCollation self.classificationTypeName = nil + self.columnCheck = nil } + /// The signature published before `columnCheck` existed, kept byte-identical and disfavoured for + /// the same reason as the ones above. + @_disfavoredOverload public init( name: String, dataType: String, @@ -302,5 +314,50 @@ public struct PluginColumnInfo: Codable, Sendable { self.ddlGenerationExpression = ddlGenerationExpression self.ddlCollation = ddlCollation self.classificationTypeName = classificationTypeName + self.columnCheck = nil + } + + public init( + name: String, + dataType: String, + isNullable: Bool = true, + isPrimaryKey: Bool = false, + defaultValue: String? = nil, + extra: String? = nil, + charset: String? = nil, + collation: String? = nil, + comment: String? = nil, + identityKind: IdentityKind? = nil, + isGenerated: Bool = false, + allowedValues: [String]? = nil, + generationExpression: String?, + generationKind: GenerationKind?, + ddlSpelling: String?, + ddlDefault: String?, + ddlGenerationExpression: String?, + ddlCollation: String?, + classificationTypeName: String?, + columnCheck: String? + ) { + self.name = name + self.dataType = dataType + self.isNullable = isNullable + self.isPrimaryKey = isPrimaryKey + self.defaultValue = defaultValue + self.extra = extra + self.charset = charset + self.collation = collation + self.comment = comment + self.identityKind = identityKind + self.isGenerated = isGenerated + self.allowedValues = allowedValues + self.generationExpression = generationExpression + self.generationKind = generationKind + self.ddlSpelling = ddlSpelling + self.ddlDefault = ddlDefault + self.ddlGenerationExpression = ddlGenerationExpression + self.ddlCollation = ddlCollation + self.classificationTypeName = classificationTypeName + self.columnCheck = columnCheck } } diff --git a/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index ed59378451..916bde30e1 100644 --- a/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -570,7 +570,9 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { // Table operations (optional — return nil to use app-level fallback) func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? + /// Turns the session's foreign key checks off. Runs outside a transaction, on the connection that writes. func foreignKeyDisableStatements() -> [String]? + /// Turns them on whatever they were before; a caller that needs the previous value back holds a session scope. func foreignKeyEnableStatements() -> [String]? /// `UPDATE SET WHERE ` in this engine's own form, or nil for that form. The host @@ -620,6 +622,20 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { /// or empty comment removes it. Return nil for a kind the engine cannot comment on. func objectCommentStatement(name: String, objectType: String, schema: String?, comment: String?) -> String? + /// The table's default character set, collation and AUTO_INCREMENT counter, read live. Nil where the + /// engine has no such table options. + func fetchTableOptions(table: String, schema: String?) async throws -> PluginTableOptions? + + /// The character sets, collations and storage engines the server offers any table. Nil where the engine + /// has none. + func fetchTableOptionsCatalog() async throws -> PluginTableOptionsCatalog? + + /// The character set and collation a new table in `schema` takes when it names none. + func fetchDefaultTableOptions(schema: String?) async throws -> PluginTableOptions? + + /// The statement that applies `change` to an existing table. Return nil where the engine cannot. + func tableOptionsStatement(table: String, schema: String?, change: PluginTableOptionsChange) -> String? + /// The statement that recomputes a materialized view's stored rows. Return nil where the engine /// has no materialized views or keeps them current on its own. func refreshMaterializedViewStatement(name: String, schema: String?, concurrently: Bool) -> String? diff --git a/apple/Plugins/TableProPluginKit/PluginRoutineDebugging.swift b/apple/Plugins/TableProPluginKit/PluginRoutineDebugging.swift new file mode 100644 index 0000000000..351823c810 --- /dev/null +++ b/apple/Plugins/TableProPluginKit/PluginRoutineDebugging.swift @@ -0,0 +1,248 @@ +import Foundation + +public protocol PluginRoutineDebugging: AnyObject, Sendable { + // False on a driver that inherits the conformance but cannot debug (PGlite), so a menu can ask without I/O. + var canDebugRoutines: Bool { get } + // Synchronous so a context menu can ask without I/O; nil means this routine cannot be debugged. + func debugTarget(for routine: PluginRoutineInfo) -> PluginDebugTarget? + func debugTargets(schema: String?) async throws -> [PluginDebugTarget] + func prepareDebug(_ target: PluginDebugTarget) async throws -> PluginDebugPlan + // Every driver the plugin takes from `sessions` is closed by the host once the session ends. + // Cancelling the task that awaits this ends the target as `stop()` would, then throws CancellationError, or an + // error saying the target may still be running when its end could not be confirmed. + func startDebug( + _ request: PluginDebugRequest, + sessions: any PluginDebugSessionSource + ) async throws -> any PluginDebugSession +} + +public protocol PluginDebugSessionSource: Sendable { + func openSession() async throws -> any PluginDatabaseDriver +} + +// The host never overlaps two calls, except `stop()`, which may arrive while any other call is still running: that +// call then ends promptly (a waiting step returns `.finished` or `.failed`, never a pause; a read throws). +public protocol PluginDebugSession: AnyObject, Sendable { + var supportedCommands: Set { get } + var output: AsyncStream { get } + func firstStop() async throws -> PluginDebugStop + func perform(_ command: PluginDebugCommand) async throws -> PluginDebugStop + func source(of unit: PluginDebugUnit) async throws -> String + func stack() async throws -> [PluginDebugFrame] + func variables(inFrame level: Int) async throws -> [PluginDebugVariable] + // The breakpoint as the engine placed it (its line may move to the next executable one); nil when it cannot be set. + func setBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws -> PluginDebugBreakpoint? + func removeBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws + // Ends the target and rolls back its open transaction, whatever state it is in, and says whether it could confirm + // that. Returns within 3 seconds: the host closes the session's transport 5 seconds after asking. + func stop() async -> PluginDebugStopOutcome +} + +public struct PluginDebugTarget: Sendable, Hashable { + public let identity: String + public let name: String + public let schema: String? + public let kindDisplayName: String + + public init(identity: String, name: String, schema: String?, kindDisplayName: String) { + self.identity = identity + self.name = name + self.schema = schema + self.kindDisplayName = kindDisplayName + } +} + +public struct PluginDebugPlan: Sendable { + public let target: PluginDebugTarget + public let entryPoints: [PluginDebugEntryPoint] + public let mayWrite: Bool + public let preparation: PluginDebugPreparation? + // Asked for in the start sheet, never in the connection form, so an off module leaves no field behind. + public let options: [ConnectionField] + // What happens to the routine's changes when it runs to the end, shown in the start sheet. + public let note: String? + + public init( + target: PluginDebugTarget, + entryPoints: [PluginDebugEntryPoint], + mayWrite: Bool, + preparation: PluginDebugPreparation? = nil, + options: [ConnectionField] = [], + note: String? = nil + ) { + self.target = target + self.entryPoints = entryPoints + self.mayWrite = mayWrite + self.preparation = preparation + self.options = options + self.note = note + } +} + +public struct PluginDebugEntryPoint: Sendable, Hashable { + public let identity: String + public let displayName: String + public let parameters: [PluginDebugParameter] + + public init(identity: String, displayName: String, parameters: [PluginDebugParameter]) { + self.identity = identity + self.displayName = displayName + self.parameters = parameters + } +} + +public struct PluginDebugParameter: Sendable, Hashable { + public let name: String + public let typeName: String + public let mode: PluginDebugParameterMode + public let hasDefault: Bool + + public init(name: String, typeName: String, mode: PluginDebugParameterMode, hasDefault: Bool) { + self.name = name + self.typeName = typeName + self.mode = mode + self.hasDefault = hasDefault + } +} + +public enum PluginDebugParameterMode: String, Sendable, Hashable { + case input + case output + case inputOutput + case variadic +} + +// Statements the host runs, after the user confirms, before the session can start (Oracle's compile for debug). +public struct PluginDebugPreparation: Sendable { + public let message: String + public let informativeText: String? + public let actionTitle: String + public let statements: [String] + + public init(message: String, informativeText: String?, actionTitle: String, statements: [String]) { + self.message = message + self.informativeText = informativeText + self.actionTitle = actionTitle + self.statements = statements + } +} + +public struct PluginDebugRequest: Sendable { + public let entryPoint: PluginDebugEntryPoint + public let arguments: [PluginDebugArgument] + public let breakpoints: [PluginDebugBreakpoint] + public let optionValues: [String: String] + + public init( + entryPoint: PluginDebugEntryPoint, + arguments: [PluginDebugArgument], + breakpoints: [PluginDebugBreakpoint], + optionValues: [String: String] = [:] + ) { + self.entryPoint = entryPoint + self.arguments = arguments + self.breakpoints = breakpoints + self.optionValues = optionValues + } +} + +public struct PluginDebugArgument: Sendable, Hashable { + public let parameterName: String + public let value: PluginDebugArgumentValue + + public init(parameterName: String, value: PluginDebugArgumentValue) { + self.parameterName = parameterName + self.value = value + } +} + +public enum PluginDebugArgumentValue: Sendable, Hashable { + case text(String) + case null + case defaultValue +} + +public enum PluginDebugCommand: String, Sendable, Hashable { + case continueExecution + case stepOver + case stepInto + case stepOut +} + +public enum PluginDebugStopOutcome: Sendable { + case ended + // The target may still be running on the server; the message names it and how to end it. + case unconfirmed(message: String, detail: String?) +} + +public enum PluginDebugStop: Sendable { + case paused(PluginDebugLocation) + case finished(PluginQueryResult?) + case failed(message: String, detail: String?) +} + +public struct PluginDebugUnit: Sendable, Hashable { + public let identity: String + public let displayName: String + + public init(identity: String, displayName: String) { + self.identity = identity + self.displayName = displayName + } +} + +// `line` is 1-based in the text `source(of:)` returns for `unit`. +public struct PluginDebugLocation: Sendable, Hashable { + public let unit: PluginDebugUnit + public let line: Int + + public init(unit: PluginDebugUnit, line: Int) { + self.unit = unit + self.line = line + } +} + +public struct PluginDebugBreakpoint: Sendable, Hashable { + public let unitIdentity: String + public let line: Int + + public init(unitIdentity: String, line: Int) { + self.unitIdentity = unitIdentity + self.line = line + } +} + +public struct PluginDebugFrame: Sendable, Hashable { + public let level: Int + public let location: PluginDebugLocation? + public let arguments: String? + + public init(level: Int, location: PluginDebugLocation?, arguments: String?) { + self.level = level + self.location = location + self.arguments = arguments + } +} + +public struct PluginDebugVariable: Sendable, Hashable { + public let name: String + public let kind: PluginDebugVariableKind + public let typeName: String? + // Nil is SQL NULL. + public let value: String? + public let isConstant: Bool + + public init(name: String, kind: PluginDebugVariableKind, typeName: String?, value: String?, isConstant: Bool) { + self.name = name + self.kind = kind + self.typeName = typeName + self.value = value + self.isConstant = isConstant + } +} + +public enum PluginDebugVariableKind: String, Sendable, Hashable { + case argument + case local + case package +} diff --git a/apple/Plugins/TableProPluginKit/PluginSessionScope.swift b/apple/Plugins/TableProPluginKit/PluginSessionScope.swift index de64f78517..199a979805 100644 --- a/apple/Plugins/TableProPluginKit/PluginSessionScope.swift +++ b/apple/Plugins/TableProPluginKit/PluginSessionScope.swift @@ -18,6 +18,10 @@ public enum PluginSessionPurpose: Sendable, Equatable { /// Rows read under `verbatimRead` are written back: the target side of a copy. A driver pins the same /// representation for both purposes, so a value read under one is written under the other unchanged. case verbatimWrite + + /// Rows arrive in an order the target's foreign keys may refuse. The driver turns off the checks it can for this + /// session, changing nothing else the writes do, and closing puts back what it saved. Nil when it cannot. + case relaxedForeignKeys } /// One session setting, named and spelled the way its driver reads and writes it. A nil value is SQL `NULL`. diff --git a/apple/Plugins/TableProPluginKit/PluginTableOptions.swift b/apple/Plugins/TableProPluginKit/PluginTableOptions.swift new file mode 100644 index 0000000000..b323b5b867 --- /dev/null +++ b/apple/Plugins/TableProPluginKit/PluginTableOptions.swift @@ -0,0 +1,77 @@ +import Foundation + +/// A table's default character set and collation, and its AUTO_INCREMENT counter. +public struct PluginTableOptions: Sendable, Equatable { + public let characterSet: String? + public let collation: String? + /// The value the next row takes, or nil when the table has no AUTO_INCREMENT column. + public let autoIncrement: UInt64? + + public init(characterSet: String? = nil, collation: String? = nil, autoIncrement: UInt64? = nil) { + self.characterSet = characterSet + self.collation = collation + self.autoIncrement = autoIncrement + } +} + +public struct PluginCharacterSet: Sendable, Equatable { + public let name: String + public let defaultCollation: String + /// Every collation of this character set, its default included. + public let collations: [String] + public let description: String? + + public init(name: String, defaultCollation: String, collations: [String], description: String? = nil) { + self.name = name + self.defaultCollation = defaultCollation + self.collations = collations + self.description = description + } +} + +/// What the server offers any table: its character sets and collations, and the storage engines it can create. +public struct PluginTableOptionsCatalog: Sendable, Equatable { + public let characterSets: [PluginCharacterSet] + public let engines: [String] + public let defaultEngine: String? + + public init(characterSets: [PluginCharacterSet], engines: [String] = [], defaultEngine: String? = nil) { + self.characterSets = characterSets + self.engines = engines + self.defaultEngine = defaultEngine + } +} + +/// A change to an existing table's options. A nil field is left as it is. +public struct PluginTableOptionsChange: Sendable, Equatable { + public let characterSet: String? + public let collation: String? + /// Rewrites every text column to `characterSet` and `collation`, which a convert always names, instead of + /// changing only the default that columns added later take. + public let convertsColumns: Bool + public let autoIncrement: UInt64? + + public init( + characterSet: String? = nil, + collation: String? = nil, + convertsColumns: Bool = false, + autoIncrement: UInt64? = nil + ) { + self.characterSet = characterSet + self.collation = collation + self.convertsColumns = convertsColumns + self.autoIncrement = autoIncrement + } +} + +public extension PluginDatabaseDriver { + func fetchTableOptions(table: String, schema: String?) async throws -> PluginTableOptions? { nil } + + func fetchTableOptionsCatalog() async throws -> PluginTableOptionsCatalog? { nil } + + func fetchDefaultTableOptions(schema: String?) async throws -> PluginTableOptions? { nil } + + func tableOptionsStatement(table: String, schema: String?, change: PluginTableOptionsChange) -> String? { + nil + } +} diff --git a/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift b/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift index 97f6dec73c..46e48f7dc5 100644 --- a/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift +++ b/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift @@ -107,6 +107,10 @@ public struct SQLDialectDescriptor: Sendable { /// gates then read every grammar TablePro knows. Ignored for an engine the app curates itself. public let lexicalFeatures: SQLLexicalFeatures? + // Whether the engine reads a built-in type name in any case, so the formatter may re-case it. False when the + // plugin does not say: ClickHouse's `String` and `UInt8` are matched by exact case. + public let typeNamesAreCaseInsensitive: Bool + public enum CaseSensitivityStyle: String, Sendable { case ilikeOperator // PostgreSQL, CockroachDB, PGlite, DuckDB, Snowflake case caseFoldFunction // Oracle, BigQuery, ClickHouse, Redshift @@ -331,6 +335,7 @@ public struct SQLDialectDescriptor: Sendable { ) } + @_disfavoredOverload public init( identifierQuote: String, keywords: Set, @@ -350,8 +355,53 @@ public struct SQLDialectDescriptor: Sendable { textCastTypeName: String?, functionNamesAreCaseInsensitive: Bool, lexicalFeatures: SQLLexicalFeatures? = nil + ) { + self.init( + identifierQuote: identifierQuote, + keywords: keywords, + functions: functions, + dataTypes: dataTypes, + tableOptions: tableOptions, + regexSyntax: regexSyntax, + booleanLiteralStyle: booleanLiteralStyle, + likeEscapeStyle: likeEscapeStyle, + paginationStyle: paginationStyle, + offsetFetchOrderBy: offsetFetchOrderBy, + requiresBackslashEscaping: requiresBackslashEscaping, + autoLimitStyle: autoLimitStyle, + caseSensitivityStyle: caseSensitivityStyle, + caseFoldFunction: caseFoldFunction, + operators: operators, + textCastTypeName: textCastTypeName, + functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive, + lexicalFeatures: lexicalFeatures, + typeNamesAreCaseInsensitive: false + ) + } + + public init( + identifierQuote: String, + keywords: Set, + functions: Set, + dataTypes: Set, + tableOptions: [String] = [], + regexSyntax: RegexSyntax = .unsupported, + booleanLiteralStyle: BooleanLiteralStyle = .numeric, + likeEscapeStyle: LikeEscapeStyle = .explicit, + paginationStyle: PaginationStyle = .limit, + offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)", + requiresBackslashEscaping: Bool = false, + autoLimitStyle: AutoLimitStyle = .limit, + caseSensitivityStyle: CaseSensitivityStyle = .unsupported, + caseFoldFunction: String = SQLDialectDescriptor.defaultCaseFoldFunction, + operators: [SQLOperatorDescriptor] = [], + textCastTypeName: String?, + functionNamesAreCaseInsensitive: Bool, + lexicalFeatures: SQLLexicalFeatures? = nil, + typeNamesAreCaseInsensitive: Bool = false ) { self.lexicalFeatures = lexicalFeatures + self.typeNamesAreCaseInsensitive = typeNamesAreCaseInsensitive self.identifierQuote = identifierQuote self.keywords = keywords self.functions = functions @@ -395,7 +445,8 @@ public struct SQLDialectDescriptor: Sendable { operators: operators, textCastTypeName: textCastTypeName, functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive, - lexicalFeatures: lexicalFeatures + lexicalFeatures: lexicalFeatures, + typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive ) } } diff --git a/apple/Plugins/TableProPluginKit/SQLiteSessionScope.swift b/apple/Plugins/TableProPluginKit/SQLiteSessionScope.swift new file mode 100644 index 0000000000..903279fb45 --- /dev/null +++ b/apple/Plugins/TableProPluginKit/SQLiteSessionScope.swift @@ -0,0 +1,80 @@ +// +// SQLiteSessionScope.swift +// TableProPluginKit +// + +import Foundation + +/// The session scopes of SQLite and libSQL's local file, which run every statement on the one connection the driver +/// holds. Only `PRAGMA foreign_keys` is held there; the verbatim purposes open none. +public enum SQLiteSessionScope { + private static let foreignKeys = "foreign_keys" + private static let on = "1" + private static let off = "0" + + public static func open( + for purpose: PluginSessionPurpose, + execute: (String) async throws -> PluginQueryResult + ) async throws -> PluginSessionScope? { + switch purpose { + case .preserve: + guard let saved = try await readForeignKeys(execute) else { return nil } + return PluginSessionScope(purpose: purpose, saved: [saved], applied: []) + case .relaxedForeignKeys: + guard let saved = try await readForeignKeys(execute), saved.value == on else { return nil } + _ = try await execute(assignment(off)) + // No scope reaches the caller past here, so nothing else would turn them back on. + let readBack: PluginSessionSetting? + do { + readBack = try await readForeignKeys(execute) + } catch { + _ = try? await execute(assignment(on)) + throw error + } + // The pragma is a no-op while a transaction is open, and says so only when read back. + guard readBack?.value == off else { + _ = try? await execute(assignment(on)) + return nil + } + let applied = PluginSessionSetting(name: foreignKeys, value: off) + return PluginSessionScope(purpose: purpose, saved: [saved], applied: [applied]) + case .verbatimRead, .verbatimWrite: + return nil + } + } + + public static func close( + _ scope: PluginSessionScope, + execute: (String) async throws -> PluginQueryResult + ) async throws { + guard let saved = scope.saved.first(where: { $0.name == foreignKeys })?.value, + try await readForeignKeys(execute)?.value != saved + else { return } + _ = try await execute(assignment(saved)) + guard try await readForeignKeys(execute)?.value == saved else { + throw SQLiteSessionScopeError.transactionOpen + } + } + + private static func readForeignKeys( + _ execute: (String) async throws -> PluginQueryResult + ) async throws -> PluginSessionSetting? { + switch try await execute("PRAGMA foreign_keys").rows.first?[safe: 0]?.asText?.lowercased() { + case "1", "true": return PluginSessionSetting(name: foreignKeys, value: on) + case "0", "false": return PluginSessionSetting(name: foreignKeys, value: off) + default: return nil + } + } + + private static func assignment(_ value: String) -> String { + "PRAGMA foreign_keys = \(value == on ? "ON" : "OFF")" + } +} + +private enum SQLiteSessionScopeError: LocalizedError { + case transactionOpen + + var errorDescription: String? { + String(localized: "Foreign key enforcement could not be put back while a transaction is open.") + } +} diff --git a/apple/Plugins/TableProPluginKit/SchemaTypes.swift b/apple/Plugins/TableProPluginKit/SchemaTypes.swift index cc094d790a..e513688258 100644 --- a/apple/Plugins/TableProPluginKit/SchemaTypes.swift +++ b/apple/Plugins/TableProPluginKit/SchemaTypes.swift @@ -44,6 +44,9 @@ public struct PluginColumnDefinition: Sendable { /// What follows `COLLATE` in a `CREATE TABLE`, or nil to write no `COLLATE` clause. /// `PluginColumnInfo.ddlCollation` says why it is not `collation`. public let ddlCollation: String? + /// The column's own `CHECK` expression, restated by a driver whose server drops it on `MODIFY`. + /// `PluginColumnInfo.columnCheck` says where it comes from. + public let columnCheck: String? /// The signature published before generated-column detail existed. Kept byte-identical and /// disfavoured so already-built plugins keep resolving their own mangled symbol. @@ -78,6 +81,7 @@ public struct PluginColumnDefinition: Sendable { self.ddlDefault = nil self.ddlGenerationExpression = nil self.ddlCollation = nil + self.columnCheck = nil } /// The signature published before the DDL spellings existed, kept byte-identical and disfavoured @@ -115,6 +119,7 @@ public struct PluginColumnDefinition: Sendable { self.ddlDefault = nil self.ddlGenerationExpression = nil self.ddlCollation = nil + self.columnCheck = nil } /// The signature published before `ddlCollation` existed, kept byte-identical and disfavoured for @@ -155,8 +160,12 @@ public struct PluginColumnDefinition: Sendable { self.ddlDefault = ddlDefault self.ddlGenerationExpression = ddlGenerationExpression self.ddlCollation = nil + self.columnCheck = nil } + /// The signature published before `columnCheck` existed, kept byte-identical and disfavoured for + /// the same reason as the ones above. + @_disfavoredOverload public init( name: String, dataType: String, @@ -193,6 +202,47 @@ public struct PluginColumnDefinition: Sendable { self.ddlDefault = ddlDefault self.ddlGenerationExpression = ddlGenerationExpression self.ddlCollation = ddlCollation + self.columnCheck = nil + } + + public init( + name: String, + dataType: String, + isNullable: Bool = true, + defaultValue: String? = nil, + isPrimaryKey: Bool = false, + autoIncrement: Bool = false, + comment: String? = nil, + unsigned: Bool = false, + onUpdate: String? = nil, + charset: String? = nil, + collation: String? = nil, + generationExpression: String?, + generationKind: GenerationKind?, + ddlSpelling: String?, + ddlDefault: String?, + ddlGenerationExpression: String?, + ddlCollation: String?, + columnCheck: String? + ) { + self.name = name + self.dataType = dataType + self.isNullable = isNullable + self.defaultValue = defaultValue + self.isPrimaryKey = isPrimaryKey + self.autoIncrement = autoIncrement + self.comment = comment + self.unsigned = unsigned + self.onUpdate = onUpdate + self.charset = charset + self.collation = collation + self.generationExpression = generationExpression + self.generationKind = generationKind + self.ddlSpelling = ddlSpelling + self.ddlDefault = ddlDefault + self.ddlGenerationExpression = ddlGenerationExpression + self.ddlCollation = ddlCollation + self.columnCheck = columnCheck } public var isGenerated: Bool { generationExpression?.isEmpty == false } diff --git a/apple/Plugins/TableProPluginKit/SqlDialect.swift b/apple/Plugins/TableProPluginKit/SqlDialect.swift index 7571dc90b3..bf55ad82ca 100644 --- a/apple/Plugins/TableProPluginKit/SqlDialect.swift +++ b/apple/Plugins/TableProPluginKit/SqlDialect.swift @@ -9,7 +9,7 @@ public enum SqlDialect: String, Sendable, CaseIterable { public static func from(databaseTypeId: String) -> SqlDialect { switch databaseTypeId { - case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite": + case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite", "KingbaseES": return .postgres case "MySQL", "MariaDB", "TiDB", "OceanBase": return .mysql diff --git a/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index b417b59570..e15f0bfdb7 100644 --- a/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -105,7 +105,10 @@ final class TeradataPlugin: NSObject, TableProPlugin, DriverPlugin { "JSON", "XML", "ST_GEOMETRY", ], autoLimitStyle: .top, - caseSensitivityStyle: .unsupported + caseSensitivityStyle: .unsupported, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) private static let logger = Logger(subsystem: "com.TablePro", category: "TeradataPlugin") diff --git a/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift b/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift index ff59f1914f..0244a3c173 100644 --- a/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift +++ b/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift @@ -143,7 +143,10 @@ final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { likeEscapeStyle: .explicit, paginationStyle: .offsetFetch, offsetFetchOrderBy: "", - caseSensitivityStyle: .regexFlag + caseSensitivityStyle: .regexFlag, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) static let explainVariants: [ExplainVariant] = [ diff --git a/apple/TablePro/AppDelegate.swift b/apple/TablePro/AppDelegate.swift index 1778ce05e7..60145aca2b 100644 --- a/apple/TablePro/AppDelegate.swift +++ b/apple/TablePro/AppDelegate.swift @@ -165,6 +165,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard QuitApproval.approve() else { return .terminateCancel } Task { + // First, while the drivers are open: a debug stop rolls back the routine's transaction. + await AppModuleTermination.stopModuleWork() await MCPServerManager.shared.stop() await DatabaseManager.shared.runPostDisconnectScriptsBeforeQuit() NSApp.reply(toApplicationShouldTerminate: true) diff --git a/apple/TablePro/Core/Autocomplete/CompletionEngine.swift b/apple/TablePro/Core/Autocomplete/CompletionEngine.swift index 6949899846..fc26946d2d 100644 --- a/apple/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/apple/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -1,6 +1,7 @@ import Foundation import TableProAutocomplete import TableProPluginKit +import TableProSQLGrammar struct CompletionContext { let items: [SQLCompletionItem] @@ -19,13 +20,15 @@ final class CompletionEngine { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType? = nil, dialect: SQLDialectDescriptor? = nil, - statementCompletions: [CompletionEntry] = [] + statementCompletions: [CompletionEntry] = [], + grammar: SQLLexicalGrammar? = nil ) { self.provider = SQLCompletionProvider( schemaProvider: schemaProvider, databaseType: databaseType, dialect: dialect, - statementCompletions: statementCompletions + statementCompletions: statementCompletions, + grammar: grammar ) self.identifierRules = databaseType?.identifierRules } diff --git a/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift b/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift index 3419a54dfb..64056e5c4f 100644 --- a/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift +++ b/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift @@ -1,6 +1,7 @@ import Foundation import TableProAutocomplete import TableProPluginKit +import TableProSQLGrammar struct QueryCompletionSession { let items: [SQLCompletionItem] @@ -29,7 +30,8 @@ enum QueryCompletionServiceFactory { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, profile: QueryCompletionProfile? = nil, - connectionId: UUID? = nil + connectionId: UUID? = nil, + grammar: SQLLexicalGrammar? = nil ) -> QueryCompletionService { let language = databaseType.map { PluginManager.shared.editorLanguage(for: $0) } ?? .sql @@ -41,6 +43,7 @@ enum QueryCompletionServiceFactory { schemaProvider: schemaProvider, databaseType: databaseType, profile: profile, + grammar: grammar, identifierCase: { SQLIdentifierCase.stored(for: connectionId) } ) } diff --git a/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift b/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift index bd9e1c2189..c62314e731 100644 --- a/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift +++ b/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift @@ -9,29 +9,17 @@ extension SQLCompletionProvider { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType? = nil, dialect: SQLDialectDescriptor? = nil, - statementCompletions: [CompletionEntry] = [] + statementCompletions: [CompletionEntry] = [], + grammar: SQLLexicalGrammar? = nil ) { self.init( catalog: schemaProvider, - grammar: databaseType?.lexicalGrammar ?? .ansi, + grammar: grammar ?? databaseType?.lexicalGrammar ?? .ansi, vocabulary: SQLCompletionVocabulary( dialect: dialect, statementCompletions: statementCompletions, databaseType: databaseType ) ) } - - func setDatabaseType( - _ type: DatabaseType, - dialect: SQLDialectDescriptor? = nil, - statementCompletions: [CompletionEntry] = [] - ) { - update( - grammar: type.lexicalGrammar, - vocabulary: SQLCompletionVocabulary( - dialect: dialect, statementCompletions: statementCompletions, databaseType: type - ) - ) - } } extension SQLCompletionVocabulary { diff --git a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift index d6811a2b98..b6a44f0c22 100644 --- a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift +++ b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift @@ -14,6 +14,7 @@ final class SQLCompletionService: QueryCompletionService { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, profile: QueryCompletionProfile? = nil, + grammar: SQLLexicalGrammar? = nil, identifierCase: @escaping @MainActor () -> SQLIdentifierCase = { .default } ) { let dialect = profile?.resolvedDialect @@ -21,13 +22,15 @@ final class SQLCompletionService: QueryCompletionService { let statementCompletions = profile?.statementCompletions ?? databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } ?? [] + let resolvedGrammar = grammar ?? databaseType?.lexicalGrammar ?? .ansi self.engine = CompletionEngine( schemaProvider: schemaProvider, databaseType: databaseType, dialect: dialect, - statementCompletions: statementCompletions + statementCompletions: statementCompletions, + grammar: resolvedGrammar ) - self.grammar = databaseType?.lexicalGrammar ?? .ansi + self.grammar = resolvedGrammar self.identifierCaseSource = identifierCase } diff --git a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index ccf4fa6590..c58dd2fce1 100644 --- a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -443,18 +443,18 @@ actor SQLSchemaProvider { let capturedConnection = connection let defaultSchema = getDefaultSchema() let capturedScopeDatabase = scopeDatabase - let (dbName, idQuote, editorLanguage, queryLanguageName) = await MainActor.run { + let (dbName, idQuote, editorLanguage, queryLanguageName, grammar) = await MainActor.run { let resolvedName = capturedScopeDatabase ?? DatabaseManager.shared.browseDatabaseName(for: capturedConnection) let quote = PluginManager.shared.sqlDialect(for: dbType)?.identifierQuote ?? "\"" let lang = PluginManager.shared.editorLanguage(for: dbType) let langName = PluginManager.shared.queryLanguageName(for: dbType) - return (resolvedName, quote, lang, langName) + let sessionGrammar = SQLLexicalResolver.executionGrammar(for: dbType, connectionId: capturedConnection.id) + return (resolvedName, quote, lang, langName, sessionGrammar) } var listedTables = tables if let editorText, editorLanguage == .sql, listedTables.count > settings.maxSchemaTables { - let grammar = dbType.lexicalGrammar let statement = SQLStatementScanner.statementAtCursor( in: editorText, cursorPosition: cursorOffset, diff --git a/apple/TablePro/Core/Compare/CompareMetadataService.swift b/apple/TablePro/Core/Compare/CompareMetadataService.swift index 278ee0ee29..308c4a927b 100644 --- a/apple/TablePro/Core/Compare/CompareMetadataService.swift +++ b/apple/TablePro/Core/Compare/CompareMetadataService.swift @@ -104,7 +104,7 @@ internal struct CompareMetadataService { /// `names` narrows the read to the objects the caller already knows it wants, matched without /// regard to case because engines disagree on identifier folding. A comparison passes nil and /// reads the whole scope; a copy of one table would otherwise pay four round trips for every - /// other table in the database. + /// other table in the database. Names that cover every table still take the whole-scope reads. /// /// `profile` says which of the four reads this caller actually looks at. A data comparison /// pairs tables and reads their rows, so the indexes and the table metadata it used to fetch @@ -124,20 +124,35 @@ internal struct CompareMetadataService { return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in guard let plugin = Self.pluginDriver(from: driver) else { return [] } - let tables = try await plugin.fetchTables(schema: schema).filter { table in - guard wanted?.contains(table.name.lowercased()) ?? true, - !CompareTableKindClassifier.isSequence(table) else { return false } + let listed = try await plugin.fetchTables(schema: schema).filter { table in + guard !CompareTableKindClassifier.isSequence(table) else { return false } let kind = CompareTableKindClassifier.kind(of: table) return kind == .table || includeViews } return try await Self.read( - tables: tables, schema: schema, profile: profile, - narrowed: wanted != nil, databaseType: databaseType, + tables: listed.filter { wanted?.contains($0.name.lowercased()) ?? true }, + schema: schema, profile: profile, + narrowed: Self.narrows(listed, to: wanted), databaseType: databaseType, indexedKinds: indexedKinds, using: plugin ) } } + /// Whether `wanted` leaves out a table `listed` offers, which is when the whole-scope reads + /// would fetch more than the caller keeps. A Copy To of every table took the per-table reads + /// anyway: 52 statements for 8 MySQL tables, measured, against 5 in bulk. A foreign table and + /// a sequence are never offered for a copy, and a view adds little to a bulk read, so none of + /// them counts. + nonisolated internal static func narrows(_ listed: [PluginTableInfo], to wanted: Set?) -> Bool { + guard let wanted else { return false } + return listed.contains { table in + CompareTableKindClassifier.kind(of: table) == .table + && !CompareTableKindClassifier.isForeign(table) + && !CompareTableKindClassifier.isSequence(table) + && !wanted.contains(table.name.lowercased()) + } + } + /// Both sides at once. /// /// A comparison is two independent reads and used to run them one after the other, so the wall @@ -219,8 +234,9 @@ internal struct CompareMetadataService { /// connection queue behind each other. Its gate was `supportsConnectionPooling`, which answers /// whether a *second* connection is safe, not whether one connection can run two statements. /// - /// `narrowed` says the caller asked for specific tables, so the whole-schema queries would read - /// the rest of the database to throw it away. Those callers keep the per-table reads. + /// `narrowed` says the caller wants only some of the scope's tables (`narrows`), so the + /// whole-schema queries would read the rest of the database to throw it away. Those callers + /// keep the per-table reads. nonisolated internal static func read( tables: [PluginTableInfo], schema: String?, diff --git a/apple/TablePro/Core/Compare/CompareRunner+Data.swift b/apple/TablePro/Core/Compare/CompareRunner+Data.swift index 374101fa37..4227d26368 100644 --- a/apple/TablePro/Core/Compare/CompareRunner+Data.swift +++ b/apple/TablePro/Core/Compare/CompareRunner+Data.swift @@ -122,8 +122,26 @@ internal extension CompareRunner { byTable.append((plan, statements)) } guard !byTable.isEmpty else { return [] } + return Self.orderedAcrossTables( + byTable, + sourceSnapshots: session.sourceSnapshots, + in: Self.referenceNamespace(of: context.source) + ) + } - let foreignKeys = CompareRunner.foreignKeyMap(from: session.sourceSnapshots) + static func orderedAcrossTables( + _ byTable: [(plan: DataComparePlan, statements: DataSyncStatements)], + sourceSnapshots: [String: TableStructureSnapshot], + in namespace: ReferenceNamespace + ) -> [SyncStatement] { + var foreignKeys: [String: [PluginForeignKeyInfo]] = [:] + for entry in byTable { + // A listing that leaves the schema off keys the snapshot by bare name, while the plan takes the endpoint's. + guard let snapshot = sourceSnapshots[entry.plan.id] ?? sourceSnapshots[entry.plan.table] else { continue } + let dependencies = dependencies(of: snapshot, ownSchema: entry.plan.schema, in: namespace) + guard !dependencies.isEmpty else { continue } + foreignKeys[entry.plan.id] = dependencies + } let nodes = byTable.map { ForeignKeyTopologicalSort.Table(name: $0.plan.table, schema: $0.plan.schema) } let parentFirst = ForeignKeyTopologicalSort .ordered(nodes, foreignKeysByTable: foreignKeys, childrenFirst: false) diff --git a/apple/TablePro/Core/Compare/CompareRunner.swift b/apple/TablePro/Core/Compare/CompareRunner.swift index 8f796c9035..2503092cf8 100644 --- a/apple/TablePro/Core/Compare/CompareRunner.swift +++ b/apple/TablePro/Core/Compare/CompareRunner.swift @@ -364,7 +364,13 @@ internal struct CompareRunner { options: session.structureOptions, sourceDatabaseType: context.source.databaseType, targetDatabaseType: context.target.databaseType, - targetIndexedKinds: SourceObjectIndexes.carriedKinds(on: context.target.databaseType) + targetIndexedKinds: SourceObjectIndexes.carriedKinds(on: context.target.databaseType), + sourceGrammar: SQLLexicalResolver.executionGrammar( + for: context.source.databaseType, connectionId: context.source.connectionId + ), + targetGrammar: SQLLexicalResolver.executionGrammar( + for: context.target.databaseType, connectionId: context.target.connectionId + ) ) /// Each pair reads two independent endpoints, so the two sides run together rather than the @@ -435,7 +441,12 @@ internal struct CompareRunner { let sourceDefined = selected .filter { $0.identity.kind != .table } .map { (result: $0, action: session.action(for: $0)) } - let foreignKeys = Self.foreignKeyMap(from: snapshots) + let foreignKeys = Self.structureForeignKeys( + source: snapshots, + in: Self.referenceNamespace(of: context.source), + target: session.targetSnapshots, + in: Self.referenceNamespace(of: context.target) + ) return try await DatabaseManager.shared.withMetadataDriver(scope: context.target.scope) { driver in guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { @@ -492,28 +503,78 @@ internal struct CompareRunner { } } + // MARK: - Foreign keys + + // What a catalog writes into `referencedSchema` for a key inside the endpoint: MySQL's database, PostgreSQL's schema. + internal struct ReferenceNamespace { + internal let name: String? + + internal init(endpoint: DatabaseEndpoint, supportsSchemas: Bool, supportsDatabases: Bool) { + self.name = ObjectCopyNamespace.name( + for: endpoint, supportsSchemas: supportsSchemas, supportsDatabases: supportsDatabases + ) + } + + // Exact: on a case-sensitive MySQL server `Shop` and `shop` are two databases. + internal func names(_ schema: String) -> Bool { + schema == name + } + } + + internal static func referenceNamespace(of endpoint: DatabaseEndpoint) -> ReferenceNamespace { + ReferenceNamespace( + endpoint: endpoint, + supportsSchemas: PluginManager.shared.supportsSchemaSwitching(for: endpoint.databaseType), + supportsDatabases: PluginManager.shared.supportsDatabaseSwitching(for: endpoint.databaseType) + ) + } + + // A dropped table exists only on the target, so its keys are read there. + internal static func structureForeignKeys( + source: [String: TableStructureSnapshot], + in sourceNamespace: ReferenceNamespace, + target: [String: TableStructureSnapshot], + in targetNamespace: ReferenceNamespace + ) -> [String: [PluginForeignKeyInfo]] { + let targetOnly = target.filter { source[$0.key] == nil } + return foreignKeyMap(from: source, in: sourceNamespace) + .merging(foreignKeyMap(from: targetOnly, in: targetNamespace)) { sourceKeys, _ in sourceKeys } + } + internal static func foreignKeyMap( - from snapshots: [String: TableStructureSnapshot] + from snapshots: [String: TableStructureSnapshot], + in namespace: ReferenceNamespace ) -> [String: [PluginForeignKeyInfo]] { var map: [String: [PluginForeignKeyInfo]] = [:] for snapshot in snapshots.values { - let dependencies = snapshot.foreignKeys.compactMap { foreignKey -> PluginForeignKeyInfo? in - guard let column = foreignKey.columns.first, - let referencedColumn = foreignKey.referencedColumns.first else { return nil } - let referencedSchema = foreignKey.referencedSchema ?? snapshot.schema - /// The sort qualifies `referencedTable` with `referencedSchema` itself, so - /// pre-qualifying here produced `public.public.orders` and dropped every edge. - return PluginForeignKeyInfo( - name: foreignKey.name, - column: column, - referencedTable: foreignKey.referencedTable, - referencedColumn: referencedColumn, - referencedSchema: referencedSchema - ) - } + let dependencies = dependencies(of: snapshot, ownSchema: snapshot.schema, in: namespace) guard !dependencies.isEmpty else { continue } map[snapshot.qualifiedName] = dependencies } return map } + + internal static func dependencies( + of snapshot: TableStructureSnapshot, + ownSchema: String?, + in namespace: ReferenceNamespace + ) -> [PluginForeignKeyInfo] { + snapshot.foreignKeys.compactMap { foreignKey -> PluginForeignKeyInfo? in + guard let column = foreignKey.columns.first, + let referencedColumn = foreignKey.referencedColumns.first else { return nil } + var referencedSchema = foreignKey.referencedSchema ?? ownSchema + // The listing leaves that namespace off its tables, so a key into it points at the child's own schema. + if let named = referencedSchema, namespace.names(named) { + referencedSchema = ownSchema + } + // The sort qualifies `referencedTable` with `referencedSchema`; pre-qualifying gave `public.public.orders`. + return PluginForeignKeyInfo( + name: foreignKey.name, + column: column, + referencedTable: foreignKey.referencedTable, + referencedColumn: referencedColumn, + referencedSchema: referencedSchema + ) + } + } } diff --git a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift index e34f80616d..0217e55e14 100644 --- a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift +++ b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift @@ -104,7 +104,7 @@ internal enum CompareSQLLiteral { return .bitString case .duckdb: return .unhexFunction - case .postgresql, .cockroachdb, .redshift, .pglite: + case .postgresql, .cockroachdb, .redshift, .pglite, .kingbasees: return .postgresBytea case .mssql: return .zeroX diff --git a/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift b/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift index 0cf57bd72f..5bb2179c04 100644 --- a/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift +++ b/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift @@ -9,13 +9,21 @@ // import Foundation +import TableProPluginKit internal enum CompareSyncEngineFamily { /// A sampled column list is not a schema. A field the source's sample missed reads as one the /// target holds and the source does not, and a script would remove it from every document of /// the target. - internal static func canGenerateStructureScript(from source: DatabaseType, to target: DatabaseType) -> Bool { + internal static func canGenerateStructureScript( + from source: DatabaseType, + to target: DatabaseType, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil + ) -> Bool { guard !source.columnsAreSampled, !target.columnsAreSampled else { return false } + let sourceFamily = SQLTypeFamily.of(source, session: sourceSession) + guard sourceFamily == SQLTypeFamily.of(target, session: targetSession) else { return false } guard source != target else { return true } return sameFamily(source, target) } @@ -28,18 +36,31 @@ internal enum CompareSyncEngineFamily { private static let compatiblePairKeys: Set = { let pairs: [[DatabaseType]] = [ - [.mysql, .mariadb] + [.mysql, .mariadb], + [.postgresql, .kingbasees] ] return Set(pairs.map { $0.map { $0.rawValue }.sorted().joined(separator: "\u{1F}") }) }() - internal static func structureScriptRefusal(from source: DatabaseType, to target: DatabaseType) -> String { + internal static func structureScriptRefusal( + from source: DatabaseType, + to target: DatabaseType, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil + ) -> String { if let sampled = [source, target].first(where: \.columnsAreSampled) { return String( format: String(localized: "%@ lists a collection's fields from a sample of its documents, so structures can be compared but no script is generated."), sampled.rawValue ) } + if let moded = [(source, sourceSession), (target, targetSession)] + .first(where: { SQLTypeFamily.leavesFamily($0.0, session: $0.1) }) { + return String( + format: String(localized: "%@ runs in a compatibility mode with column types of its own, so structures can be compared but no script is generated."), + moded.0.rawValue + ) + } return String( format: String(localized: "Structure sync needs matching database types. %@ and %@ can be compared, but no script is generated."), source.rawValue, target.rawValue diff --git a/apple/TablePro/Core/Compare/CompareSyncExecutor.swift b/apple/TablePro/Core/Compare/CompareSyncExecutor.swift index f85a8f3329..f5dd7f1942 100644 --- a/apple/TablePro/Core/Compare/CompareSyncExecutor.swift +++ b/apple/TablePro/Core/Compare/CompareSyncExecutor.swift @@ -159,7 +159,13 @@ internal actor CompareSyncExecutor { let request = OperationRequest( connectionId: target.connectionId, databaseType: target.databaseType, - sql: Self.digest(of: runnable, databaseType: target.databaseType), + sql: Self.digest( + of: runnable, + databaseType: target.databaseType, + grammar: SQLLexicalResolver.readings( + for: target.databaseType, session: driver.sessionLexicalState + ).execution + ), kind: Self.kind(for: mode, statements: runnable, databaseType: target.databaseType), caller: .userInterface, capabilities: [.mayWrite, .mayRunDestructive, .mayRunMultiStatement, .confirmationPreCleared], @@ -330,8 +336,12 @@ internal actor CompareSyncExecutor { /// statement count and a hash of every statement, so two scripts that share their first ten /// thousand characters are never recorded as the same run. Each statement is written the way the /// saved script writes it, so the gate reads statements ended where they end. - static func digest(of statements: [SyncStatement], databaseType: DatabaseType) -> String { - let scriptText = SQLScriptText(databaseType: databaseType) + static func digest( + of statements: [SyncStatement], + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> String { + let scriptText = SQLScriptText(databaseType: databaseType, grammar: grammar ?? databaseType.lexicalGrammar) var digest = "" var length = 0 for statement in statements { diff --git a/apple/TablePro/Core/Compare/CompareSyncSession.swift b/apple/TablePro/Core/Compare/CompareSyncSession.swift index a14e41ad6f..6077bd999e 100644 --- a/apple/TablePro/Core/Compare/CompareSyncSession.swift +++ b/apple/TablePro/Core/Compare/CompareSyncSession.swift @@ -185,13 +185,23 @@ internal final class CompareSyncSession: ObservableObject { internal var canGenerateStructureScript: Bool { guard mode == .structure, let source, let target else { return false } - return CompareSyncEngineFamily.canGenerateStructureScript(from: source.databaseType, to: target.databaseType) + return CompareSyncEngineFamily.canGenerateStructureScript( + from: source.databaseType, + to: target.databaseType, + sourceSession: SQLLexicalResolver.sessionState(for: source.connectionId), + targetSession: SQLLexicalResolver.sessionState(for: target.connectionId) + ) } internal var crossEngineNotice: String? { guard let source, let target else { return nil } if mode == .structure, !canGenerateStructureScript { - return CompareSyncEngineFamily.structureScriptRefusal(from: source.databaseType, to: target.databaseType) + return CompareSyncEngineFamily.structureScriptRefusal( + from: source.databaseType, + to: target.databaseType, + sourceSession: SQLLexicalResolver.sessionState(for: source.connectionId), + targetSession: SQLLexicalResolver.sessionState(for: target.connectionId) + ) } return CompareSyncEngineFamily.crossEngineDataWarning(from: source.databaseType, to: target.databaseType) } @@ -395,8 +405,9 @@ internal final class CompareSyncSession: ObservableObject { } private func script(of statements: [SyncStatement]) -> String { - guard let databaseType = target?.databaseType else { return "" } - return SQLScriptText(databaseType: databaseType).script(statements.map(\.sql)) + guard let target else { return "" } + let grammar = SQLLexicalResolver.executionGrammar(for: target.databaseType, connectionId: target.connectionId) + return SQLScriptText(databaseType: target.databaseType, grammar: grammar).script(statements.map(\.sql)) } // MARK: - Results view diff --git a/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift b/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift index bd753dabe4..80388eab7f 100644 --- a/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift +++ b/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift @@ -20,7 +20,12 @@ internal struct SchemaSyncScriptBuilder { internal init(targetDriver: any PluginDatabaseDriver, targetDatabaseType: DatabaseType) { self.targetDriver = targetDriver self.targetTypeFamily = SQLTypeFamily.of(targetDatabaseType) - self.scriptText = SQLScriptText(databaseType: targetDatabaseType) + self.scriptText = SQLScriptText( + databaseType: targetDatabaseType, + grammar: SQLLexicalResolver.readings( + for: targetDatabaseType, session: targetDriver.sessionLexicalState + ).execution + ) } internal func build( @@ -158,7 +163,7 @@ internal struct SchemaSyncScriptBuilder { internal enum SchemaChangeOrdering { internal static func sorted(_ changes: [SchemaChange]) -> [SchemaChange] { - var buckets: [[SchemaChange]] = Array(repeating: [], count: 11) + var buckets: [[SchemaChange]] = Array(repeating: [], count: 13) for change in changes { buckets[bucket(for: change)].append(change) } @@ -171,13 +176,15 @@ internal enum SchemaChangeOrdering { case .deleteForeignKey, .modifyForeignKey: return 1 case .deleteIndex, .modifyIndex: return 2 case .deleteColumn: return 3 - case .modifyColumn: return 4 - case .addColumn: return 5 - case .modifyPrimaryKey: return 6 - case .addIndex: return 7 - case .addForeignKey: return 8 - case .addCheckConstraint: return 9 - case .modifyTableComment: return 10 + case .modifyTableCharacterSet: return 4 + case .modifyColumn: return 5 + case .addColumn: return 6 + case .modifyPrimaryKey: return 7 + case .modifyAutoIncrement: return 8 + case .addIndex: return 9 + case .addForeignKey: return 10 + case .addCheckConstraint: return 11 + case .modifyTableComment: return 12 } } } diff --git a/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift b/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift index f9481ba8f0..5ea255cd10 100644 --- a/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift +++ b/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift @@ -29,11 +29,17 @@ internal struct SourceObjectDiffEngine { options: StructureCompareOptions = .default, sourceDatabaseType: DatabaseType, targetDatabaseType: DatabaseType, - targetIndexedKinds: Set + targetIndexedKinds: Set, + sourceGrammar: SQLLexicalGrammar? = nil, + targetGrammar: SQLLexicalGrammar? = nil ) { self.options = options - self.sourceScriptText = SQLScriptText(databaseType: sourceDatabaseType) - self.targetScriptText = SQLScriptText(databaseType: targetDatabaseType) + self.sourceScriptText = SQLScriptText( + databaseType: sourceDatabaseType, grammar: sourceGrammar ?? sourceDatabaseType.lexicalGrammar + ) + self.targetScriptText = SQLScriptText( + databaseType: targetDatabaseType, grammar: targetGrammar ?? targetDatabaseType.lexicalGrammar + ) self.targetIndexedKinds = targetIndexedKinds } diff --git a/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift b/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift index e839430c90..a07f42130c 100644 --- a/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift +++ b/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift @@ -21,6 +21,7 @@ import Foundation import os import TableProPluginKit +import TableProSQLGrammar internal struct SourceObjectSyncBuilder { private static let logger = Logger(subsystem: "com.TablePro", category: "SourceObjectSyncBuilder") @@ -40,7 +41,12 @@ internal struct SourceObjectSyncBuilder { self.targetDriver = targetDriver self.targetDatabaseType = targetDatabaseType self.indexSchema = indexSchema - self.scriptText = SQLScriptText(databaseType: targetDatabaseType) + self.scriptText = SQLScriptText( + databaseType: targetDatabaseType, + grammar: SQLLexicalResolver.readings( + for: targetDatabaseType, session: targetDriver.sessionLexicalState + ).execution + ) self.changeBuilder = SchemaSyncScriptBuilder(targetDriver: targetDriver, targetDatabaseType: targetDatabaseType) } diff --git a/apple/TablePro/Core/Compare/SyncSafetyClassifier.swift b/apple/TablePro/Core/Compare/SyncSafetyClassifier.swift index 31320ee21c..5da3bf6d05 100644 --- a/apple/TablePro/Core/Compare/SyncSafetyClassifier.swift +++ b/apple/TablePro/Core/Compare/SyncSafetyClassifier.swift @@ -71,8 +71,16 @@ internal struct SyncSafetyClassifier { constraint.name ) )] + case .modifyTableCharacterSet(_, let new) where new.convertsColumns: + return [SyncHazard( + kind: .collationOrCharsetChange, + severity: .refusedByDefault, + explanation: String( + localized: "Converting the table's character set rewrites every text column and loses characters the new one cannot hold." + ) + )] case .deleteForeignKey, .addForeignKey, .modifyForeignKey, .addColumn, .addIndex, .modifyIndex, - .modifyTableComment: + .modifyTableComment, .modifyTableCharacterSet, .modifyAutoIncrement: return [] } } diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift index 57ee9e0d5b..3677faa438 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift @@ -174,7 +174,7 @@ final class QueryExecutionCoordinator: ObservableObject { guard case .needsBatchDriver = route else { return false } parent.tabManager.mutate(at: index) { $0.execution.errorMessage = String( - localized: "Update the database driver in Settings > Plugins to run a batch more than once with GO." + localized: "Update the database driver in Settings > Extensions to run a batch more than once with GO." ) } return true diff --git a/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift b/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift index adc5485830..3232ba57c7 100644 --- a/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift +++ b/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift @@ -146,7 +146,7 @@ internal enum CrossEngineIndexTranslator { /// The database types whose index type is the server's own access method, read from `pg_am`, so /// `BLOOM`, `HNSW` or `IVFFLAT` names something a PostgreSQL `USING` clause can write. - private static let accessMethodSources: Set = [.postgresql, .pglite] + private static let accessMethodSources: Set = [.postgresql, .pglite, .kingbasees] /// The database types whose indexes, past the primary key, are keys of the table itself: /// Redshift's `DISTKEY` and `SORTKEY`, Snowflake's clustering key, and BigQuery's clustering and diff --git a/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift b/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift index beeff299d8..408762ef7f 100644 --- a/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift +++ b/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift @@ -17,6 +17,7 @@ // import Foundation +import TableProPluginKit internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { case mysql @@ -32,6 +33,20 @@ internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { familiesByTypeId[type.rawValue] ?? .generic } + /// The family a live session spells its types in. A KingbaseES cluster initialized in Oracle mode spells + /// `varchar2(10)` as `character varying(10 char)` and `date` as `sys.date`, a timestamp, and PostgreSQL parses + /// neither (measured on V9R1C10). Its driver reports that mode as PL/SQL lexing, the one part of it a session + /// carries, so such a session has no family a copy can cross to. + internal static func of(_ type: DatabaseType, session: PluginSessionLexicalState?) -> SQLTypeFamily { + if leavesFamily(type, session: session) { return .generic } + return of(type) + } + + /// Whether the session's mode takes it out of the family its engine has. + internal static func leavesFamily(_ type: DatabaseType, session: PluginSessionLexicalState?) -> Bool { + type == .kingbasees && session?.enabled.contains(.plsqlBlocks) == true + } + /// MySQL reads a `BIT` back as the number it holds; PostgreSQL and DuckDB read theirs as a string of digits. internal var readsBitsAsDecimal: Bool { self == .mysql } @@ -60,6 +75,7 @@ internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { "AlloyDB": .postgres, "Citus": .postgres, "Greenplum": .postgres, + "KingbaseES": .postgres, "SQLite": .sqlite, "libSQL": .sqlite, "Turso": .sqlite, diff --git a/apple/TablePro/Core/DataGrid/Binding+CommittingGridEdit.swift b/apple/TablePro/Core/DataGrid/Binding+CommittingGridEdit.swift new file mode 100644 index 0000000000..e67f7df75d --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Binding+CommittingGridEdit.swift @@ -0,0 +1,23 @@ +// +// Binding+CommittingGridEdit.swift +// TablePro +// + +import SwiftUI + +extension Binding { + /// For a tab selection whose panes share one grid model. The open cell edit commits against the pane + /// being left before the new tab is published, because the incoming pane's grid can mount before + /// the outgoing one is dismantled, and an edit committed at that teardown would land on the new + /// pane's rows, which reuse the same row and column ids. + @MainActor + func committingEdit(of model: DataGridModel) -> Binding { + Binding( + get: { wrappedValue }, + set: { newValue in + model.commitEditing() + wrappedValue = newValue + } + ) + } +} diff --git a/apple/TablePro/Core/DataGrid/DataGridModel.swift b/apple/TablePro/Core/DataGrid/DataGridModel.swift index 9e9cd17f68..5e665060df 100644 --- a/apple/TablePro/Core/DataGrid/DataGridModel.swift +++ b/apple/TablePro/Core/DataGrid/DataGridModel.swift @@ -51,6 +51,8 @@ final class DataGridModel: ObservableObject { @Published private(set) var displayRevision = 0 @Published private(set) var selectionRevision = 0 @Published private(set) var editabilityRevision = 0 + // What a fitsRows grid needs to show every row; the mounted view measures it. + @Published private(set) var fittingHeight: CGFloat = 0 private(set) weak var mountedView: (any DataGridMountedView)? private var orderRevision = 0 @@ -456,6 +458,11 @@ final class DataGridModel: ObservableObject { var isMounted: Bool { mountedView != nil } + func setFittingHeight(_ height: CGFloat) { + guard height != fittingHeight else { return } + fittingHeight = height + } + func revealColumn(_ id: DataGridColumnID) { guard let position = columns.displayPosition(of: id) else { return } mountedView?.revealColumn(position) diff --git a/apple/TablePro/Core/DataWrite/DataWriteExecutor.swift b/apple/TablePro/Core/DataWrite/DataWriteExecutor.swift index c347256c19..d0bf6b9918 100644 --- a/apple/TablePro/Core/DataWrite/DataWriteExecutor.swift +++ b/apple/TablePro/Core/DataWrite/DataWriteExecutor.swift @@ -120,6 +120,13 @@ enum DataWriteExecutor { } var sideStatements: [String] = [] + // An epilogue sets a fixed value, so the scope is what gives SQLite back the foreign_keys it had. + // Inside the user's transaction the pragma does nothing, and PostgreSQL would put a SET LOCAL value + // back session-wide, past the transaction: there the toggles run as they always did. + var preserved: PluginSessionScope? + if !plan.prologue.isEmpty, !(await driver.heldSessionTransactionState()).holdsUserTransaction { + preserved = await openPreservingScope(on: driver) + } func drainEpilogue() async { for statement in plan.epilogue { @@ -132,6 +139,7 @@ enum DataWriteExecutor { ) } } + await closePreservingScope(preserved, on: driver) } for statement in plan.prologue { @@ -239,6 +247,25 @@ enum DataWriteExecutor { return DataWriteRun(results: results, sideStatements: sideStatements) } + private static func openPreservingScope(on driver: DatabaseDriver) async -> PluginSessionScope? { + do { + return try await driver.openSessionScope(for: .preserve) + } catch { + logger.warning("Save runs without saving the session settings: \(error.publicLogShape, privacy: .public)") + return nil + } + } + + // Shielded like a rollback: a cancelled save still has to leave the session as it found it. + private static func closePreservingScope(_ scope: PluginSessionScope?, on driver: DatabaseDriver) async { + guard let scope else { return } + do { + try await TaskCancellationShield.run { try await driver.closeSessionScope(scope) } + } catch { + logger.error("Restoring the session settings after the save failed: \(error.publicLogShape, privacy: .public)") + } + } + private static func rowsFound( by lookup: RowLookup, for step: DataWriteStep, diff --git a/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift b/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift index c1a8415f58..7c584aff47 100644 --- a/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift +++ b/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift @@ -15,7 +15,7 @@ import Foundation enum DataWriteRowCounts { private static let enginesReportingRealCounts: Set = [ .mysql, .mariadb, .tidb, .oceanbase, - .postgresql, .redshift, .cockroachdb, .pglite, + .postgresql, .redshift, .cockroachdb, .pglite, .kingbasees, .sqlite, .libsql, .turso, .duckdb, .mssql, .oracle, ] diff --git a/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift b/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift index f0c6ab7947..8a03dfefd9 100644 --- a/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift +++ b/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift @@ -16,7 +16,7 @@ enum ExplicitIdentityInsert: Equatable { static func style(for databaseType: DatabaseType) -> ExplicitIdentityInsert? { switch databaseType { - case .postgresql, .pglite: + case .postgresql, .pglite, .kingbasees: return .overridingSystemValue case .mssql: return .identityInsertSession diff --git a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift index 1e5af1b028..383ee0663f 100644 --- a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift +++ b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift @@ -77,11 +77,11 @@ extension DatabaseAccessBridge { ) case .statements: throw DatabaseAccessError.invalidArgument( - String(localized: "Update the database driver in Settings > Plugins to run several statements in one call.") + String(localized: "Update the database driver in Settings > Extensions to run several statements in one call.") ) case .needsBatchDriver: throw DatabaseAccessError.invalidArgument( - String(localized: "Update the database driver in Settings > Plugins to run a batch more than once with GO.") + String(localized: "Update the database driver in Settings > Extensions to run a batch more than once with GO.") ) case nil: throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) diff --git a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift index 8aa3ccd4d8..5f00b084c2 100644 --- a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift +++ b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift @@ -214,8 +214,11 @@ internal actor DatabaseAccessBridge { guard !Self.statementText(query, grammar: .ansi).isEmpty else { throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) } - let databaseType = try await ensureConnected(scope.connectionId) - let normalizedQuery = Self.statementText(query, databaseType: databaseType) + let (sessionDriver, databaseType) = try await resolveDriver(scope.connectionId) + let grammar = SQLLexicalResolver.readings( + for: databaseType, session: sessionDriver.sessionLexicalState + ).execution + let normalizedQuery = Self.statementText(query, databaseType: databaseType, grammar: grammar) guard !normalizedQuery.isEmpty else { throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) } @@ -253,7 +256,6 @@ internal actor DatabaseAccessBridge { /// A write that timed out or failed may still have committed: a group only returns once /// every child has, so by the time the error arrives the driver call has finished one way or /// the other, and a catalog that might have changed is refreshed rather than trusted. - let grammar = databaseType.lexicalGrammar let result: QueryResult let tableEdits: SucceededStatements? do { @@ -405,11 +407,15 @@ internal actor DatabaseAccessBridge { SQLStatementScanner.executableText(of: query, grammar: grammar) } - internal static func statementText(_ query: String, databaseType: DatabaseType) -> String { + internal static func statementText( + _ query: String, + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> String { QueryStatementScanner.executableText( of: query, model: QueryStatementModel.forDatabaseType(databaseType), - grammar: databaseType.lexicalGrammar + grammar: grammar ?? databaseType.lexicalGrammar ) } } diff --git a/apple/TablePro/Core/Database/DatabaseDriver.swift b/apple/TablePro/Core/Database/DatabaseDriver.swift index 466a82b60a..3d4de75985 100644 --- a/apple/TablePro/Core/Database/DatabaseDriver.swift +++ b/apple/TablePro/Core/Database/DatabaseDriver.swift @@ -407,6 +407,18 @@ protocol DatabaseDriver: AnyObject, Sendable { schema: String? ) async throws -> PluginConcurrentRefreshAvailability? + // MARK: - Table Options + + /// Nil where the engine has no table options. + func fetchTableOptions(tableName: String) async throws -> TableOptions? + + func fetchTableOptionsCatalog() async throws -> TableOptionsCatalog? + + func fetchDefaultTableOptions(schema: String?) async throws -> TableOptions? + + /// Nil where the engine cannot make `change`. Takes the table's own schema, like `objectCommentStatement`. + func tableOptionsStatement(tableName: String, schema: String?, change: PluginTableOptionsChange) -> String? + // MARK: - Sequences func fetchSequenceDetails(sequence: String, schema: String?) async throws -> PluginSequenceDetails? @@ -894,6 +906,16 @@ extension DatabaseDriver { nil } + func fetchTableOptions(tableName: String) async throws -> TableOptions? { nil } + + func fetchTableOptionsCatalog() async throws -> TableOptionsCatalog? { nil } + + func fetchDefaultTableOptions(schema: String?) async throws -> TableOptions? { nil } + + func tableOptionsStatement(tableName: String, schema: String?, change: PluginTableOptionsChange) -> String? { + nil + } + func fetchSequenceDetails(sequence: String, schema: String?) async throws -> PluginSequenceDetails? { nil } func sequenceNextValueStatement(for sequence: PluginSequenceDetails, nextValue: PluginSequenceNextValue) -> String? { diff --git a/apple/TablePro/Core/Database/DatabaseManager+Health.swift b/apple/TablePro/Core/Database/DatabaseManager+Health.swift index c3be0d168a..dad575f0b0 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Health.swift @@ -137,6 +137,10 @@ extension DatabaseManager { /// so a database switch cannot promote another connection in the meantime. MetadataConnectionPool.shared.beginTransportReplacement(connectionId: connectionId) defer { MetadataConnectionPool.shared.endTransportReplacement(connectionId: connectionId) } + // Debug drivers are dedicated connections, so the pool's replacement fence does not hold them back; the + // debugger keeps its own fence closed until this reconnect is over. + await RoutineDebugSessions.shared.stopAll(connectionId: connectionId) + defer { RoutineDebugSessions.shared.reopenAdmission(for: connectionId) } await SchemaService.shared.prepareForReload(connectionId: connectionId) await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: connectionId) /// Asked again after the suspensions above: a switch that landed before the replacement @@ -397,6 +401,8 @@ extension DatabaseManager { /// closing the pool there withdrew an open that another database's table load was waiting on. let replacesPooledTransport = session.connection.activeTunnelKind != nil || session.liveness != .live if replacesPooledTransport { + // A debug target dialed through this transport would be left holding its locks once it goes. + await RoutineDebugSessions.shared.stopAll(connectionId: sessionId) MetadataConnectionPool.shared.beginTransportReplacement(connectionId: sessionId) /// The other databases' connections were dialed through the same transport, so they go /// with it and reopen on their next use. @@ -407,6 +413,11 @@ extension DatabaseManager { MetadataConnectionPool.shared.endTransportReplacement(connectionId: sessionId) } } + defer { + if replacesPooledTransport { + RoutineDebugSessions.shared.reopenAdmission(for: sessionId) + } + } await stopHealthMonitor(for: sessionId) diff --git a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift index 86bafad920..7383aeeec9 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -743,6 +743,10 @@ extension DatabaseManager { disconnectsInFlight.insert(sessionId) defer { disconnectsInFlight.remove(sessionId) } + // Before the tunnel goes: a debug target whose client vanishes keeps its locks until someone aborts it. + await RoutineDebugSessions.shared.stopAll(connectionId: sessionId, tabsCloseAfter: true) + defer { RoutineDebugSessions.shared.reopenAdmission(for: sessionId) } + tabStatePersister?.persistTabState(for: sessionId) if origin == .userRequested { userRequestedDisconnects.insert(sessionId) diff --git a/apple/TablePro/Core/Database/DatabaseManager+Startup.swift b/apple/TablePro/Core/Database/DatabaseManager+Startup.swift index e65a935a0b..5f68e9c7a7 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Startup.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Startup.swift @@ -24,15 +24,19 @@ extension DatabaseManager { return !commands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - /// The statements `executeStartupCommands` sends, one per line or `;`, read the way the engine reads a quote or a + /// The statements `executeStartupCommands` sends, one per line or `;`, read the way the session reads a quote or a /// comment. Also read by the catalog, because a temporary table a startup command creates shadows a real one for /// the whole session. - nonisolated internal static func startupStatements(from commands: String?, databaseType: DatabaseType) -> [String] { + nonisolated internal static func startupStatements( + from commands: String?, + databaseType: DatabaseType, + grammar sessionGrammar: SQLLexicalGrammar? = nil + ) -> [String] { guard hasStartupCommands(commands), let commands else { return [] } if QueryStatementModel.forDatabaseType(databaseType) == .redis { return RedisCommandSplitter.commands(in: commands).map(\.text) } - let grammar = databaseType.lexicalGrammar + let grammar = sessionGrammar ?? databaseType.lexicalGrammar let source = commands as NSString var statements: [String] = [] var lineStart = 0 @@ -62,7 +66,9 @@ extension DatabaseManager { _ commands: String?, on driver: DatabaseDriver, connectionName: String ) async -> [(statement: String, error: String)] { var failures: [(statement: String, error: String)] = [] - for statement in Self.startupStatements(from: commands, databaseType: driver.connection.type) { + let databaseType = driver.connection.type + let grammar = SQLLexicalResolver.readings(for: databaseType, session: driver.sessionLexicalState).execution + for statement in Self.startupStatements(from: commands, databaseType: databaseType, grammar: grammar) { do { _ = try await driver.execute(query: statement) Self.startupLogger.info( diff --git a/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift b/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift index 1469b7dd77..386b56df4e 100644 --- a/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift +++ b/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift @@ -23,6 +23,7 @@ enum DatabaseObjectToolEligibility { var creatableTypeKinds: Set = [] var canSetSequenceValues = false var canAlterSequences = false + var canSetTableOptions = false static let none = Support() @@ -66,7 +67,11 @@ enum DatabaseObjectToolEligibility { } != nil }), canSetSequenceValues: driver.sequenceNextValueStatement(for: sequenceProbe, nextValue: .value(1)) != nil, - canAlterSequences: driver.alterSequenceStatement(for: sequenceProbe, settings: changed) != nil + canAlterSequences: driver.alterSequenceStatement(for: sequenceProbe, settings: changed) != nil, + canSetTableOptions: driver.tableOptionsStatement( + tableName: "t", schema: "s", + change: PluginTableOptionsChange(characterSet: "utf8mb4", collation: "utf8mb4_bin") + ) != nil ) } @@ -121,4 +126,10 @@ enum DatabaseObjectToolEligibility { static func canEditSequence(_ type: TableInfo.TableType?, support: Support, isReadOnly: Bool) -> Bool { !isReadOnly && type == .sequence && support.canAlterSequences } + + /// Asked of the live driver, which answers per server flavor. A view refuses `ALTER TABLE` with ERROR 1347. + @MainActor + static func offersTableOptions(driver: DatabaseDriver?, objectKind: TableInfo.TableType) -> Bool { + (objectKind == .table || objectKind == .partitionedTable) && Support.of(driver).canSetTableOptions + } } diff --git a/apple/TablePro/Core/Database/FilterSQLGenerator.swift b/apple/TablePro/Core/Database/FilterSQLGenerator.swift index 09a83f2bf3..96b8a39e75 100644 --- a/apple/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/apple/TablePro/Core/Database/FilterSQLGenerator.swift @@ -31,6 +31,9 @@ struct FilterSQLGenerator { private let stringLiteralPrefix: String /// The engine's type family, for the comparisons a dialect descriptor cannot describe. private let family: SQLTypeFamily? + // Oracle reads the literal '' as NULL, and so does KingbaseES in Oracle mode, which its type cannot tell apart + // from its PostgreSQL mode, so `c != ''` matches no row there. + private let readsEmptyStringAsNull: Bool init( dialect: SQLDialectDescriptor, @@ -45,6 +48,7 @@ struct FilterSQLGenerator { self.columnTypesByName = ColumnTypeSQLQuoting.lookupByName(columns: columns, columnTypes: columnTypes) self.stringLiteralPrefix = stringLiteralPrefix self.family = databaseType.map(SQLTypeFamily.of) + self.readsEmptyStringAsNull = family == .oracle || databaseType == .kingbasees } /// The one place a string literal is spelled, so the engine's prefix cannot be forgotten on @@ -125,13 +129,15 @@ struct FilterSQLGenerator { guard ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) else { return "\(quotedColumn) IS NULL" } - return "(\(quotedColumn) IS NULL OR \(patternOperand(quotedColumn, columnType: columnType)) = '')" + let operand = patternOperand(quotedColumn, columnType: columnType) + return "(\(quotedColumn) IS NULL OR \(emptyValueTest(operand, negated: false)))" case .isNotEmpty: guard ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) else { return "\(quotedColumn) IS NOT NULL" } - return "(\(quotedColumn) IS NOT NULL AND \(patternOperand(quotedColumn, columnType: columnType)) != '')" + let operand = patternOperand(quotedColumn, columnType: columnType) + return "(\(quotedColumn) IS NOT NULL AND \(emptyValueTest(operand, negated: true)))" case .inList: return generateInCondition( @@ -154,7 +160,7 @@ struct FilterSQLGenerator { case .regex: let operand = patternOperand(quotedColumn, columnType: columnType) guard dialect.regexSyntax != .unsupported else { - let pattern = quotedLiteral("%\(escapeSQLQuote(filter.value))%") + let pattern = quotedLiteral("%\(escapeStringValue(filter.value))%") return "\(folding.foldingLikeOperand(operand)) \(folding.likeKeyword) " + folding.foldingLikeOperand(pattern) } @@ -164,6 +170,11 @@ struct FilterSQLGenerator { } } + private func emptyValueTest(_ operand: String, negated: Bool) -> String { + guard readsEmptyStringAsNull else { return "\(operand) \(negated ? "!=" : "=") ''" } + return "LENGTH(\(operand)) \(negated ? ">" : "=") 0" + } + /// SQLite keeps integer 5 and text '5' apart in a column with no affinity, and the grid reads both as `5`, /// so SQLite's own text for a number matches either one. private func eitherStorageComparison(_ column: String, _ value: String, columnType: ColumnType?, negated: Bool) -> String? { @@ -311,9 +322,7 @@ struct FilterSQLGenerator { // MARK: - LIKE Conditions - /// Database-specific ESCAPE clause for LIKE patterns. - /// Implicit style (MySQL/MariaDB): backslash is the default LIKE escape, no clause needed. - /// Explicit style: requires an ESCAPE declaration. + // An implicit-style engine escapes LIKE with a backslash by default and takes no clause. private var likeEscapeClause: String { if dialect.likeEscapeStyle == .implicit { return "" } return " ESCAPE '!'" @@ -353,7 +362,7 @@ struct FilterSQLGenerator { negated: Bool, folding: PluginSQLCaseFolding ) -> String { - let quotedPattern = quotedLiteral(escapeSQLQuote(pattern)) + let quotedPattern = quotedLiteral(escapeStringValue(pattern)) let keyword = negated ? folding.notLikeKeyword : folding.likeKeyword let operand = folding.foldingLikeOperand(column) return "\(operand) \(keyword) \(folding.foldingLikeOperand(quotedPattern))\(likeEscapeClause)" @@ -512,50 +521,27 @@ struct FilterSQLGenerator { return isTrue ? "1" : "0" } - /// Escape only single quotes for SQL string literal context. - /// Used for LIKE patterns where wildcards are already escaped - /// by escapeLikeWildcards for the ESCAPE clause. - private func escapeSQLQuote(_ value: String) -> String { - guard value.contains("'") else { return value } - return value.replacingOccurrences(of: "'", with: "''") + // Snowflake takes `ESCAPE '!'` yet reads `\'` as a quote; BigQuery and Spanner read `\` without declaring it. + private var literalsReadBackslash: Bool { + dialect.requiresBackslashEscaping || dialect.likeEscapeStyle == .implicit } - /// Escape special characters in string values + // `.literal` compares code units: the default compares grapheme clusters and skips a quote a combining mark follows. private func escapeStringValue(_ value: String) -> String { - // Fast path: most values have no special chars - if dialect.likeEscapeStyle == .implicit { - // MySQL/MariaDB/ClickHouse: backslash is significant in string literals - guard value.contains("\\") || value.contains("'") else { return value } - return value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "''") - } else { - // ANSI SQL: only single-quote needs escaping - guard value.contains("'") else { return value } - return value.replacingOccurrences(of: "'", with: "''") - } + let body = literalsReadBackslash + ? value.replacingOccurrences(of: "\\", with: "\\\\", options: .literal) + : value + return body.replacingOccurrences(of: "'", with: "''", options: .literal) } + // Only the LIKE layer: escapeStringValue then spells the pattern as a literal. private func escapeLikeWildcards(_ value: String) -> String { - if dialect.likeEscapeStyle == .implicit { - guard value.contains("\\") || value.contains("%") || value.contains("_") else { return value } - // MySQL uses \ as both string escape and default LIKE escape. - // Need double backslash in SQL string so string layer yields single \ - // which LIKE then uses as escape char. - return value - .replacingOccurrences(of: "\\", with: "\\\\\\\\") - .replacingOccurrences(of: "%", with: "\\\\%") - .replacingOccurrences(of: "_", with: "\\\\_") - } + let escape = dialect.likeEscapeStyle == .implicit ? "\\" : "!" // `[` opens a character class in a SQL Server LIKE pattern. - let escapesBracket = family == .mssql - guard value.contains("!") || value.contains("%") || value.contains("_") - || escapesBracket && value.contains("[") else { return value } - let escaped = value - .replacingOccurrences(of: "!", with: "!!") - .replacingOccurrences(of: "%", with: "!%") - .replacingOccurrences(of: "_", with: "!_") - return escapesBracket ? escaped.replacingOccurrences(of: "[", with: "![") : escaped + let metacharacters = [escape, "%", "_"] + (family == .mssql ? ["["] : []) + return metacharacters.reduce(value) { pattern, metacharacter in + pattern.replacingOccurrences(of: metacharacter, with: escape + metacharacter, options: .literal) + } } } diff --git a/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift b/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift index 0bba3e9ea4..96d91478f3 100644 --- a/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift +++ b/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift @@ -1,5 +1,6 @@ import Foundation import TableProPluginKit +import TableProSQLGrammar struct QueryDiagnostic: Equatable, Identifiable { enum Severity: String { @@ -42,7 +43,7 @@ struct CombinedQueryDiagnosticsProducer: QueryDiagnosticsProducing { @MainActor enum QueryDiagnosticsFactory { - static func make(for databaseType: DatabaseType?) -> QueryDiagnosticsProducing { + static func make(for databaseType: DatabaseType?, grammar: SQLLexicalGrammar? = nil) -> QueryDiagnosticsProducing { let resolvedType = databaseType ?? .mysql // Highlighting as JavaScript does not make the language JavaScript: Elasticsearch, Typesense @@ -57,7 +58,7 @@ enum QueryDiagnosticsFactory { case .sql: return CombinedQueryDiagnosticsProducer(producers: [ SQLDiagnosticsProducer(), - SQLConfusableCharacterDiagnosticsProducer(grammar: resolvedType.lexicalGrammar) + SQLConfusableCharacterDiagnosticsProducer(grammar: grammar ?? resolvedType.lexicalGrammar) ]) case .bash: // A command line's arguments are plain text, so `SET smile :)` holds a bracket that closes diff --git a/apple/TablePro/Core/Diagnostics/TabSwitchTrace.swift b/apple/TablePro/Core/Diagnostics/TabSwitchTrace.swift index 815d794304..ec160af1d6 100644 --- a/apple/TablePro/Core/Diagnostics/TabSwitchTrace.swift +++ b/apple/TablePro/Core/Diagnostics/TabSwitchTrace.swift @@ -12,7 +12,7 @@ internal enum TabSwitchTrigger: String, Sendable { case tabStrip } -// requery: the memory budget dropped the rows. firstLoad: restored from the last session, never shown since. +// requery: TablePro changed the table since the rows were read. firstLoad: restored from the last session, never shown since. internal enum TabSwitchRows: String, Sendable { case resident case requery diff --git a/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift b/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift index b481a2bade..a52f02125a 100644 --- a/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift +++ b/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift @@ -31,11 +31,12 @@ enum MCPTabSnapshotProvider { ?? coordinator.connection.name let selectedId = coordinator.tabManager.selectedTabId for tab in coordinator.tabManager.tabs { + guard let tabType = tab.tabType.snapshotName else { continue } snapshots.append(MCPTabSnapshot( tabId: tab.id, connectionId: coordinator.connectionId, connectionName: connectionName, - tabType: tab.tabType.snapshotName, + tabType: tabType, tableName: tab.tableContext.tableName, databaseName: tab.tableContext.databaseName, schemaName: tab.tableContext.schemaName, @@ -156,7 +157,8 @@ enum MCPTabSnapshotProvider { } private extension TabType { - var snapshotName: String { + // Nil for a debug tab: the debugger is a module and adds nothing a client can list. + var snapshotName: String? { switch self { case .query: "query" case .table: "table" @@ -168,6 +170,7 @@ private extension TabType { case .objectSource: "objectSource" case .versionHistory: "versionHistory" case .tableListing: "tableListing" + case .routineDebug: nil } } } diff --git a/apple/TablePro/Core/Menu/DebugMenuBuilder.swift b/apple/TablePro/Core/Menu/DebugMenuBuilder.swift new file mode 100644 index 0000000000..d716612ced --- /dev/null +++ b/apple/TablePro/Core/Menu/DebugMenuBuilder.swift @@ -0,0 +1,63 @@ +import AppKit + +// Built only while the Routine Debugger module is on. Items dim rather than hide when no debug tab is in front. +@MainActor +enum DebugMenuBuilder { + static let identifier = NSUserInterfaceItemIdentifier("menu.debug") + + static var title: String { + String(localized: "Debug") + } + + static func build(keyboard: KeyboardSettings) -> NSMenuItem { + let menu = MenuItemFactory.menu(title, items: [ + MenuItemFactory.item( + String(localized: "Debug Routine…"), + action: #selector(MainSplitViewController.chooseRoutineToDebug(_:)) + ), + MenuItemFactory.separator, + MenuItemFactory.item( + String(localized: "Continue"), + action: #selector(MainSplitViewController.debugContinue(_:)), + shortcut: .debugContinue, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Step Over"), + action: #selector(MainSplitViewController.debugStepOver(_:)), + shortcut: .debugStepOver, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Step Into"), + action: #selector(MainSplitViewController.debugStepInto(_:)), + shortcut: .debugStepInto, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Step Out"), + action: #selector(MainSplitViewController.debugStepOut(_:)), + shortcut: .debugStepOut, + keyboard: keyboard + ), + // No default: Xcode's Command-Period is Cancel Query here. + MenuItemFactory.item( + String(localized: "Stop"), + action: #selector(MainSplitViewController.debugStop(_:)) + ), + MenuItemFactory.separator, + MenuItemFactory.item( + String(localized: "Toggle Breakpoint"), + action: #selector(MainSplitViewController.toggleBreakpoint(_:)), + shortcut: .toggleBreakpoint, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Remove All Breakpoints"), + action: #selector(MainSplitViewController.removeAllBreakpoints(_:)) + ) + ]) + menu.identifier = identifier + return menu + } +} diff --git a/apple/TablePro/Core/Menu/FileMenuBuilder.swift b/apple/TablePro/Core/Menu/FileMenuBuilder.swift index 7c01c4f0f6..0c01e01829 100644 --- a/apple/TablePro/Core/Menu/FileMenuBuilder.swift +++ b/apple/TablePro/Core/Menu/FileMenuBuilder.swift @@ -74,6 +74,10 @@ enum FileMenuBuilder { shortcut: .saveChanges, keyboard: keyboard ), + MenuItemFactory.item( + String(localized: "Discard Changes…"), + action: #selector(MainSplitViewController.discardChanges(_:)) + ), MenuItemFactory.item( String(localized: "Save As…"), action: #selector(MainSplitViewController.saveDocumentAs(_:)), diff --git a/apple/TablePro/Core/Menu/MainMenuBuilder.swift b/apple/TablePro/Core/Menu/MainMenuBuilder.swift index 9829c521ae..2fbc402a49 100644 --- a/apple/TablePro/Core/Menu/MainMenuBuilder.swift +++ b/apple/TablePro/Core/Menu/MainMenuBuilder.swift @@ -15,11 +15,14 @@ struct BuiltMainMenu { let helpMenu: NSMenu? } -/// Builds the whole menu bar. Menu order follows the macOS HIG: the app menu, the -/// standard File/Edit/View menus, app-specific menus, then Window and Help. +// Menu order follows the macOS HIG: the app menu, File, Edit, View, the app's own menus, then Window and Help. @MainActor enum MainMenuBuilder { static func build(keyboard: KeyboardSettings) -> BuiltMainMenu { + build(keyboard: keyboard, modules: AppModuleSettings.shared.enabledModules) + } + + static func build(keyboard: KeyboardSettings, modules: Set) -> BuiltMainMenu { let services = NSMenu(title: String(localized: "Services")) let window = WindowMenuBuilder.build(keyboard: keyboard) let help = HelpMenuBuilder.build() @@ -31,6 +34,9 @@ enum MainMenuBuilder { bar.addItem(ViewMenuBuilder.build(keyboard: keyboard)) bar.addItem(DatabaseMenuBuilder.build(keyboard: keyboard)) bar.addItem(QueryMenuBuilder.build(keyboard: keyboard)) + if modules.contains(.routineDebugger) { + bar.addItem(DebugMenuBuilder.build(keyboard: keyboard)) + } bar.addItem(window) bar.addItem(help) @@ -50,6 +56,25 @@ enum MainMenuBuilder { NSApp.mainMenu = built.bar } + // Adds or removes a module's menu in the installed bar; rebuilding the bar would orphan the Window menu's window list. + static func applyModules( + _ modules: Set, + keyboard: KeyboardSettings, + to bar: NSMenu, + windowsMenu: NSMenu? + ) { + let existing = bar.items.first { $0.identifier == DebugMenuBuilder.identifier } + switch (modules.contains(.routineDebugger), existing) { + case (true, nil): + let index = bar.items.firstIndex { $0.submenu != nil && $0.submenu === windowsMenu } ?? bar.items.count + bar.insertItem(DebugMenuBuilder.build(keyboard: keyboard), at: index) + case (false, let item?): + bar.removeItem(item) + default: + return + } + } + /// The menu bar's key equivalents are one global resource, so exactly one function /// computes them, from the keyboard settings and the key window's text-focus state. /// A rebind, a settings sync, a focus transition and a key-window change all route diff --git a/apple/TablePro/Core/Modules/AppModule.swift b/apple/TablePro/Core/Modules/AppModule.swift new file mode 100644 index 0000000000..e65d22848f --- /dev/null +++ b/apple/TablePro/Core/Modules/AppModule.swift @@ -0,0 +1,41 @@ +import Foundation + +enum AppModule: String, CaseIterable, Sendable { + case routineDebugger + + var title: String { + switch self { + case .routineDebugger: String(localized: "Routine Debugger") + } + } + + var summary: String { + switch self { + case .routineDebugger: String(localized: "Step through PL/pgSQL and PL/SQL routines with breakpoints.") + } + } + + var defaultEnabled: Bool { + switch self { + case .routineDebugger: false + } + } + + var defaultsKey: String { + "com.TablePro.modules.\(rawValue).enabled" + } + + static func enabledModules(in defaults: UserDefaults) -> Set { + Set(allCases.filter { (defaults.object(forKey: $0.defaultsKey) as? Bool) ?? $0.defaultEnabled }) + } + + // For value types that cannot reach the main actor; reads the same keys `AppModuleSettings` writes. + static func storedEnabledModules() -> Set { + enabledModules(in: AppStorageEnvironment.shared.defaults) + } +} + +extension Notification.Name { + // Posted after the new state is stored; `object` is the `AppModule` that changed. + static let appModulesDidChange = Notification.Name("AppModulesDidChange") +} diff --git a/apple/TablePro/Core/Modules/AppModuleActivation.swift b/apple/TablePro/Core/Modules/AppModuleActivation.swift new file mode 100644 index 0000000000..14fc7db9e5 --- /dev/null +++ b/apple/TablePro/Core/Modules/AppModuleActivation.swift @@ -0,0 +1,36 @@ +import AppKit + +// The one writer of a module's state outside tests: the Extensions pane and Reset All Settings, both a click in TablePro. +@MainActor +enum AppModuleActivation { + static func setEnabled(_ enabled: Bool, for module: AppModule, settings: AppModuleSettings = .shared) { + guard settings.isEnabled(module) != enabled else { return } + settings.setEnabled(enabled, for: module) + if let bar = NSApp.mainMenu { + MainMenuBuilder.applyModules( + settings.enabledModules, + keyboard: AppSettingsManager.shared.keyboard, + to: bar, + windowsMenu: NSApp.windowsMenu + ) + MainMenuBuilder.syncKeyEquivalents() + } + MainWindowToolbar.applyModuleChange() + if !enabled { + stopWork(of: module) + } + } + + static func resetToDefaults(settings: AppModuleSettings = .shared) { + for module in AppModule.allCases { + setEnabled(module.defaultEnabled, for: module, settings: settings) + } + } + + private static func stopWork(of module: AppModule) { + switch module { + case .routineDebugger: + Task { await RoutineDebugSessions.shared.stopAll() } + } + } +} diff --git a/apple/TablePro/Core/Modules/AppModuleSettings.swift b/apple/TablePro/Core/Modules/AppModuleSettings.swift new file mode 100644 index 0000000000..36f5104d88 --- /dev/null +++ b/apple/TablePro/Core/Modules/AppModuleSettings.swift @@ -0,0 +1,37 @@ +import Combine +import Foundation + +// Per Mac: the keys live in the local defaults suite and no sync payload carries them. +@MainActor +final class AppModuleSettings: ObservableObject { + static let shared = AppModuleSettings() + + @Published private(set) var enabledModules: Set + + private let defaults: UserDefaults + private let notificationCenter: NotificationCenter + + init( + defaults: UserDefaults = AppStorageEnvironment.shared.defaults, + notificationCenter: NotificationCenter = .default + ) { + self.defaults = defaults + self.notificationCenter = notificationCenter + enabledModules = AppModule.enabledModules(in: defaults) + } + + func isEnabled(_ module: AppModule) -> Bool { + enabledModules.contains(module) + } + + func setEnabled(_ enabled: Bool, for module: AppModule) { + guard isEnabled(module) != enabled else { return } + defaults.set(enabled, forKey: module.defaultsKey) + if enabled { + enabledModules.insert(module) + } else { + enabledModules.remove(module) + } + notificationCenter.post(name: .appModulesDidChange, object: module) + } +} diff --git a/apple/TablePro/Core/Modules/AppModuleTermination.swift b/apple/TablePro/Core/Modules/AppModuleTermination.swift new file mode 100644 index 0000000000..9974d64f5b --- /dev/null +++ b/apple/TablePro/Core/Modules/AppModuleTermination.swift @@ -0,0 +1,44 @@ +import Foundation + +@MainActor +enum AppModuleTermination { + static let stopLimit: Duration = .seconds(5) + // From the approved quit on, so nothing puts up an alert that would hold it. + private(set) static var hasBegun = false + + // A stop waiting on a server that no longer answers must not hold the quit: `stopAll` returns by its deadline, + // the same one the runs close their connections at. + static func stopModuleWork() async { + hasBegun = true + guard RoutineDebugSessions.shared.hasActiveSessions else { return } + await RoutineDebugSessions.shared.stopAll(timeout: stopLimit) + } + + // True when `work` returned before the limit. Work still running at the limit is left to the process exit. + static func runCapped( + at limit: Duration, + clock: any Clock = ContinuousClock(), + _ work: @escaping @MainActor @Sendable () async -> Void + ) async -> Bool { + var deadline: Task? + let outcomes = AsyncStream { continuation in + Task { @MainActor in + await work() + continuation.yield(true) + continuation.finish() + } + deadline = Task { + try? await clock.sleep(for: limit) + continuation.yield(false) + continuation.finish() + } + } + var finished = false + for await outcome in outcomes { + finished = outcome + break + } + deadline?.cancel() + return finished + } +} diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift index 5d83919195..1d94e168ed 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift @@ -62,7 +62,9 @@ internal enum ObjectCopyEligibility { from source: DatabaseType, to target: DatabaseType, sourceLanguage: EditorLanguage, - targetLanguage: EditorLanguage + targetLanguage: EditorLanguage, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil ) -> String? { guard supportsCopying(editorLanguage: sourceLanguage), supportsCopying(editorLanguage: targetLanguage) else { @@ -71,7 +73,7 @@ internal enum ObjectCopyEligibility { source.rawValue, target.rawValue ) } - return crossEngineRefusal(from: source, to: target) + return crossEngineRefusal(from: source, sourceSession, to: target, targetSession) } /// The second half of the gate, and the one the editor language cannot answer. @@ -81,8 +83,12 @@ internal enum ObjectCopyEligibility { /// where both engines have a type system `SQLTypeFamily` names, which is the same set the /// translation was written and tested against. Within one engine nothing is translated, so an /// engine no family names still copies to itself. - private static func crossEngineRefusal(from source: DatabaseType, to target: DatabaseType) -> String? { - guard SQLTypeFamily.needsTranslation(from: source, to: target) else { return nil } + private static func crossEngineRefusal( + from source: DatabaseType, _ sourceSession: PluginSessionLexicalState?, + to target: DatabaseType, _ targetSession: PluginSessionLexicalState? + ) -> String? { + guard crosses(from: source, sourceSession, to: target, targetSession) else { return nil } + if let moded = modeRefusal(source, sourceSession, target, targetSession) { return moded } let unnamed = [source, target].filter { SQLTypeFamily.of($0) == .generic } guard let first = unnamed.first else { return nil } return String( @@ -93,6 +99,33 @@ internal enum ObjectCopyEligibility { ) } + /// `SQLTypeFamily.needsTranslation` with each side's session applied, so two servers of one engine + /// in different modes cross as well. + private static func crosses( + from source: DatabaseType, _ sourceSession: PluginSessionLexicalState?, + to target: DatabaseType, _ targetSession: PluginSessionLexicalState? + ) -> Bool { + let sourceFamily = SQLTypeFamily.of(source, session: sourceSession) + let targetFamily = SQLTypeFamily.of(target, session: targetSession) + if source == target { return sourceFamily != targetFamily } + return sourceFamily != targetFamily || sourceFamily == .generic + } + + private static func modeRefusal( + _ source: DatabaseType, _ sourceSession: PluginSessionLexicalState?, + _ target: DatabaseType, _ targetSession: PluginSessionLexicalState? + ) -> String? { + let moded = [(source, sourceSession), (target, targetSession)] + .first { SQLTypeFamily.leavesFamily($0.0, session: $0.1) } + guard let moded else { return nil } + return String( + format: String( + localized: "%@ runs in a compatibility mode with column types of its own. Copy to a target of the same type in the same mode." + ), + moded.0.rawValue + ) + } + /// Why a view, routine or trigger cannot cross to another engine. /// /// Its definition is the source's own SQL text and nothing here parses it, so a MySQL view's @@ -101,9 +134,12 @@ internal enum ObjectCopyEligibility { /// structure the translator can restate; a definition has only text. internal static func definitionEngineRefusal( from source: DatabaseType, - to target: DatabaseType + to target: DatabaseType, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil ) -> String? { - guard SQLTypeFamily.needsTranslation(from: source, to: target) else { return nil } + guard crosses(from: source, sourceSession, to: target, targetSession) else { return nil } + if let moded = modeRefusal(source, sourceSession, target, targetSession) { return moded } return String( format: String( localized: "Its definition is written in %1$@'s own SQL, which %2$@ does not parse." diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift index 127c8315fd..0d1be56987 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift @@ -12,6 +12,7 @@ import Foundation import TableProPluginKit +import TableProSQLGrammar /// One table's work: its DDL, and the read and write it will stream between. internal struct ObjectCopyTableStep: Identifiable, Sendable { @@ -61,6 +62,12 @@ internal struct ObjectCopyTableStep: Identifiable, Sendable { /// Set when this table is in the plan but part of it cannot run, so the sheet can say why /// before the user presses Copy. internal let note: String? + /// One count per foreign key this table is created with. Nil when a key cannot be checked, + /// which keeps the target's checks on for the rows. + internal let keyChecks: [ObjectCopyKeyCheck]? + /// False when the target keeps this table's rows through a ROLLBACK, so a rolled back run still + /// reports them. + internal let rollsBack: Bool /// Spelled out rather than left to the memberwise init so the three fields a same-engine copy /// never sets can default. Every one of them describes something only a crossing or a @@ -83,8 +90,12 @@ internal struct ObjectCopyTableStep: Identifiable, Sendable { coercer: CrossEngineValueCoercer? = nil, serverSideInsert: SyncStatement? = nil, hasRowFilter: Bool = false, - note: String? + note: String?, + keyChecks: [ObjectCopyKeyCheck]? = [], + rollsBack: Bool = true ) { + self.keyChecks = keyChecks + self.rollsBack = rollsBack self.selection = selection self.dropStatements = dropStatements self.sequenceStatements = sequenceStatements @@ -207,6 +218,22 @@ internal struct ObjectCopyPartialNote: Identifiable, Sendable { internal var id: String { selection.id } } +/// Counts the rows of a table that point at nothing through one foreign key. +internal struct ObjectCopyKeyCheck: Sendable, Equatable { + internal let constraint: String + internal let sql: String +} + +/// Whether the copy's foreign keys point only at tables it writes, which is when the target's checks +/// may be off: turning them back on checks nothing already written. +internal struct ObjectCopyKeysInside: Sendable, Equatable { + /// Every key of a table the copy creates names a table in the copy. + internal var created = false + /// Every table that gets rows is one the copy creates, so the target holds the source's keys + /// and no others, and each names a table that gets all of its rows. + internal var rows = false +} + internal struct ObjectCopyPlan: Sendable { internal let request: ObjectCopyRequest internal let createsDatabase: Bool @@ -224,6 +251,9 @@ internal struct ObjectCopyPlan: Sendable { /// so the run may write that DDL under the target's write scope. A definition read as the /// session is runs as the session is, the way it did before scopes. internal let isStructureReadPinned: Bool + internal let keysInside: ObjectCopyKeysInside + /// The target session's execution grammar, which the review script is written in. + internal let targetGrammar: SQLLexicalGrammar internal init( request: ObjectCopyRequest, @@ -232,7 +262,9 @@ internal struct ObjectCopyPlan: Sendable { definitionSteps: [ObjectCopyDefinitionStep], schemaStatements: [SyncStatement] = [], skipped: [ObjectCopySkip] = [], - isStructureReadPinned: Bool = false + isStructureReadPinned: Bool = false, + keysInside: ObjectCopyKeysInside = ObjectCopyKeysInside(), + targetGrammar: SQLLexicalGrammar? = nil ) { self.request = request self.createsDatabase = createsDatabase @@ -241,6 +273,8 @@ internal struct ObjectCopyPlan: Sendable { self.schemaStatements = schemaStatements self.skipped = skipped self.isStructureReadPinned = isStructureReadPinned + self.keysInside = keysInside + self.targetGrammar = targetGrammar ?? request.target.databaseType.lexicalGrammar } /// Shown above the script, for the caveats the copy cannot do anything about: a key column the @@ -352,9 +386,14 @@ internal struct ObjectCopyPlan: Sendable { /// A run that promises nothing keeps them in a phase of their own, where one table's failure /// does not reach the tables already copied. internal var clearsInsideDataTransaction: Bool { - !clearGroups.isEmpty - && request.wrapEachTableInTransaction - && request.errorHandling != .skipAndContinue + !clearGroups.isEmpty && dataRunsInOneTransaction + } + + /// Stop and roll back promises the target ends as it was, so the clears and every table's rows + /// are one transaction. The other two keep what ran, and PostgreSQL commits an aborted + /// transaction as a ROLLBACK, so their batches commit as they go. + internal var dataRunsInOneTransaction: Bool { + request.wrapEachTableInTransaction && request.errorHandling == .stopAndRollback } /// The definitions held back until the rows are in: a trigger fires on the copy itself, and a @@ -386,7 +425,7 @@ internal struct ObjectCopyPlan: Sendable { /// filled, and a user reasoning about a cascade from what they were asked to approve reached /// the opposite conclusion from what the run would do. internal var scriptText: String { - let script = SQLScriptText(databaseType: request.target.databaseType) + let script = SQLScriptText(databaseType: request.target.databaseType, grammar: targetGrammar) var lines: [String] = [] if case .newDatabase(_, let name, _) = request.destination { lines.append(String(format: String(localized: "-- Create database %@"), name)) diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner+SourceDefinitions.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner+SourceDefinitions.swift index 2a6879073b..7e7b11a27c 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner+SourceDefinitions.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner+SourceDefinitions.swift @@ -152,12 +152,12 @@ internal extension ObjectCopyPlanner { let wanted = Set(names.map { $0.lowercased() }) let scopes = await VerbatimTransferScopes.open(reading: SessionScopeHandle(plugin)) do { - let tables = try await plugin.fetchTables(schema: schema).filter { wanted.contains($0.name.lowercased()) } + let listed = try await plugin.fetchTables(schema: schema) let reads = try await CompareMetadataService.read( - tables: tables, + tables: listed.filter { wanted.contains($0.name.lowercased()) }, schema: schema, profile: .structure, - narrowed: true, + narrowed: CompareMetadataService.narrows(listed, to: wanted), databaseType: databaseType, indexedKinds: SourceObjectIndexes.carriedKinds(on: databaseType), using: plugin diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift index 448133d1ca..ad8c0235c4 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift @@ -42,8 +42,8 @@ internal struct ObjectCopyPlanner { try refuseUpFront(request) /// One pass per source namespace. A database-level copy on PostgreSQL spans every schema, - /// and each schema's tables have to be read, ordered and written in their own scope: one - /// read against a nil schema answers only whatever the connection is currently on. + /// and each schema's tables have to be read and written in their own scope: one read + /// against a nil schema answers only whatever the connection is currently on. var skipped: [ObjectCopySkip] = [] var tableSteps: [ObjectCopyTableStep] = [] var definitionSteps: [ObjectCopyDefinitionStep] = [] @@ -99,6 +99,7 @@ internal struct ObjectCopyPlanner { passes.append(ScopePass( scope: scope, sourceEndpoint: sourceEndpoint, + sourceNamespace: ObjectCopyNamespace.name(for: sourceEndpoint), targetEndpoint: targetEndpoint, sourceReads: sourceReads, targetObjects: targetObjects, @@ -106,6 +107,20 @@ internal struct ObjectCopyPlanner { skipped: tableSkips )) } + let parentsFirst = Self.orderAcrossScopes(&passes) + let drafts = passes.flatMap(\.drafts) + var keysInside = Self.keysInside( + passes.map { (namespace: $0.sourceNamespace, tables: $0.drafts.map(\.selection)) }, + reads: Dictionary(drafts.map { ($0.selection, $0.read) }, uniquingKeysWith: { first, _ in first }), + created: Set(drafts.filter(\.writesStructure).map(\.selection)), + rows: Dictionary( + drafts.filter(\.copiesData).map { draft in + let scope = draft.rowScope + return (draft.selection, !VerbatimTransferScopes.filtersRows(scope) && scope?.rowLimit == nil) + }, + uniquingKeysWith: { first, _ in first } + ) + ) /// Every source scope of a copy to a chosen target lands in the one schema that was chosen, /// so the names its indexes take are allocated across scopes, not afresh for each. @@ -144,6 +159,8 @@ internal struct ObjectCopyPlanner { skipped: &skipped ) } + tableSteps.sort { parentsFirst($0.selection, $1.selection) } + keysInside.rows = keysInside.rows && tableSteps.filter(\.copiesData).allSatisfy { $0.keyChecks != nil } return ObjectCopyPlan( request: request, @@ -152,8 +169,40 @@ internal struct ObjectCopyPlanner { definitionSteps: definitionSteps, schemaStatements: try await buildSchemaStatements(request, namespaces: targetNamespaces), skipped: skipped, - isStructureReadPinned: isStructureReadPinned + isStructureReadPinned: isStructureReadPinned, + keysInside: keysInside, + targetGrammar: SQLLexicalResolver.executionGrammar( + for: request.target.databaseType, connectionId: request.target.connectionId + ) + ) + } + + /// Settles one dependency order over every scope's tables and puts each pass's drafts in it. + /// + /// Sorted one scope at a time, a PostgreSQL database with `app.orders` referencing `core.users` + /// created `orders` first, because `app` was the first scope. The drafts are reordered before + /// any step is built, because a sequence several tables share is created under the first. + private static func orderAcrossScopes( + _ passes: inout [ScopePass] + ) -> (ObjectCopySelection, ObjectCopySelection) -> Bool { + var reads: [ObjectCopySelection: TableStructureRead] = [:] + for draft in passes.flatMap(\.drafts) { + reads[draft.selection] = draft.read + } + let ordered = orderedByDependency( + passes.map { (namespace: $0.sourceNamespace, tables: $0.drafts.map(\.selection)) }, + reads: reads ) + let rank = Dictionary( + ordered.enumerated().map { ($0.element, $0.offset) }, uniquingKeysWith: { first, _ in first } + ) + let parentsFirst = { (lhs: ObjectCopySelection, rhs: ObjectCopySelection) in + (rank[lhs] ?? .max) < (rank[rhs] ?? .max) + } + for index in passes.indices { + passes[index].drafts.sort { parentsFirst($0.selection, $1.selection) } + } + return parentsFirst } private func sourceTableReads( @@ -203,7 +252,12 @@ internal struct ObjectCopyPlanner { scope: targetScope(request, endpoint: request.target) ) { driver in guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { return [] } - let scriptText = SQLScriptText(databaseType: driver.connection.type) + let scriptText = SQLScriptText( + databaseType: driver.connection.type, + grammar: SQLLexicalResolver.readings( + for: driver.connection.type, session: driver.sessionLexicalState + ).execution + ) return namespaces.flatMap { name -> [SyncStatement] in guard let sql = plugin.createSchemaStatement(name: name) else { return [] } let summary = String(format: String(localized: "Create schema %@"), name) @@ -231,10 +285,14 @@ internal struct ObjectCopyPlanner { private struct ScopePass { let scope: Scope let sourceEndpoint: DatabaseEndpoint + /// The engine's own namespace, not the endpoint's schema. MySQL reports no schema on a + /// table while its foreign keys carry the database name, so ordering by the schema alone + /// matched no edge and fell through to alphabetical order. + let sourceNamespace: String? let targetEndpoint: DatabaseEndpoint let sourceReads: [TableStructureRead] let targetObjects: [String: ObjectCopySelection] - let drafts: [ObjectCopyTableDraft] + var drafts: [ObjectCopyTableDraft] let skipped: [ObjectCopySkip] } @@ -283,7 +341,9 @@ internal struct ObjectCopyPlanner { from: request.source.databaseType, to: request.target.databaseType, sourceLanguage: PluginManager.shared.editorLanguage(for: request.source.databaseType), - targetLanguage: PluginManager.shared.editorLanguage(for: request.target.databaseType) + targetLanguage: PluginManager.shared.editorLanguage(for: request.target.databaseType), + sourceSession: manager.driver(for: request.source.connectionId)?.sessionLexicalState, + targetSession: manager.driver(for: request.target.connectionId)?.sessionLexicalState ) { throw ObjectCopyError.refused(reason) } @@ -412,20 +472,13 @@ internal struct ObjectCopyPlanner { } guard !reads.isEmpty else { return [] } - /// The engine's own namespace, not the endpoint's schema. MySQL reports no schema on a - /// table while its foreign keys carry the database name, so ordering by the schema alone - /// matched no edge and fell through to alphabetical order. - let sourceNamespace = ObjectCopyNamespace.name(for: sourceEndpoint) - /// Seeded from the user's own order, not from `reads.keys`. Swift seeds Dictionary hashing - /// per process, so taking the keys gave the tables with no foreign key between them a - /// different tie-break on every launch: the approved script, the progress order and the - /// outcome list were all shuffled differently for the same copy. + /// In the user's own order, not `reads.keys`: Swift seeds Dictionary hashing per process, so + /// the tie-break between unrelated tables changed on every launch and shuffled the approved + /// script. `orderAcrossScopes` sorts them once every scope is read. var drafts: [ObjectCopyTableDraft] = [] let targetServerVersion = manager.driver(for: request.target.connectionId)?.serverVersion - for selection in Self.orderedByDependency( - scope.objects.filter { reads[$0] != nil }, reads: reads, effectiveSchema: sourceNamespace - ) { - guard let read = reads[selection], let snapshot = read.sourceSnapshot else { continue } + for selection in scope.objects { + guard let read = reads.removeValue(forKey: selection), let snapshot = read.sourceSnapshot else { continue } let targetRead = match(selection, in: targetReads) let existsInTarget = targetRead != nil if existsInTarget, request.existingPolicy == .skip { @@ -500,7 +553,9 @@ internal struct ObjectCopyPlanner { coercer: draft.coercer, serverSideInsert: draft.copiesData ? serverSide[draft.selection.id] : nil, hasRowFilter: VerbatimTransferScopes.filtersRows(draft.rowScope), - note: draft.note + note: draft.note, + keyChecks: statements.keyChecks, + rollsBack: draft.rollsBack ) } } @@ -739,6 +794,9 @@ internal struct ObjectCopyPlanner { ddl.create = try builder.build( operations: [.createTable(input.snapshot)], foreignKeysByTable: [:] ) + ddl.keyChecks = Self.keyChecks( + for: input.snapshot, schema: input.targetSchema, driver: plugin, databaseType: driver.connection.type + ) } if input.emptiesFirst { ddl.truncate = Self.emptyStatements( @@ -822,6 +880,36 @@ internal struct ObjectCopyPlanner { ) } + /// Turning the target's checks back on checks nothing already written, so a copy that wrote + /// with them off counts what each key would have refused before it commits. Nil when a key's + /// columns do not pair up, since that one could not be checked. + nonisolated static func keyChecks( + for snapshot: TableStructureSnapshot, + schema: String?, + driver: any PluginDatabaseDriver, + databaseType: DatabaseType + ) -> [ObjectCopyKeyCheck]? { + let quote = driver.quoteIdentifier + let child = SchemaQualifiedName.render(name: snapshot.name, schema: schema, databaseType: databaseType, quote: quote) + var checks: [ObjectCopyKeyCheck] = [] + for key in snapshot.foreignKeys { + guard !key.columns.isEmpty, key.columns.count == key.referencedColumns.count else { return nil } + let parent = SchemaQualifiedName.render( + name: key.referencedTable, schema: key.referencedSchema ?? schema, databaseType: databaseType, quote: quote + ) + // A key with any column NULL holds nothing, the way MATCH SIMPLE reads it. + let present = key.columns.map { "c.\(quote($0)) IS NOT NULL" }.joined(separator: " AND ") + let matches = zip(key.columns, key.referencedColumns) + .map { "p.\(quote($1)) = c.\(quote($0))" } + .joined(separator: " AND ") + checks.append(ObjectCopyKeyCheck( + constraint: key.name, + sql: "SELECT COUNT(*) FROM \(child) c WHERE \(present) AND NOT EXISTS (SELECT 1 FROM \(parent) p WHERE \(matches))" + )) + } + return checks + } + /// What empties a table before its rows are written. /// /// DELETE whenever the run promises to roll the table back. TRUNCATE commits implicitly on the @@ -849,7 +937,8 @@ internal struct ObjectCopyPlanner { format: String(localized: "Every row already in %@ is removed."), table ) )] - return SQLScriptText(databaseType: databaseType).sendableStatements(sql).map { statement in + let grammar = SQLLexicalResolver.readings(for: databaseType, session: driver.sessionLexicalState).execution + return SQLScriptText(databaseType: databaseType, grammar: grammar).sendableStatements(sql).map { statement in SyncStatement(sql: statement, objectName: table, summary: summary, hazards: hazards) } } @@ -881,7 +970,10 @@ internal struct ObjectCopyPlanner { /// engines alone, so one skip per object here costs nothing while reading every body first /// and discarding all of them costs a round trip per object. if let reason = ObjectCopyEligibility.definitionEngineRefusal( - from: request.source.databaseType, to: request.target.databaseType + from: request.source.databaseType, + to: request.target.databaseType, + sourceSession: manager.driver(for: request.source.connectionId)?.sessionLexicalState, + targetSession: manager.driver(for: request.target.connectionId)?.sessionLexicalState ) { skipped += selections.map { ObjectCopySkip(selection: $0, reason: reason) } return [] @@ -907,7 +999,12 @@ internal struct ObjectCopyPlanner { sourceReads: sourceReads, connection: connection ) - let targetScriptText = SQLScriptText(databaseType: request.target.databaseType) + let targetScriptText = SQLScriptText( + databaseType: request.target.databaseType, + grammar: SQLLexicalResolver.executionGrammar( + for: request.target.databaseType, connectionId: request.target.connectionId + ) + ) var pending: [( selection: ObjectCopySelection, definition: String, read: RoutineSourceRead, target: ObjectCopySelection? )] = [] @@ -1047,28 +1144,31 @@ internal struct ObjectCopyPlanner { /// Parents before children, so a foreign key in a `CREATE TABLE` finds the table it points at. /// A cycle keeps whatever order the sort settles on and fails at the server, which is the same /// answer Compare & Sync gives it. - /// `effectiveSchema` is the scope the tables were read in, and it is load-bearing. + /// + /// Every scope goes into one sort, because a key may point into another scope's table. Each + /// scope's `namespace` is the one its tables were read in, and it is load-bearing. /// `fetchTables` returns `PluginTableInfo.schema == nil` on MySQL and PostgreSQL while their /// foreign keys carry a non-nil `referencedSchema`, so nodes built from the table's own schema /// were keyed `child` while the dependency named `public.parent`. No edge ever matched, every /// table looked independent, and the sort fell through to alphabetical order: a child could be /// created before its parent and the server rejected it. nonisolated internal static func orderedByDependency( - _ selections: [ObjectCopySelection], - reads: [ObjectCopySelection: TableStructureRead], - effectiveSchema: String? + _ scopes: [(namespace: String?, tables: [ObjectCopySelection])], + reads: [ObjectCopySelection: TableStructureRead] ) -> [ObjectCopySelection] { + let selections = scopes.flatMap { $0.tables } guard selections.count > 1 else { return selections } var foreignKeysByTable: [String: [PluginForeignKeyInfo]] = [:] var nodes: [ForeignKeyTopologicalSort.Table] = [] var bySortKey: [String: ObjectCopySelection] = [:] - for selection in selections { - guard let read = reads[selection] else { continue } - let schema = read.table.schema ?? selection.schema ?? effectiveSchema - let node = ForeignKeyTopologicalSort.Table(name: read.table.name, schema: schema) - nodes.append(node) - foreignKeysByTable[node.identifier] = read.foreignKeys - bySortKey[node.identifier] = selection + for scope in scopes { + for selection in scope.tables { + guard let read = reads[selection] else { continue } + let node = sortNode(selection, read: read, namespace: scope.namespace) + nodes.append(node) + foreignKeysByTable[node.identifier] = read.foreignKeys + bySortKey[node.identifier] = selection + } } var emitted: Set = [] @@ -1089,6 +1189,51 @@ internal struct ObjectCopyPlanner { return result } + /// `created` holds each table whose structure the copy writes, and `rows` each table that gets + /// rows, true when it gets every one. Keys are read from the source, which is what the target + /// holds only for a table the copy creates: one it keeps may carry keys of its own. + nonisolated internal static func keysInside( + _ scopes: [(namespace: String?, tables: [ObjectCopySelection])], + reads: [ObjectCopySelection: TableStructureRead], + created: Set, + rows: [ObjectCopySelection: Bool] + ) -> ObjectCopyKeysInside { + var nodes: [(selection: ObjectCopySelection, keys: [PluginForeignKeyInfo], node: ForeignKeyTopologicalSort.Table)] = [] + for scope in scopes { + for selection in scope.tables { + guard let read = reads[selection] else { return ObjectCopyKeysInside() } + nodes.append((selection, read.foreignKeys, sortNode(selection, read: read, namespace: scope.namespace))) + } + } + func pointsWithin( + _ keys: [PluginForeignKeyInfo], from node: ForeignKeyTopologicalSort.Table, _ tables: Set + ) -> Bool { + keys.allSatisfy { key in + tables.contains(ForeignKeyTopologicalSort.Table( + name: key.referencedTable, schema: key.referencedSchema ?? node.schema + ).identifier) + } + } + let present = Set(nodes.map(\.node.identifier)) + let filled = Set(nodes.filter { rows[$0.selection] == true }.map(\.node.identifier)) + return ObjectCopyKeysInside( + created: nodes.filter { created.contains($0.selection) }.allSatisfy { + pointsWithin($0.keys, from: $0.node, present) + }, + rows: nodes.filter { rows[$0.selection] != nil }.allSatisfy { + created.contains($0.selection) && pointsWithin($0.keys, from: $0.node, filled) + } + ) + } + + nonisolated private static func sortNode( + _ selection: ObjectCopySelection, + read: TableStructureRead, + namespace: String? + ) -> ForeignKeyTopologicalSort.Table { + ForeignKeyTopologicalSort.Table(name: read.table.name, schema: read.table.schema ?? selection.schema ?? namespace) + } + /// A view selects from a table, a routine calls a view, and a trigger hangs off a table, so the /// kinds run in that order. Within a kind the user's own order is kept. nonisolated internal static func orderedByKind(_ selections: [ObjectCopySelection]) -> [ObjectCopySelection] { diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift index 2277b22a2b..2ed4008ce1 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift @@ -38,9 +38,15 @@ internal struct ObjectCopyRowCopier: Sendable { internal struct Outcome: Sendable { internal let inserted: Int internal let cancelled: Bool - /// What is in the target whatever happens next. Zero where the caller holds a transaction - /// it can roll back, and every flushed batch where it does not. - internal var committed: Int = 0 + } + + /// A table that failed after some of its batches were written. Outside a transaction those stay + /// in the target, and a count that left them out would send a retry over them. + internal struct PartialFailure: LocalizedError { + internal let written: Int + internal let underlying: any Error + + internal var errorDescription: String? { underlying.localizedDescription } } internal let step: ObjectCopyTableStep @@ -92,29 +98,35 @@ internal struct ObjectCopyRowCopier: Sendable { var inserted = 0 - while let element = try await stream.next() { + do { + while let element = try await stream.next() { + if Task.isCancelled { return Outcome(inserted: inserted, cancelled: true) } + guard case .rows(let rows) = element else { continue } + for row in rows { + let values = try aligned(row) + let rowBytes = budget.byteCount(of: values) + guard let batch = filler.append(values, bytes: rowBytes) else { continue } + inserted += try await write(batch, generator: generator, driver: targetDriver) + onProgress(inserted) + if Task.isCancelled { return Outcome(inserted: inserted, cancelled: true) } + } + } + + /// Checked again here. Cancellation can reach an `AsyncThrowingStream` as an ordinary end + /// of stream, or land after its last element, and the loop then falls out with rows still + /// pending: writing them committed a batch the user had already stopped and reported the + /// table as copied. if Task.isCancelled { return Outcome(inserted: inserted, cancelled: true) } - guard case .rows(let rows) = element else { continue } - for row in rows { - let values = try aligned(row) - let rowBytes = budget.byteCount(of: values) - guard let batch = filler.append(values, bytes: rowBytes) else { continue } + if let batch = filler.take() { inserted += try await write(batch, generator: generator, driver: targetDriver) onProgress(inserted) - if Task.isCancelled { return Outcome(inserted: inserted, cancelled: true) } } + return Outcome(inserted: inserted, cancelled: Task.isCancelled) + } catch is CancellationError { + return Outcome(inserted: inserted, cancelled: true) + } catch where inserted > 0 { + throw PartialFailure(written: inserted, underlying: error) } - - /// Checked again here. Cancellation can reach an `AsyncThrowingStream` as an ordinary end - /// of stream, or land after its last element, and the loop then falls out with rows still - /// pending: writing them committed a batch the user had already stopped and reported the - /// table as copied. - if Task.isCancelled { return Outcome(inserted: inserted, cancelled: true) } - if let batch = filler.take() { - inserted += try await write(batch, generator: generator, driver: targetDriver) - onProgress(inserted) - } - return Outcome(inserted: inserted, cancelled: Task.isCancelled) } // MARK: - Server side @@ -139,9 +151,6 @@ internal struct ObjectCopyRowCopier: Sendable { /// worse-looking number; over-reporting one is a false claim about their data. let inserted = result.rowsAffected onProgress(inserted) - /// `committed` is left at zero, as the streamed path leaves it: whether these rows survive - /// is the caller's transaction to decide, and it is the caller that fills it in when there - /// is no transaction to roll back. return Outcome(inserted: inserted, cancelled: false) } diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift index f9208a3a5b..7c758ee83d 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift @@ -165,11 +165,10 @@ internal struct ObjectCopyRunner { /// the table it points at and a trigger before the table that owns it. One parent-first /// pass had every DROP rejected by the constraint below it. /// - /// Both phases run under one `withMetadataDriver` call and, where the engine has them, - /// with foreign key checks off and a transaction around them. Split across two calls, a - /// Stop between them or a failing first CREATE left every later object dropped with - /// nothing put back. - if !plan.schemaStatements.isEmpty || !plan.cleanupGroups.isEmpty || !plan.creationGroups.isEmpty { + /// Both phases run under one `withMetadataDriver` call, in a transaction or with foreign + /// key checks off. Split across two calls, a Stop between them or a failing first CREATE + /// left every later object dropped with nothing put back. + if !(plan.schemaStatements + plan.cleanupStatements + plan.creationStatements).isEmpty { let result = try await runStructure(plan, request: request, progress: progress) outcomes += result.outcomes cancelled = cancelled || result.cancelled @@ -185,16 +184,20 @@ internal struct ObjectCopyRunner { /// A run that promises a rollback empties them inside the data phase's own transaction /// instead, because a DELETE that has already committed cannot be taken back by anything /// that fails afterwards. Only a run that promises nothing clears here. + var uncleared: Set = [] if !cancelled, !plan.clearGroups.isEmpty, !plan.clearsInsideDataTransaction { let result = try await runDDL(plan.clearGroups, request: request, progress: progress) - outcomes += result.outcomes.filter { $0.error != nil } + let failures = result.outcomes.filter { $0.error != nil } + outcomes += failures + // A table that kept its own rows would get the source's on top of them. + uncleared = Set(failures.map(\.id)) cancelled = cancelled || result.cancelled pendingInSessionTransaction = pendingInSessionTransaction || result.joinedSessionTransaction if result.stopped { return finished() } } if !cancelled, request.content.includesData { - let dataOutcomes = try await runData(plan, request: request, progress: progress) + let dataOutcomes = try await runData(plan, skipping: uncleared, request: request, progress: progress) outcomes += dataOutcomes.outcomes cancelled = cancelled || dataOutcomes.cancelled rowsCopied = dataOutcomes.rowsCopied @@ -237,8 +240,8 @@ internal struct ObjectCopyRunner { /// /// A replacement is destructive only in the moment between its DROP and its CREATE, so the two /// have to be one unit as far as the engine can make them: a transaction where DDL is - /// transactional, and otherwise at least one connection lease with foreign key checks off so - /// the ordering cannot fail half way. + /// transactional and the run promises a rollback, and otherwise at least one connection lease + /// with foreign key checks off so the ordering cannot fail half way. private func runStructure( _ plan: ObjectCopyPlan, request: ObjectCopyRequest, @@ -250,6 +253,11 @@ internal struct ObjectCopyRunner { let scope = request.target.scope let hasCleanup = !plan.cleanupGroups.isEmpty let schemaStatements = plan.schemaStatements + // A MySQL DROP refuses a table another references, and a CREATE a key to a table not there + // yet, which a cycle always has. Past a replacement, a key to a table outside the copy keeps + // the checks, so it still fails when that table is missing. + let dropsTables = plan.cleanupGroups.contains { !$0.statements.isEmpty } + let relaxesKeys = tableGroups.contains { !$0.statements.isEmpty } && (dropsTables || plan.keysInside.created) let pinsSession = plan.isStructureReadPinned && VerbatimTransferScopes.isSameEngine( source: request.source.databaseType, target: request.target.databaseType ) @@ -259,23 +267,30 @@ internal struct ObjectCopyRunner { throw ObjectCopyError.refused(Self.noTargetDriver) } let owner = await Self.transactionOwner(of: driver) - let usesTransaction = hasCleanup && plugin.supportsTransactionalDDL && owner.opensTransaction - let relaxesForeignKeys = hasCleanup && !usesTransaction + // The other two settings keep what ran, and PostgreSQL commits an aborted transaction as a ROLLBACK. + let usesTransaction = hasCleanup && errorHandling == .stopAndRollback + && plugin.supportsTransactionalDDL && owner.opensTransaction if usesTransaction { try await plugin.beginTransaction(mode: .readWrite) } - if relaxesForeignKeys { - for statement in plugin.foreignKeyDisableStatements() ?? [] { - _ = try? await plugin.execute(query: statement) + + var result: DDLResult + do { + result = try await Self.withForeignKeysRelaxed( + on: plugin, when: relaxesKeys && !usesTransaction && owner != .session + ) { _ in + try await Self.executeTableDDL( + schemaStatements: schemaStatements, + groups: tableGroups, + on: plugin, + pinsSession: pinsSession, + errorHandling: errorHandling, + progress: progress + ) } + } catch { + // The pool never ends a transaction, so the next lease would inherit an aborted one. + if usesTransaction { try? await plugin.rollbackTransaction() } + throw error } - - var result = try await Self.executeTableDDL( - schemaStatements: schemaStatements, - groups: tableGroups, - on: plugin, - pinsSession: pinsSession, - errorHandling: errorHandling, - progress: progress - ) /// Outside the write scope, because a routine or trigger records the session's /// `sql_mode`: it gets its own creation settings, or else the target's. if !result.stopped { @@ -288,14 +303,14 @@ internal struct ObjectCopyRunner { } result.joinedSessionTransaction = owner == .session - if relaxesForeignKeys { - for statement in plugin.foreignKeyEnableStatements() ?? [] { - _ = try? await plugin.execute(query: statement) - } - } guard usesTransaction else { return result } guard result.stopped else { - try await plugin.commitTransaction() + do { + try await plugin.commitTransaction() + } catch { + try? await plugin.rollbackTransaction() + throw error + } return result } try? await plugin.rollbackTransaction() @@ -406,6 +421,40 @@ internal struct ObjectCopyRunner { return ranAny } + /// Parents already come first, but a cycle, a self reference whose child row comes first, or a + /// source row that already breaks a key is written only with the target's checks off. + nonisolated private static func withForeignKeysRelaxed( + on plugin: any PluginDatabaseDriver, + when relaxes: Bool, + _ body: (_ relaxed: Bool) async throws -> T + ) async throws -> T { + guard relaxes else { return try await body(false) } + let opened: PluginSessionScope? + do { + opened = try await plugin.openSessionScope(for: .relaxedForeignKeys) + } catch { + logger.warning("Copying with foreign key checks on: \(error.publicLogShape, privacy: .public)") + opened = nil + } + guard let scope = opened else { return try await body(false) } + // Shielded like a rollback: the connection goes back to the pool after this lease. + func close() async { + do { + try await TaskCancellationShield.run { try await plugin.closeSessionScope(scope) } + } catch { + logger.error("Restoring foreign key checks failed: \(error.publicLogShape, privacy: .public)") + } + } + do { + let value = try await body(true) + await close() + return value + } catch { + await close() + throw error + } + } + /// Each statement shielded like a rollback: the connection goes back to the pool after this /// lease, and a setting left behind would apply to whatever leases it next. nonisolated private static func closeSettings( @@ -453,33 +502,73 @@ internal struct ObjectCopyRunner { var stopped = false /// True when the rows went into a transaction the session already held. var joinedSessionTransaction = false - /// Only what was committed. A cancelled table rolls its rows back, so counting what the - /// copier inserted reported rows the target never kept. + /// Every row written, a stopped or failed table's included. A rollback takes the count back + /// with the rows; a target that opened no transaction keeps both, and leaving them out + /// would send a retry over them. var rowsCopied = 0 + + mutating func recordCopy(of step: ObjectCopyTableStep, inserted: Int) { + rowsCopied += inserted + outcomes.append(ObjectCopyObjectOutcome(selection: step.selection, rowsCopied: inserted, error: nil)) + } + + /// Stop reaching the driver mid-stream is the user stopping, not the copy failing, and the + /// two produce different notifications. + mutating func recordStop(of step: ObjectCopyTableStep, inserted: Int, progress: ObjectCopyProgress) { + cancelled = true + stopped = true + if inserted > 0 { recordCopy(of: step, inserted: inserted) } + progress.setRowsForCurrentObject(0, completedBefore: rowsCopied) + } + + /// False when the run stops here. + mutating func recordFailure( + of step: ObjectCopyTableStep, + _ error: any Error, + errorHandling: ImportErrorHandling, + progress: ObjectCopyProgress + ) -> Bool { + ObjectCopyRunner.logger.error( + "Copy rows failed for \(step.qualifiedTargetName, privacy: .public): \(error.publicLogShape, privacy: .public)" + ) + let written = (error as? ObjectCopyRowCopier.PartialFailure)?.written ?? 0 + rowsCopied += written + progress.setRowsForCurrentObject(0, completedBefore: rowsCopied) + outcomes.append(ObjectCopyObjectOutcome( + selection: step.selection, rowsCopied: written, error: error.localizedDescription + )) + guard errorHandling == .skipAndContinue else { + stopped = true + return false + } + return true + } } private func runData( _ plan: ObjectCopyPlan, + skipping uncleared: Set, request: ObjectCopyRequest, progress: ObjectCopyProgress ) async throws -> DataResult { - guard plan.clearsInsideDataTransaction else { - return await runIndependentTables(plan, request: request, progress: progress) + guard plan.dataRunsInOneTransaction else { + let steps = plan.dataSteps.filter { !uncleared.contains($0.id) } + return await runIndependentTables(steps, request: request, progress: progress) } - return try await runClearedTables(plan, request: request, progress: progress) + return try await runInOneTransaction(plan, request: request, progress: progress) } - /// The clear and every row it makes room for, in one transaction on the target. + /// Every clear and every row, in one transaction on the target. /// /// Emptying a table is reversible only while the transaction that emptied it is still open, so /// the clears cannot run in a phase of their own once the run has promised a rollback. They /// still all run before any table is filled, which is what keeps a cascading foreign key from /// taking rows out of a table the user never selected. /// - /// One transaction for every table is the price of that promise, and it is charged only here: - /// a copy with nothing to empty keeps its per-table transactions, where one table's failure - /// leaves the tables already copied alone. - private func runClearedTables( + /// The rows relax the target's checks only here, where a failure takes every one of them back: + /// committed table by table, a cycle's first table would keep rows pointing at a second that + /// then failed. + private func runInOneTransaction( _ plan: ObjectCopyPlan, request: ObjectCopyRequest, progress: ObjectCopyProgress @@ -491,6 +580,7 @@ internal struct ObjectCopyRunner { let targetType = request.target.databaseType let sourceScope = request.source.scope let errorHandling = request.errorHandling + let relaxesRows = plan.keysInside.rows /// A lease this phase cannot take is a run-level failure with no object to pin it on, and /// it is thrown for the same reason the DDL phases throw it: swallowed into a stopped @@ -507,6 +597,8 @@ internal struct ObjectCopyRunner { var result = DataResult() result.joinedSessionTransaction = owner == .session + // Checks stay on for the clears: a table outside the copy that references a cleared one + // keeps its own ON DELETE rule, and MySQL cannot prove no other database holds one. let cleared = await Self.execute( clearGroups, on: targetPlugin, errorHandling: errorHandling, progress: progress ) @@ -515,24 +607,36 @@ internal struct ObjectCopyRunner { result.stopped = cleared.stopped if !result.stopped { - let copied = await Self.stream( - steps, - from: sourceScope, - into: targetPlugin, - manager: manager, - sourceType: sourceType, - targetType: targetType, - errorHandling: errorHandling, - progress: progress - ) - result.outcomes += copied.outcomes - result.cancelled = result.cancelled || copied.cancelled - result.stopped = copied.stopped - result.rowsCopied = copied.rowsCopied + // Every table here was just created, so the checks cost nothing yet. One the account + // may not run, such as a SELECT on a write-only login, keeps the target's checks on. + let checkable = usesTransaction && relaxesRows + ? await Self.keyChecksRun(steps, on: targetPlugin) + : false + try await Self.withForeignKeysRelaxed(on: targetPlugin, when: checkable) { relaxed in + let copied = await Self.stream( + steps, + from: sourceScope, + into: targetPlugin, + manager: manager, + sourceType: sourceType, + targetType: targetType, + errorHandling: errorHandling, + progress: progress + ) + result.outcomes += copied.outcomes + result.cancelled = result.cancelled || copied.cancelled + result.stopped = copied.stopped + result.rowsCopied = copied.rowsCopied + // A row read while the source was changing, or one the source already broke, + // fails here rather than committing past keys nothing checks again. + if relaxed, !result.stopped { + await Self.checkKeys(of: steps, on: targetPlugin, into: &result, progress: progress) + } + } } guard usesTransaction else { return result } - guard result.stopped, errorHandling != .stopAndCommit else { + guard result.stopped else { /// A commit that fails leaves the transaction open, and the clears are inside it, /// so it is rolled back rather than left for the lease to decide. do { @@ -544,10 +648,16 @@ internal struct ObjectCopyRunner { return result } try? await targetPlugin.rollbackTransaction() - /// The target is back where it started, rows and all, so nothing may be reported - /// as copied and no table may be reported as done. - result.outcomes = result.outcomes.filter { $0.error != nil } - result.rowsCopied = 0 + /// Nothing may be reported as copied that the rollback took back, and no such table as + /// done. A table the engine keeps through a ROLLBACK still holds its rows, and leaving + /// them out would send a retry over them. + let kept = Set(steps.filter { !$0.rollsBack }.map(\.id)) + result.outcomes = result.outcomes.compactMap { outcome in + if kept.contains(outcome.id) { return outcome } + guard let error = outcome.error else { return nil } + return ObjectCopyObjectOutcome(selection: outcome.selection, rowsCopied: 0, error: error) + } + result.rowsCopied = result.outcomes.filter { kept.contains($0.id) }.reduce(0) { $0 + $1.rowsCopied } return result } } @@ -589,107 +699,63 @@ internal struct ObjectCopyRunner { } } guard !outcome.cancelled else { - result.cancelled = true - result.stopped = true - progress.setRowsForCurrentObject(0, completedBefore: result.rowsCopied) + result.recordStop(of: step, inserted: outcome.inserted, progress: progress) break } - result.rowsCopied += outcome.inserted - result.outcomes.append(ObjectCopyObjectOutcome( - selection: step.selection, rowsCopied: outcome.inserted, error: nil - )) + result.recordCopy(of: step, inserted: outcome.inserted) } catch is CancellationError { - result.cancelled = true - result.stopped = true - progress.setRowsForCurrentObject(0, completedBefore: result.rowsCopied) + result.recordStop(of: step, inserted: 0, progress: progress) break } catch { - logger.error( - "Copy rows failed for \(step.qualifiedTargetName, privacy: .public): \(error.publicLogShape, privacy: .public)" - ) - result.outcomes.append(ObjectCopyObjectOutcome( - selection: step.selection, rowsCopied: 0, error: error.localizedDescription - )) - guard errorHandling == .skipAndContinue else { - result.stopped = true - break - } + guard result.recordFailure(of: step, error, errorHandling: errorHandling, progress: progress) else { break } } } return result } - /// Each table is its own unit of work, so one that fails leaves the ones already copied alone. - /// Both scopes are open at once, which the planner has already established is safe: two scopes - /// on one connection are refused unless both route to the pool. + /// Each batch commits as it is written, so a failure keeps what ran before it, and the target's + /// checks stay on: past a stop or a skipped failure a child's parent rows can be missing. Both + /// scopes are open at once, which the planner has already established is safe: two scopes on + /// one connection are refused unless both route to the pool. private func runIndependentTables( - _ plan: ObjectCopyPlan, + _ steps: [ObjectCopyTableStep], request: ObjectCopyRequest, progress: ObjectCopyProgress ) async -> DataResult { var result = DataResult() let targetType = request.target.databaseType - for step in plan.dataSteps { + for step in steps { if progress.isCancelled || Task.isCancelled { result.cancelled = true result.stopped = true break } progress.startObject(step.qualifiedTargetName) - let wrapsInTransaction = request.wrapEachTableInTransaction - && request.errorHandling != .skipAndContinue do { let copied = try await copyRows( step, request: request, targetType: targetType, - wrapsInTransaction: wrapsInTransaction, completedBefore: result.rowsCopied, progress: progress ) let outcome = copied.outcome result.joinedSessionTransaction = result.joinedSessionTransaction || copied.joinedSessionTransaction - /// A cancelled table that rolled back neither counts as copied nor reads as an - /// object that succeeded. On a target without transactions nothing rolled back, so - /// the batches already flushed are in the target and saying otherwise hides them - /// from a user about to retry and double the rows. guard !outcome.cancelled else { - result.cancelled = true - result.stopped = true - if outcome.committed > 0 { - result.rowsCopied += outcome.committed - result.outcomes.append(ObjectCopyObjectOutcome( - selection: step.selection, rowsCopied: outcome.committed, error: nil - )) - } - progress.setRowsForCurrentObject(0, completedBefore: result.rowsCopied) + result.recordStop(of: step, inserted: outcome.inserted, progress: progress) break } - result.rowsCopied += outcome.inserted - result.outcomes.append(ObjectCopyObjectOutcome( - selection: step.selection, rowsCopied: outcome.inserted, error: nil - )) + result.recordCopy(of: step, inserted: outcome.inserted) } catch is CancellationError { - /// Stop reaching the driver mid-stream is the user stopping, not the copy failing, - /// and the two produce different notifications. - result.cancelled = true - result.stopped = true - progress.setRowsForCurrentObject(0, completedBefore: result.rowsCopied) + result.recordStop(of: step, inserted: 0, progress: progress) break } catch { - Self.logger.error( - "Copy rows failed for \(step.qualifiedTargetName, privacy: .public): \(error.publicLogShape, privacy: .public)" - ) - result.outcomes.append(ObjectCopyObjectOutcome( - selection: step.selection, rowsCopied: 0, error: error.localizedDescription - )) - guard request.errorHandling == .skipAndContinue else { - result.stopped = true - break - } + guard result.recordFailure( + of: step, error, errorHandling: request.errorHandling, progress: progress + ) else { break } } } return result @@ -705,7 +771,6 @@ internal struct ObjectCopyRunner { _ step: ObjectCopyTableStep, request: ObjectCopyRequest, targetType: DatabaseType, - wrapsInTransaction: Bool, completedBefore: Int, progress: ObjectCopyProgress ) async throws -> CopiedRows { @@ -714,62 +779,76 @@ internal struct ObjectCopyRunner { let copier = ObjectCopyRowCopier( step: step, sourceDatabaseType: request.source.databaseType, targetDatabaseType: targetType ) - let errorHandling = request.errorHandling /// The injected manager on both sides. Reaching for the singleton on the target bypassed /// the caller's own connections and routing, which is the whole point of injecting one. let manager = self.manager - return try await manager.withMetadataDriver(scope: sourceScope, workload: .bulk) { sourceDriver in - guard let sourcePlugin = CompareMetadataService.pluginDriver(from: sourceDriver) else { - throw ObjectCopyError.refused(Self.noSourceDriver) + // Target first, as `runInOneTransaction` takes them: two copies taking the two leases in + // opposite orders could each hold the one the other waits for. + return try await manager.withMetadataDriver(scope: targetScope, workload: .bulk) { targetDriver in + guard let targetPlugin = CompareMetadataService.pluginDriver(from: targetDriver) else { + throw ObjectCopyError.refused(Self.noTargetDriver) } + let owner = await Self.transactionOwner(of: targetDriver) return try await manager.withMetadataDriver( - scope: targetScope, workload: .bulk - ) { targetDriver in - guard let targetPlugin = CompareMetadataService.pluginDriver(from: targetDriver) else { - throw ObjectCopyError.refused(Self.noTargetDriver) + scope: sourceScope, workload: .bulk + ) { sourceDriver in + guard let sourcePlugin = CompareMetadataService.pluginDriver(from: sourceDriver) else { + throw ObjectCopyError.refused(Self.noSourceDriver) } - let owner = await Self.transactionOwner(of: targetDriver) - let usesTransaction = wrapsInTransaction && owner.opensTransaction - if usesTransaction { - try await targetPlugin.beginTransaction(mode: .readWrite) + let outcome = try await copier.copy(from: sourcePlugin, to: targetPlugin) { rows in + progress.setRowsForCurrentObject(rows, completedBefore: completedBefore) } + return CopiedRows(outcome: outcome, joinedSessionTransaction: owner == .session) + } + } + } + + nonisolated private static func keyChecksRun( + _ steps: [ObjectCopyTableStep], + on driver: any PluginDatabaseDriver + ) async -> Bool { + for check in steps.flatMap({ $0.keyChecks ?? [] }) { + do { + _ = try await driver.execute(query: check.sql) + } catch { + logger.warning( + "Copying with foreign key checks on, \(check.constraint, privacy: .public) cannot be checked: \(error.publicLogShape, privacy: .public)" + ) + return false + } + } + return true + } + + /// Stops at the first key that fails, since the rollback that follows takes back every table. + nonisolated private static func checkKeys( + of steps: [ObjectCopyTableStep], + on driver: any PluginDatabaseDriver, + into result: inout DataResult, + progress: ObjectCopyProgress + ) async { + for step in steps { + for check in step.keyChecks ?? [] { + if progress.isCancelled || Task.isCancelled { + result.cancelled = true + result.stopped = true + return + } + let failure: String do { - let outcome = try await copier.copy( - from: sourcePlugin, - to: targetPlugin - ) { rows in - progress.setRowsForCurrentObject(rows, completedBefore: completedBefore) - } - guard usesTransaction else { - /// Nothing of the copy's own to roll back, so every batch already flushed is - /// in the target, or pending in the transaction the session already held, - /// whether the user stopped or not. - return CopiedRows( - outcome: ObjectCopyRowCopier.Outcome( - inserted: outcome.inserted, - cancelled: outcome.cancelled, - committed: outcome.inserted - ), - joinedSessionTransaction: owner == .session - ) - } - if outcome.cancelled { - try? await targetPlugin.rollbackTransaction() - } else { - try await targetPlugin.commitTransaction() - } - return CopiedRows(outcome: outcome, joinedSessionTransaction: false) + let counted = try await driver.execute(query: check.sql).rows.first?.first?.asText + guard let broken = counted.flatMap(Int.init), broken > 0 else { continue } + failure = String( + format: String(localized: "Rows in %1$@ break the foreign key %2$@."), + step.qualifiedTargetName, check.constraint + ) } catch { - if usesTransaction { - if errorHandling == .stopAndCommit { - try? await targetPlugin.commitTransaction() - } else { - try? await targetPlugin.rollbackTransaction() - } - } - throw error + failure = error.localizedDescription } + result.outcomes.append(ObjectCopyObjectOutcome(selection: step.selection, rowsCopied: 0, error: failure)) + result.stopped = true + return } } } diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift index d70010ee22..3c3b28e2e1 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift @@ -183,7 +183,9 @@ internal final class ObjectCopySession: ObservableObject { from: source.databaseType, to: target.databaseType, sourceLanguage: PluginManager.shared.editorLanguage(for: source.databaseType), - targetLanguage: PluginManager.shared.editorLanguage(for: target.databaseType) + targetLanguage: PluginManager.shared.editorLanguage(for: target.databaseType), + sourceSession: SQLLexicalResolver.sessionState(for: source.connectionId), + targetSession: SQLLexicalResolver.sessionState(for: target.connectionId) ) { return reason } diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyTableDraft.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyTableDraft.swift index 5b354b0923..3fd3ed1724 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyTableDraft.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyTableDraft.swift @@ -16,6 +16,8 @@ import TableProPluginKit /// each run over the whole list. internal struct ObjectCopyTableDraft { let selection: ObjectCopySelection + /// Kept for the dependency order, which waits until every scope has been read. + let read: TableStructureRead let snapshot: TableStructureSnapshot /// The same table said in the target's own types, which is what the target driver is handed. /// Identical to `snapshot` whenever the two engines share a type family, apart from an index the @@ -41,6 +43,8 @@ internal struct ObjectCopyTableDraft { /// The `WHERE` and row limit the user set on this table, or nil for every row. let rowScope: PluginExportRowScope? let note: String? + /// False for a MySQL table whose storage engine keeps its rows through a ROLLBACK, such as MyISAM. + let rollsBack: Bool init( selection: ObjectCopySelection, @@ -54,6 +58,7 @@ internal struct ObjectCopyTableDraft { request: ObjectCopyRequest ) { self.selection = selection + self.read = read self.snapshot = snapshot self.rowScope = request.rowScope(for: selection) self.sourceSchema = sourceSchema @@ -79,6 +84,10 @@ internal struct ObjectCopyTableDraft { ) self.targetStructure = translation.snapshot self.isCrossEngine = translation.translated + self.rollsBack = !DataSyncTransactionality.cannotRollBack( + storageEngine: writesStructure ? translation.snapshot.engine : targetSnapshot?.engine, + databaseType: request.target.databaseType + ) /// Only a run that writes the structure has anything to report about it. Appending into a /// table the target already has changes no type, so a list of conversions would describe /// DDL that is not going to run. @@ -232,6 +241,7 @@ internal struct ObjectCopyTableDDL: Sendable { var drop: [SyncStatement] = [] var create: [SyncStatement] = [] var truncate: [SyncStatement] = [] + var keyChecks: [ObjectCopyKeyCheck]? } internal enum ObjectCopyError: LocalizedError { diff --git a/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift b/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift index 2834f53641..a05f015296 100644 --- a/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift +++ b/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift @@ -175,6 +175,8 @@ extension DatabaseType { Preferred tries TLS first, falls back to plain. Matches psql and DataGrip defaults. \ Required by AWS RDS, Cloud SQL, Heroku, Supabase, Neon. """) + case "KingbaseES": + return String(localized: "Preferred tries TLS first, falls back to plain.") case _ where pluginTypeId == "MySQL": return String(localized: """ Preferred performs a 2-pass connect: tries TLS first, falls back to plain only on \ diff --git a/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift index 0f0d3cc92f..3a57d99e8c 100644 --- a/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift +++ b/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift @@ -408,7 +408,10 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable /// Through the same owner Compare & Sync saves its scripts with, so a dump ends an Oracle unit on a `/` line and /// puts a MySQL routine in a `DELIMITER` block exactly as a saved sync script does. func scriptText(for ddl: String) -> String { - SQLScriptText(databaseType: dbType).scriptText(forDriverText: ddl) + SQLScriptText( + databaseType: dbType, + grammar: SQLLexicalResolver.readings(for: dbType, session: driver.sessionLexicalState).execution + ).scriptText(forDriverText: ddl) } /// `tableType` carries the routine's own kind for a `.routine`, because `DROP FUNCTION` and diff --git a/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift b/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift index f4fa5c70ea..4478e37f26 100644 --- a/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift +++ b/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift @@ -97,7 +97,7 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable { self.joinsTransaction = joinsTransaction self.driver = driver self.databaseType = databaseType - self.grammar = databaseType.lexicalGrammar + self.grammar = SQLLexicalResolver.readings(for: databaseType, session: driver.sessionLexicalState).execution self.tableEditDialect = TableEditDialect.of(databaseType) self.databaseTypeId = databaseType.rawValue self.targetTable = targetTable @@ -600,9 +600,8 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable { /// `ROLLBACK TO SAVEPOINT` keeps the transaction, and the classifier already reads it as not ending one. private func fileTransactionEnd(of statement: String) -> FileTransactionEnd? { - guard CatalogChangeClassifier.effect(of: statement, databaseType: databaseType).endsTransaction else { - return nil - } + let effect = CatalogChangeClassifier.effect(of: statement, databaseType: databaseType, grammar: grammar) + guard effect.endsTransaction else { return nil } return Self.rollbackKeywords.contains(QueryClassifier.leadingKeyword(of: statement)) ? .rollback : .commit } diff --git a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift index 02d3916b03..9f88679c97 100644 --- a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift +++ b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift @@ -13,7 +13,7 @@ enum ImportTypeMapper { serverVersion: String? = nil ) -> String { switch databaseType { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift, .cockroachdb, .kingbasees: return postgresType( type, jsonColumnType: PostgreSQLServerVersion.jsonColumnType( diff --git a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift index 687fa88b57..c32a51f3aa 100644 --- a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -1235,6 +1235,26 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor try await pluginDriver.concurrentRefreshAvailability(materializedView: materializedView, schema: schema) } + // MARK: - Table Options + + func fetchTableOptions(tableName: String) async throws -> TableOptions? { + try await pluginDriver.fetchTableOptions(table: tableName, schema: pluginDriver.currentSchema) + .map(TableOptions.init) + } + + func fetchTableOptionsCatalog() async throws -> TableOptionsCatalog? { + try await pluginDriver.fetchTableOptionsCatalog().map(TableOptionsCatalog.init) + } + + func fetchDefaultTableOptions(schema: String?) async throws -> TableOptions? { + try await pluginDriver.fetchDefaultTableOptions(schema: schema ?? pluginDriver.currentSchema) + .map(TableOptions.init) + } + + func tableOptionsStatement(tableName: String, schema: String?, change: PluginTableOptionsChange) -> String? { + pluginDriver.tableOptionsStatement(table: tableName, schema: schema, change: change) + } + // MARK: - Sequences func fetchSequenceDetails(sequence: String, schema: String?) async throws -> PluginSequenceDetails? { diff --git a/apple/TablePro/Core/Plugins/PluginError.swift b/apple/TablePro/Core/Plugins/PluginError.swift index d2f65f94aa..9e461e045e 100644 --- a/apple/TablePro/Core/Plugins/PluginError.swift +++ b/apple/TablePro/Core/Plugins/PluginError.swift @@ -79,9 +79,9 @@ enum PluginError: LocalizedError { case .unknownDatabaseType: return String(localized: "Edit the connection and choose its database type.") case .pluginLoadFailed: - return String(localized: "Update or reinstall the plugin in Settings > Plugins.") + return String(localized: "Update or reinstall the plugin in Settings > Extensions.") case .pluginInstallFailed: - return String(localized: "Try again, or install the plugin from Settings > Plugins.") + return String(localized: "Try again, or install the plugin from Settings > Extensions.") default: return nil } diff --git a/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift b/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift index 9eceec9b11..65b16f6b8f 100644 --- a/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift +++ b/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift @@ -25,7 +25,7 @@ extension PluginManager { let typeId = databaseType.pluginTypeId if let entry = installedEntry(servingTypeId: typeId), !entry.serves(databaseType.rawValue) { return .outdated(reason: String( - format: String(localized: "The installed %1$@ does not support %2$@. Update it in Settings > Plugins."), + format: String(localized: "The installed %1$@ does not support %2$@. Update it in Settings > Extensions."), entry.name, Self.registryDisplayName(of: databaseType) )) diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index 980fbea84a..8b62ed43fc 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift @@ -153,7 +153,10 @@ extension PluginMetadataRegistry { booleanLiteralStyle: .truefalse, likeEscapeStyle: .implicit, paginationStyle: .limit, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ), statementCompletions: [ CompletionEntry(label: "SELECT", insertText: "SELECT"), @@ -339,7 +342,10 @@ extension PluginMetadataRegistry { booleanLiteralStyle: .truefalse, likeEscapeStyle: .implicit, paginationStyle: .limit, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ), statementCompletions: [ CompletionEntry(label: "SELECT", insertText: "SELECT"), @@ -595,7 +601,10 @@ extension PluginMetadataRegistry { likeEscapeStyle: .explicit, paginationStyle: .limit, requiresBackslashEscaping: true, - caseSensitivityStyle: .ilikeOperator + caseSensitivityStyle: .ilikeOperator, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ), statementCompletions: [ CompletionEntry(label: "SELECT", insertText: "SELECT"), diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index b07d3cb752..b194fc5c04 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -50,7 +50,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.mySQL.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.mySQL.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.mySQL.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) let mysqlColumnTypes: [String: [String]] = [ @@ -92,7 +93,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .ilikeOperator, operators: SQLEngineVocabulary.postgreSQL.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.postgreSQL.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.postgreSQL.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) // Redshift ILIKE only folds ASCII, so it uses LOWER on both sides instead. @@ -147,7 +149,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.sqlite.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlite.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlite.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) let sqliteColumnTypes: [String: [String]] = [ @@ -713,7 +716,7 @@ extension PluginMetadataRegistry { ] return defaults + mysqlVariantDefaults( dialect: mysqlDialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: mysqlIdleReleaseField - ) + ) + defaults.filter { $0.typeId == "PostgreSQL" }.flatMap { kingbaseESDefaults(postgreSQL: $0.snapshot) } } } @@ -751,7 +754,8 @@ private extension SQLDialectDescriptor { operators: vocabulary.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: textCastTypeName, functionNamesAreCaseInsensitive: vocabulary.functionNamesAreCaseInsensitive, - lexicalFeatures: lexicalFeatures + lexicalFeatures: lexicalFeatures, + typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive ) } } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+HanaDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+HanaDefaults.swift index c616f7271d..16e6804ba0 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+HanaDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+HanaDefaults.swift @@ -124,7 +124,8 @@ private let hanaDialect = SQLDialectDescriptor( caseSensitivityStyle: .caseFoldFunction, textCastTypeName: nil, functionNamesAreCaseInsensitive: true, - lexicalFeatures: [.dollarAndHashInIdentifiers] + lexicalFeatures: [.dollarAndHashInIdentifiers], + typeNamesAreCaseInsensitive: true ) private func hanaConnectionFields() -> [ConnectionField] { diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift new file mode 100644 index 0000000000..7e42690355 --- /dev/null +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift @@ -0,0 +1,41 @@ +// +// PluginMetadataRegistry+KingbaseESDefaults.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + // Created by initdb on V9R1C10; dbms_sql, sys and wmsys exist only in an Oracle-mode cluster. + static let kingbaseESSystemSchemaNames = [ + "pg_catalog", "information_schema", "pg_toast", "sys_catalog", "sys", "anon", "dbms_job", "dbms_scheduler", + "dbms_sql", "kdb_schedule", "perf", "pg_bitmapindex", "src_restrict", "sys_hm", "sysaudit", "sysmac", "wmsys", + "xlog_record_read", + ] + + /// KingbaseES runs PostgreSQL's catalogs and wire protocol, so its entry is PostgreSQL's with its own identity + /// and without the cloud and Kerberos sign-in fields, which its servers do not offer. + static func kingbaseESDefaults(postgreSQL: PluginMetadataSnapshot) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [("KingbaseES", PluginMetadataSnapshot( + displayName: "KingbaseES", iconName: "cylinder", defaultPort: 54_321, + requiresAuthentication: true, supportsForeignKeys: postgreSQL.supportsForeignKeys, + supportsSchemaEditing: postgreSQL.supportsSchemaEditing, isDownloadable: true, + primaryUrlScheme: "kingbase", parameterStyle: .dollar, + navigationModel: postgreSQL.navigationModel, explainVariants: postgreSQL.explainVariants, + pathFieldRole: .database, supportsHealthMonitor: postgreSQL.supportsHealthMonitor, + urlSchemes: ["kingbase", "kingbase8"], postConnectActions: postgreSQL.postConnectActions, + brandColorHex: "#C9141E", queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: postgreSQL.supportsDatabaseSwitching, + structureEditing: postgreSQL.structureEditing, + capabilities: postgreSQL.capabilities, schema: postgreSQL.schema, editor: postgreSQL.editor, + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: postgreSQL.connection.additionalConnectionFields.filter { + ["usePgpass", "connectionOptions"].contains($0.id) + }, + category: .relational, + tagline: String(localized: "PostgreSQL and Oracle compatible SQL") + ) + ).withSystemNames(databases: postgreSQL.schema.systemDatabaseNames, schemas: kingbaseESSystemSchemaNames))] + } +} diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 5c5b5b6f5b..383f3e0822 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -413,7 +413,10 @@ extension PluginMetadataRegistry { "DATE", "TIME", "TIMESTAMP", "INTERVAL", "PERIOD", "JSON", "XML", ], autoLimitStyle: .top, - caseSensitivityStyle: .unsupported + caseSensitivityStyle: .unsupported, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ), statementCompletions: [], columnTypesByCategory: [ @@ -900,7 +903,7 @@ extension PluginMetadataRegistry { supportsSSH: false, supportsSSL: false, supportsCascadeDrop: false, - supportsForeignKeyDisable: true, + supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift index 2e4454c3ac..8dc79aeb53 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift @@ -138,7 +138,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.sqlServer.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlServer.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlServer.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) let mssqlColumnTypes: [String: [String]] = [ @@ -186,7 +187,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .caseFoldFunction, operators: SQLEngineVocabulary.oracle.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.oracle.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.oracle.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) let oracleColumnTypes: [String: [String]] = [ @@ -244,7 +246,10 @@ extension PluginMetadataRegistry { paginationStyle: .offsetFetch, offsetFetchOrderBy: "ORDER BY 1", autoLimitStyle: .fetchFirst, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) let damengCompletions = [ CompletionEntry(label: "SELECT", insertText: "SELECT"), @@ -304,7 +309,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .ilikeOperator, operators: SQLEngineVocabulary.duckDB.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: "VARCHAR", - functionNamesAreCaseInsensitive: SQLEngineVocabulary.duckDB.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.duckDB.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) let duckdbColumnTypes: [String: [String]] = [ @@ -365,7 +371,10 @@ extension PluginMetadataRegistry { likeEscapeStyle: .explicit, paginationStyle: .limit, autoLimitStyle: .limit, - caseSensitivityStyle: .unsupported + caseSensitivityStyle: .unsupported, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) let cassandraColumnTypes: [String: [String]] = [ @@ -566,7 +575,10 @@ extension PluginMetadataRegistry { booleanLiteralStyle: .numeric, likeEscapeStyle: .explicit, paginationStyle: .limit, - caseSensitivityStyle: .collationDefined + caseSensitivityStyle: .collationDefined, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) let d1ColumnTypes: [String: [String]] = [ diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift index fd09d3271e..4b3bc5e265 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift @@ -137,7 +137,8 @@ private extension SQLDialectDescriptor { operators: operators, textCastTypeName: textCastTypeName, functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive, - lexicalFeatures: lexicalFeatures + lexicalFeatures: lexicalFeatures, + typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive ) } } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift index 4ced33b335..974471c1f2 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift @@ -168,7 +168,10 @@ extension PluginMetadataRegistry { likeEscapeStyle: .explicit, paginationStyle: paginationStyle, offsetFetchOrderBy: "", - caseSensitivityStyle: .regexFlag + caseSensitivityStyle: .regexFlag, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) } diff --git a/apple/TablePro/Core/Plugins/PluginObjectMapping.swift b/apple/TablePro/Core/Plugins/PluginObjectMapping.swift index 4f5898e5ab..ae22dbc476 100644 --- a/apple/TablePro/Core/Plugins/PluginObjectMapping.swift +++ b/apple/TablePro/Core/Plugins/PluginObjectMapping.swift @@ -3,7 +3,7 @@ // TablePro // // The single crossing between PluginKit's column, index, foreign key, check constraint, table -// metadata, routine, trigger and type transfer types and the app's. +// metadata, table options, routine, trigger and type transfer types and the app's. // import Foundation @@ -30,7 +30,8 @@ extension ColumnInfo { ddlDefault: column.ddlDefault, ddlGenerationExpression: column.ddlGenerationExpression, ddlCollation: column.ddlCollation, - classificationTypeName: column.classificationTypeName + classificationTypeName: column.classificationTypeName, + columnCheck: column.columnCheck ) } } @@ -98,6 +99,37 @@ extension TableMetadata { } } +extension TableOptions { + init(_ options: PluginTableOptions) { + self.init( + characterSet: options.characterSet, + collation: options.collation, + autoIncrement: options.autoIncrement + ) + } +} + +extension TableCharacterSet { + init(_ characterSet: PluginCharacterSet) { + self.init( + name: characterSet.name, + defaultCollation: characterSet.defaultCollation, + collations: characterSet.collations, + description: characterSet.description + ) + } +} + +extension TableOptionsCatalog { + init(_ catalog: PluginTableOptionsCatalog) { + self.init( + characterSets: catalog.characterSets.map(TableCharacterSet.init), + engines: catalog.engines, + defaultEngine: catalog.defaultEngine + ) + } +} + extension ObjectAttribute { init(_ attribute: PluginObjectAttribute) { self.init(label: attribute.label, value: attribute.value) diff --git a/apple/TablePro/Core/RoutineDebugging/MainContentCoordinator+RoutineDebugging.swift b/apple/TablePro/Core/RoutineDebugging/MainContentCoordinator+RoutineDebugging.swift new file mode 100644 index 0000000000..606b8dc891 --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/MainContentCoordinator+RoutineDebugging.swift @@ -0,0 +1,214 @@ +// +// MainContentCoordinator+RoutineDebugging.swift +// TablePro +// + +import AppKit +import Foundation +import TableProPluginKit + +extension MainContentCoordinator { + // Answers without I/O, so a context menu or a menu validation can ask. Every read goes through another driver. + private var routineDebugger: (any PluginDatabaseDriver & PluginRoutineDebugging)? { + guard AppModuleSettings.shared.isEnabled(.routineDebugger), + connection.type.supportsConnectionPooling, + let adapter = services.databaseManager.driver(for: connectionId) as? PluginDriverAdapter, + let debugger = adapter.schemaPluginDriver as? any PluginDatabaseDriver & PluginRoutineDebugging, + debugger.canDebugRoutines + else { return nil } + return debugger + } + + func routineDebugTarget(for routine: RoutineInfo) -> PluginDebugTarget? { + routineDebugger?.debugTarget(for: routine.pluginRoutine) + } + + func canChooseRoutineToDebug() -> Bool { + routineDebugger != nil && services.databaseManager.session(for: connectionId)?.isConnected == true + } + + func debugRoutine(_ routine: RoutineInfo) { + debugRoutine(routine, database: nil, schema: nil) + } + + // A sidebar row names its own database and schema, which the run keeps whatever the browse cursor does meanwhile. + func debugRoutine(_ routine: RoutineInfo, database: String?, schema: String?) { + guard let target = routineDebugTarget(for: routine), + let pick = routineDebugPick(for: target, routine: routine, database: database, schema: schema) + else { return } + launchDebugging { flow, setup in + await flow.run(pick, setup: setup) + } + } + + // Read once, at the click; a database the row does not name is the one being browsed. + func routineDebugPick( + for target: PluginDebugTarget, + routine: RoutineInfo, + database: String?, + schema: String? + ) -> RoutineDebugPick? { + guard let scope = services.databaseManager.resolvedScope( + database: database, + schema: schema ?? target.schema ?? routine.schema, + for: connectionId + ) else { return nil } + return RoutineDebugPick(target: target, scope: scope) + } + + // The chooser lists one database and schema; whatever is chosen from it keeps that database. + func chooseRoutineToDebug() { + guard canChooseRoutineToDebug(), let listed = browseScope else { return } + let databaseManager = services.databaseManager + launchDebugging { [weak self] flow, setup in + await flow.choose(setup: setup) { chooserSetup in + guard let window = self?.contentWindow else { return nil } + let target = await RoutineDebugChooserSheet.present(on: window, setup: chooserSetup) { + guard AppModuleSettings.shared.isEnabled(.routineDebugger) else { throw CancellationError() } + return try await databaseManager.withMetadataDriver(scope: listed) { driver in + try await Self.metadataDebugger(driver).debugTargets(schema: listed.schema) + } + } + return target.map { RoutineDebugPick($0, listedIn: listed) } + } + } + } + + var frontRoutineDebugController: RoutineDebugController? { + guard let tab = tabManager.selectedTab, tab.tabType == .routineDebug else { return nil } + return RoutineDebugSessions.shared.controller(forTab: tab.id) + } + + // Not a user close: nothing goes to Reopen Closed Tab and the saved tab set is left alone. + func closeRoutineDebugTabs() { + let ids = tabManager.tabs.filter { $0.tabType == .routineDebug }.map(\.id) + for id in ids { + closeRoutineDebugTab(id) + } + } + + // MARK: - Starting a run + + // The setup is tracked until the flow ends, so turning the module off dismisses whatever it has on screen. + private func launchDebugging( + _ body: @escaping @MainActor (RoutineDebugStartFlow, RoutineDebugSetup) async -> Void + ) { + // Read before anything waits: a disconnect or reconnect while a sheet is up makes this start stale. + let generation = RoutineDebugSessions.shared.admissionGeneration(for: connectionId) + let setup = RoutineDebugSetup() + let flow = startFlow(generation: generation) + RoutineDebugSessions.shared.beginSetup(setup) + Task { + await body(flow, setup) + RoutineDebugSessions.shared.endSetup(setup) + } + } + + private func startFlow(generation: Int) -> RoutineDebugStartFlow { + let databaseManager = services.databaseManager + let options = RoutineDebugOptionStore() + let connectionId = connectionId + return RoutineDebugStartFlow( + isModuleEnabled: { AppModuleSettings.shared.isEnabled(.routineDebugger) }, + prepare: { pick in + let target = pick.target + return try await databaseManager.withMetadataDriver(scope: pick.scope) { driver in + try await Self.metadataDebugger(driver).prepareDebug(target) + } + }, + presentStartSheet: { [weak self] plan, setup in + guard let window = self?.contentWindow else { return nil } + let choice = await RoutineDebugStartSheet.present( + plan: plan, + rememberedOption: { fieldId in options.value(for: fieldId, connectionId: connectionId) }, + on: window, + setup: setup + ) + if let choice { + options.save(RoutineDebugStartSheet.rememberedValues(of: choice, in: plan), connectionId: connectionId) + } + return choice + }, + reportFailure: { [weak self] target, error in + guard !(error is CancellationError) else { return } + AlertHelper.showErrorSheet( + title: String(format: String(localized: "Can't Debug %@"), target.name), + message: RoutineDebugController.message(for: error), + window: self?.contentWindow + ) + }, + open: { [weak self] plan, choice, scope in + self?.openRoutineDebugTab(plan: plan, choice: choice, scope: scope, generation: generation) + } + ) + } + + private func openRoutineDebugTab( + plan: PluginDebugPlan, + choice: RoutineDebugStartChoice, + scope: DatabaseScope, + generation: Int + ) { + // Read again: the connection may have reconnected, or the module gone off, while the sheet was up. + guard let debugger = routineDebugger else { return } + guard RoutineDebugSessions.shared.admits(connectionId, generation: generation) else { + AlertHelper.showErrorSheet( + title: String(format: String(localized: "Can't Debug %@"), plan.target.name), + message: RoutineDebugError.connectionChanged.localizedDescription, + window: contentWindow + ) + return + } + let tabId = UUID() + let preparationSetup = RoutineDebugSetup() + let controller = RoutineDebugController( + plan: plan, + choice: choice, + connectionId: connectionId, + databaseType: connection.type, + debugger: debugger, + sessionSource: RoutineDebugSessionSource.live(scope: scope, generation: generation), + gate: executionGate, + confirmPreparation: { [weak self] preparation in + await RoutineDebugPreparationPrompt.confirm( + preparation, + window: self?.contentWindow, + setup: preparationSetup + ) + }, + withdrawPreparation: { preparationSetup.cancel() } + ) + controller.onCancelled = { [weak self] in + self?.closeRoutineDebugTab(tabId) + } + guard RoutineDebugSessions.shared.register( + controller, + tabId: tabId, + connectionId: connectionId, + generation: generation + ) else { return } + tabManager.addRoutineDebugTab( + id: tabId, + title: QueryTabManager.routineDebugTitle(for: plan.target.name), + databaseName: scope.database, + schemaName: scope.schema + ) + controller.start() + } + + private func closeRoutineDebugTab(_ tabId: UUID) { + RoutineDebugSessions.shared.endSession(forTab: tabId, window: contentWindow) + guard tabManager.tabs.contains(where: { $0.id == tabId }) else { return } + tabManager.closeTab(id: tabId) + } + + // The pooled metadata driver for this read, which is the plugin's own driver behind the adapter. + nonisolated private static func metadataDebugger( + _ driver: DatabaseDriver + ) throws -> any PluginDatabaseDriver & PluginRoutineDebugging { + guard let adapter = driver as? PluginDriverAdapter, + let debugger = adapter.schemaPluginDriver as? any PluginDatabaseDriver & PluginRoutineDebugging + else { throw RoutineDebugError.unsupported } + return debugger + } +} diff --git a/apple/TablePro/Core/RoutineDebugging/RoutineDebugController.swift b/apple/TablePro/Core/RoutineDebugging/RoutineDebugController.swift new file mode 100644 index 0000000000..9393c0b321 --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/RoutineDebugController.swift @@ -0,0 +1,777 @@ +// +// RoutineDebugController.swift +// TablePro +// + +import AppKit +import Combine +import Foundation +import os +import TableProPluginKit + +extension PluginDebugCommand { + static let hostCommands: [PluginDebugCommand] = [.continueExecution, .stepOver, .stepInto, .stepOut] +} + +internal struct RoutineDebugStartChoice: Sendable { + let entryPoint: PluginDebugEntryPoint + let arguments: [PluginDebugArgument] + let optionValues: [String: String] +} + +// One debug run. Session calls never overlap except `stop()`: every other call waits its turn in `lastOperation`. +// ObservableObject rather than @Observable, which needs macOS 14. +@MainActor +internal final class RoutineDebugController: ObservableObject { + enum State { + case preparing + case starting + case running + case paused(PluginDebugLocation) + case finished(PluginQueryResult?) + case failed(String) + // The plugin's own words when a waiting step answered the stop, which can say the routine still runs on the server. + case stopped(String?) + + var isTerminal: Bool { + switch self { + case .finished, .failed, .stopped: true + case .preparing, .starting, .running, .paused: false + } + } + + var pausedLocation: PluginDebugLocation? { + if case .paused(let location) = self { return location } + return nil + } + + var endedByItself: Bool { + switch self { + case .finished, .failed: true + case .preparing, .starting, .running, .paused, .stopped: false + } + } + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "RoutineDebugController") + private static let outputLineLimit = 5_000 + // Output a plugin relays as the routine completes (Oracle's DBMS_OUTPUT) comes through its own task, shortly after. + private static let outputDrainLimit: Duration = .seconds(1) + static let stopNotConfirmed = String( + localized: "The debugger didn't confirm the stop. The routine may still be running on the server." + ) + + let plan: PluginDebugPlan + let choice: RoutineDebugStartChoice + let connectionId: UUID + let databaseType: DatabaseType + + @Published private(set) var state: State = .preparing + @Published private(set) var isStopping = false + @Published private(set) var supportedCommands: Set = [] + @Published private(set) var frames: [PluginDebugFrame] = [] + @Published private(set) var selectedFrameLevel: Int? + // The frame whose source and variables are on screen; they always change together. + @Published private(set) var displayedFrameLevel: Int? + // False while a pause's stack is still loading: the list then shows the previous stack, which cannot be chosen from. + @Published private(set) var stackIsCurrent = false + @Published private(set) var variables: [PluginDebugVariable] = [] + @Published private(set) var outputLines: [String] = [] + @Published private(set) var displayedUnit: PluginDebugUnit? + @Published private(set) var sources: [String: String] = [:] + // What the ribbon shows: placed breakpoints plus edits waiting for the next pause. + @Published private(set) var breakpoints: [String: Set] = [:] + @Published private(set) var inspectionError: String? + @Published private(set) var resultGrid: HeldRowsGridAdapter? + + var cursorLine: Int? + // Set by the coordinator: the user declined to start, so the tab has nothing to show. + var onCancelled: (@MainActor () -> Void)? + + private let debugger: any PluginRoutineDebugging + private let sessionSource: RoutineDebugSessionSource + private let gate: any ExecutionGate + private let confirmPreparation: @MainActor (PluginDebugPreparation) async -> Bool + private let withdrawPreparation: @MainActor () -> Void + private let cancelLimit: Duration + private var session: (any PluginDebugSession)? + private var startTask: Task? + private var preparationTask: Task? + private var authorizationTask: Task? + private var placedBreakpoints: [String: Set] = [:] + private var lastOperation: Task? + private var stopTask: Task? + private var stopDeadline: RoutineDebugStopDeadline? + // The one `stop()` call a session gets, shared by everything that ends its target. + private var targetStop: Task? + // The plugin could not confirm its target ended, or did not answer by the deadline: the status says so. + private(set) var unconfirmedStop: String? + private var outputTask: Task? + private var pauseGeneration = 0 + private var stopMessage: String? + // `isEnding` from the moment the run starts to end; `hasEnded` once its drivers are closed and its state final. + private var isEnding = false + private(set) var hasEnded = false + private var endWaiters: [CheckedContinuation] = [] + + init( + plan: PluginDebugPlan, + choice: RoutineDebugStartChoice, + connectionId: UUID, + databaseType: DatabaseType, + debugger: any PluginRoutineDebugging, + sessionSource: RoutineDebugSessionSource, + gate: any ExecutionGate = ExecutionGateProvider.shared, + cancelLimit: Duration = RoutineDebugSessions.defaultStopTimeout, + confirmPreparation: @escaping @MainActor (PluginDebugPreparation) async -> Bool, + withdrawPreparation: @escaping @MainActor () -> Void = {} + ) { + self.plan = plan + self.choice = choice + self.connectionId = connectionId + self.databaseType = databaseType + self.debugger = debugger + self.sessionSource = sessionSource + self.gate = gate + self.confirmPreparation = confirmPreparation + self.withdrawPreparation = withdrawPreparation + self.cancelLimit = cancelLimit + } + + // MARK: - Commands + + func canPerform(_ command: PluginDebugCommand) -> Bool { + guard !isStopping, state.pausedLocation != nil else { return false } + return supportedCommands.contains(command) + } + + func perform(_ command: PluginDebugCommand) { + guard canPerform(command), let session else { return } + setState(.running) + enqueue { [weak self] in + guard let self, !self.isStopping else { return } + // An edit made while the pause was still loading had its own sync skipped once this left the pause. + await self.syncBreakpoints(session) + guard !self.isStopping else { return } + await self.runStep { try await session.perform(command) } + } + } + + var canStop: Bool { + !isEnding && !isStopping + } + + // Every way a run is stopped comes here: Stop, its tab or window closing, a disconnect or reconnect, the module + // turned off, quit. A caller that waits on the stop passes its deadline so both end at the same instant. + func stop(by deadline: RoutineDebugStopDeadline? = nil) { + guard canStop else { return } + isStopping = true + let deadline = startStopDeadline(deadline) + notifyStateChange() + if case .preparing = state { + withdrawPreparation() + } + if let session { + stopTask = Task { + await self.endTarget(session) + await self.end(.stopped(nil)) + } + } else if let startTask { + cancelStart(startTask, by: deadline) + } else if let preparationTask { + cancelPreparation(preparationTask, by: deadline) + } else if let authorizationTask { + cancelAuthorization(authorizationTask, by: deadline) + } else if lastOperation == nil { + Task { await self.end(.stopped(nil)) } + } + // Otherwise the preparation question is up or the run is between two steps, and it ends itself as stopped at + // its next check. + } + + private func startStopDeadline(_ requested: RoutineDebugStopDeadline? = nil) -> RoutineDebugStopDeadline { + if let stopDeadline { + return stopDeadline + } + let deadline = requested ?? RoutineDebugStopDeadline(after: cancelLimit) + stopDeadline = deadline + return deadline + } + + // The only place a session hears `stop()`: at once, even over a read, an edit or a step still running, which the + // plugin then ends itself. Returns when the plugin answers or at the deadline, whichever comes first. + private func endTarget(_ session: any PluginDebugSession) async { + let deadline = startStopDeadline() + let stopping: Task + if let targetStop { + stopping = targetStop + } else { + let source = sessionSource + stopping = Task { + // A plugin may need one more session to end its target, which the connection's admission fence must not refuse. + await source.permitCleanup() + return await session.stop() + } + targetStop = stopping + } + let answered = await deadline.wait { _ = await stopping.value } + guard answered else { + Self.logger.warning("Debug target did not answer its stop by the deadline; closing its connections") + unconfirmedStop = Self.stopNotConfirmed + return + } + if case .unconfirmed(let message, let detail) = await stopping.value { + unconfirmedStop = Self.join(message, detail) + } + } + + // The target may already be running its call before `startDebug` returns. Cancelling makes the plugin end it, and a + // session it returns anyway is stopped within the same deadline; one that never answers loses its drivers then. + private func cancelStart(_ start: Task, by deadline: RoutineDebugStopDeadline) { + let source = sessionSource + stopTask = Task { + await source.permitCleanup() + start.cancel() + let answered = await deadline.wait { _ = await start.result } + guard !answered else { return } + Self.logger.warning("Debug start ignored its cancellation; closing its drivers") + self.unconfirmedStop = Self.stopNotConfirmed + await self.end(.stopped(nil)) + } + } + + // The gate withdraws its prompt when cancelled and answers as a Cancel would; one that cannot is not waited on past + // the deadline, since nothing is open yet that the run would have to close. + private func cancelAuthorization(_ work: Task, by deadline: RoutineDebugStopDeadline) { + stopTask = Task { + work.cancel() + let answered = await deadline.wait { _ = await work.value } + guard !answered else { return } + await self.end(.stopped(nil)) + } + } + + // A statement already sent cannot be called back: the run waits for it until the deadline, then closes its + // connection, which abandons it. Either way the run ends stopped and never goes on to start the session. + private func cancelPreparation(_ work: Task, by deadline: RoutineDebugStopDeadline) { + stopTask = Task { + work.cancel() + let answered = await deadline.wait { _ = await work.result } + guard !answered else { return } + Self.logger.warning("Debug preparation still running at the deadline; closing its connection") + await self.end(.stopped(nil)) + } + } + + func waitUntilEnded() async { + guard !hasEnded else { return } + await withCheckedContinuation { endWaiters.append($0) } + } + + // MARK: - Breakpoints + + var hasBreakpoints: Bool { + breakpoints.values.contains { !$0.isEmpty } + } + + // Toggle Breakpoint acts on the caret's line of the source on screen, and nothing once the run is ending. + var canToggleBreakpoint: Bool { + !isEnding && displayedUnit != nil && cursorLine != nil + } + + var canRemoveAllBreakpoints: Bool { + !isEnding && hasBreakpoints + } + + var displayedBreakpointLines: Set { + guard let unit = displayedUnit else { return [] } + return breakpoints[unit.identity] ?? [] + } + + func toggleBreakpointAtCursor() { + guard canToggleBreakpoint, let cursorLine else { return } + toggleBreakpoint(atLine: cursorLine) + } + + func toggleBreakpoint(atLine line: Int) { + guard let unit = displayedUnit, line > 0, !isEnding else { return } + if breakpoints[unit.identity]?.contains(line) == true { + breakpoints[unit.identity]?.remove(line) + } else { + breakpoints[unit.identity, default: []].insert(line) + } + scheduleBreakpointSync() + } + + func removeAllBreakpoints() { + guard canRemoveAllBreakpoints else { return } + breakpoints = [:] + scheduleBreakpointSync() + } + + // While running the engine may not take a breakpoint, so edits wait for the next pause. + private func scheduleBreakpointSync() { + guard let session, state.pausedLocation != nil, !isStopping else { return } + enqueue { [weak self] in + guard let self, self.state.pausedLocation != nil, !self.isStopping else { return } + await self.syncBreakpoints(session) + } + } + + private func syncBreakpoints(_ session: any PluginDebugSession) async { + let units = Set(breakpoints.keys).union(placedBreakpoints.keys) + for unit in units.sorted() { + let wanted = breakpoints[unit] ?? [] + let placed = placedBreakpoints[unit] ?? [] + for line in placed.subtracting(wanted).sorted() { + guard !isStopping else { return } + do { + try await session.removeBreakpoint(PluginDebugBreakpoint(unitIdentity: unit, line: line)) + placedBreakpoints[unit]?.remove(line) + } catch { + // A stop ends the call on purpose; that is no failure to show. + guard !isStopping else { return } + breakpoints[unit, default: []].insert(line) + inspectionError = Self.message(for: error) + } + } + for line in wanted.subtracting(placed).sorted() { + guard !isStopping else { return } + let requested = PluginDebugBreakpoint(unitIdentity: unit, line: line) + do { + let result = try await session.setBreakpoint(requested) + if let result { + placedBreakpoints[result.unitIdentity, default: []].insert(result.line) + } + // Removed while the request was out: the server's breakpoint stays placed but unwanted, so the + // next sync takes it off instead of the marker coming back. + guard breakpoints[unit]?.contains(line) == true else { continue } + breakpoints[unit]?.remove(line) + if let result { + breakpoints[result.unitIdentity, default: []].insert(result.line) + } + } catch { + guard !isStopping else { return } + breakpoints[unit]?.remove(line) + inspectionError = Self.message(for: error) + } + } + } + } + + // MARK: - Frames + + var currentLocation: PluginDebugLocation? { + guard let paused = state.pausedLocation else { return nil } + let shown = frames.first { $0.level == displayedFrameLevel } + return shown?.location ?? paused + } + + var executionLine: Int? { + guard let location = currentLocation, location.unit.identity == displayedUnit?.identity else { return nil } + return location.line + } + + var displayedSource: String { + displayedUnit.flatMap { sources[$0.identity] } ?? "" + } + + func selectFrame(level: Int) { + guard let session, state.pausedLocation != nil, stackIsCurrent, selectedFrameLevel != level, + let frame = frames.first(where: { $0.level == level }) + else { return } + selectedFrameLevel = level + let generation = pauseGeneration + enqueue { [weak self] in + guard let self, self.isCurrentFrame(level, generation) else { return } + do { + if let unit = frame.location?.unit { + try await self.loadSource(of: unit, from: session) + } + guard self.isCurrentFrame(level, generation) else { return } + let values = try await session.variables(inFrame: level) + guard self.isCurrentFrame(level, generation) else { return } + self.show(frame: level, unit: frame.location?.unit, variables: values) + } catch { + guard !self.isStopping else { return } + self.inspectionError = Self.message(for: error) + } + } + } + + private func isCurrentFrame(_ level: Int, _ generation: Int) -> Bool { + isCurrentPause(generation) && selectedFrameLevel == level + } + + private func show(frame level: Int, unit: PluginDebugUnit?, variables values: [PluginDebugVariable]) { + if let unit { + displayedUnit = unit + } + variables = values + displayedFrameLevel = level + } + + // MARK: - Status + + var statusText: String { + if isStopping, !hasEnded { + return String(localized: "Stopping…") + } + switch state { + case .preparing: + return String(localized: "Preparing…") + case .starting: + return String(localized: "Starting…") + case .running: + return String(localized: "Running…") + case .paused(let location): + return String(format: String(localized: "Paused at line %d in %@"), location.line, location.unit.displayName) + case .finished: + return String(localized: "Finished") + case .failed(let message): + return message + case .stopped(let message): + return message ?? String(localized: "Stopped") + } + } + + // MARK: - Run + + func start() { + guard lastOperation == nil, !isEnding else { return } + lastOperation = Task { [weak self] in + await self?.run() + } + } + + private func run() async { + // A stop that landed before this task ran found nothing to cancel, so nothing may be asked or opened. + guard !isStopping else { + await end(.stopped(nil)) + return + } + let decision = await authorize(OperationRequest( + connectionId: connectionId, + databaseType: databaseType, + sql: nil, + kind: plan.mayWrite ? .writeQuery : .readQuery, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: String(localized: "Debug Routine") + )) + guard await proceed(after: decision) else { return } + if let preparation = plan.preparation { + guard await prepare(preparation) else { return } + } + guard !isStopping else { + await end(.stopped(nil)) + return + } + setState(.starting) + let request = PluginDebugRequest( + entryPoint: choice.entryPoint, + arguments: choice.arguments, + breakpoints: breakpoints.flatMap { unit, lines in + lines.map { PluginDebugBreakpoint(unitIdentity: unit, line: $0) } + }, + optionValues: choice.optionValues + ) + let debugger = debugger + let sessionSource = sessionSource + let start = Task { try await debugger.startDebug(request, sessions: sessionSource) } + startTask = start + let session: any PluginDebugSession + do { + session = try await start.value + startTask = nil + } catch { + startTask = nil + if !isStopping { + Self.logger.error("Debug session did not start: \(error.publicLogShape, privacy: .public)") + } else if !(error is CancellationError) { + // A cancelled start throws something else only when it could not confirm its target ended. + unconfirmedStop = Self.message(for: error) + } + await end(isStopping ? .stopped(nil) : .failed(Self.message(for: error))) + return + } + // Stopped while starting: a session that came back anyway is ended before its drivers close. + guard !isStopping, !isEnding else { + await endTarget(session) + await end(.stopped(nil)) + return + } + self.session = session + placedBreakpoints = request.breakpoints.reduce(into: [:]) { placed, breakpoint in + placed[breakpoint.unitIdentity, default: []].insert(breakpoint.line) + } + supportedCommands = session.supportedCommands + observeOutput(of: session) + await runStep { try await session.firstStop() } + } + + // In a task of its own, so a stop can withdraw a confirmation or Touch ID prompt the gate is still waiting on. + private func authorize(_ request: OperationRequest) async -> OperationDecision { + let gate = gate + let work = Task { await gate.authorize(request) } + authorizationTask = work + defer { authorizationTask = nil } + return await work.value + } + + private func proceed(after decision: OperationDecision) async -> Bool { + switch decision { + case .authorized: + guard isStopping else { return true } + await end(.stopped(nil)) + return false + case .denied(let reason, let cause): + if cause == .cancelledByUser || isStopping { + let declined = !isStopping + await end(.stopped(nil)) + if declined { + onCancelled?() + } + } else { + await end(.failed(reason)) + } + return false + } + } + + private func prepare(_ preparation: PluginDebugPreparation) async -> Bool { + guard await confirmPreparation(preparation) else { + let declined = !isStopping + await end(.stopped(nil)) + if declined { + onCancelled?() + } + return false + } + guard !isStopping else { + await end(.stopped(nil)) + return false + } + let decision = await authorize(OperationRequest( + connectionId: connectionId, + databaseType: databaseType, + sql: preparation.statements.joined(separator: "\n"), + kind: .schemaMutation, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: preparation.actionTitle + )) + guard await proceed(after: decision) else { return false } + let statements = preparation.statements + let source = sessionSource + let work = Task { try await source.run(statements) } + preparationTask = work + do { + try await work.value + preparationTask = nil + } catch { + preparationTask = nil + await end(isStopping ? .stopped(nil) : .failed(Self.message(for: error))) + return false + } + guard !isStopping else { + await end(.stopped(nil)) + return false + } + return true + } + + private func runStep(_ step: @escaping () async throws -> PluginDebugStop) async { + let outcome: PluginDebugStop + do { + outcome = try await step() + } catch { + guard !isStopping, let session else { return } + Self.logger.error("Debug step failed: \(error.publicLogShape, privacy: .public)") + // The step's outcome is unknown, so the target is ended before its drivers close. + await endTarget(session) + await end(.failed(Self.message(for: error))) + return + } + // A stop in flight owns the ending; a pause that raced it is the plugin's to abort. The waiting step's own words + // (rolled back, say) become the status when the stop itself was confirmed. + guard !isStopping else { + switch outcome { + case .failed(let message, let detail): + answerStop(with: Self.join(message, detail)) + case .finished: + answerStop(with: String(localized: "The routine finished before the stop reached it.")) + default: + break + } + return + } + switch outcome { + case .paused(let location): + pauseGeneration += 1 + selectedFrameLevel = nil + displayedFrameLevel = nil + stackIsCurrent = false + // The last pause's values would read as this one's until the new ones load, or for good if they fail. + variables = [] + inspectionError = nil + setState(.paused(location)) + await inspect(location, generation: pauseGeneration) + case .finished(let result): + await end(.finished(result)) + case .failed(let message, let detail): + // A target the plugin gave up on may still run: its stop says so, and may still end it, before the drivers close. + if let session { + await endTarget(session) + } + await end(.failed(Self.join(message, detail))) + @unknown default: + if let session { + await endTarget(session) + } + await end(.failed(String(localized: "The debugger stopped for a reason TablePro doesn't know."))) + } + } + + private func inspect(_ location: PluginDebugLocation, generation: Int) async { + guard let session else { return } + inspectionError = nil + displayedUnit = location.unit + await syncBreakpoints(session) + guard isCurrentPause(generation) else { return } + do { + try await loadSource(of: location.unit, from: session) + guard isCurrentPause(generation) else { return } + let stack = try await session.stack() + guard isCurrentPause(generation) else { return } + frames = stack + let level = stack.first?.level ?? 0 + selectedFrameLevel = level + stackIsCurrent = true + let values = try await session.variables(inFrame: level) + guard isCurrentFrame(level, generation) else { return } + show(frame: level, unit: location.unit, variables: values) + } catch { + guard !isStopping else { return } + inspectionError = Self.message(for: error) + } + } + + private func loadSource(of unit: PluginDebugUnit, from session: any PluginDebugSession) async throws { + guard sources[unit.identity] == nil else { return } + sources[unit.identity] = try await session.source(of: unit) + } + + private func isCurrentPause(_ generation: Int) -> Bool { + generation == pauseGeneration && state.pausedLocation != nil && !isStopping + } + + private func observeOutput(of session: any PluginDebugSession) { + let stream = session.output + outputTask = Task { [weak self] in + for await line in stream { + self?.appendOutput(line) + } + } + } + + private func appendOutput(_ line: String) { + outputLines.append(line) + if outputLines.count > Self.outputLineLimit { + outputLines.removeFirst(outputLines.count - Self.outputLineLimit) + } + } + + private func enqueue(_ operation: @escaping @MainActor () async -> Void) { + let previous = lastOperation + lastOperation = Task { + await previous?.value + await operation() + } + } + + private func answerStop(with message: String) { + stopMessage = message + if hasEnded, case .stopped(.none) = state { + setState(.stopped(message)) + } + } + + private func end(_ final: State) async { + guard !isEnding else { return } + isEnding = true + // A run that ended by itself may still have output on its way, possibly read over these connections; a stopped + // one keeps what already arrived. + if !isStopping, let outputTask, final.endedByItself { + _ = await AppModuleTermination.runCapped(at: Self.outputDrainLimit) { + await outputTask.value + } + } + await sessionSource.closeAll() + outputTask?.cancel() + // Read after the wait: the waiting step's answer to the stop can land while the drivers close. + var final = final + switch final { + case .stopped(.none): + final = .stopped(unconfirmedStop ?? stopMessage) + case .failed(let message): + if let unconfirmedStop, unconfirmedStop != message { + final = .failed(Self.join(message, unconfirmedStop)) + } + default: + break + } + if case .finished(let result) = final { + resultGrid = result.flatMap { Self.makeResultGrid($0, databaseType: databaseType) } + } + hasEnded = true + setState(final) + let waiters = endWaiters + endWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + private func setState(_ newState: State) { + state = newState + notifyStateChange() + } + + // The toolbar and the Debug menu validate on a window update; a step finishing posts no event to cause one. + private func notifyStateChange() { + NSApplication.shared.setWindowsNeedUpdate(true) + } + + // MARK: - Helpers + + static func message(for error: Error) -> String { + if let driverError = error as? any PluginDriverError { + return join(driverError.pluginErrorMessage, driverError.pluginErrorDetail) + } + return error.localizedDescription + } + + private static func join(_ message: String, _ detail: String?) -> String { + guard let detail, !detail.isEmpty else { return message } + return message + "\n" + detail + } + + private static func makeResultGrid(_ result: PluginQueryResult, databaseType: DatabaseType) -> HeldRowsGridAdapter? { + guard !result.columns.isEmpty else { return nil } + let classifier = ColumnTypeClassifier(family: SQLTypeFamily.of(databaseType)) + let rows = TableRows.from( + queryRows: result.rows, + columns: result.columns, + columnTypes: result.columnTypeNames.map { classifier.classify(rawTypeName: $0) } + ) + var configuration = DataGridConfiguration() + configuration.traits = DataGridEngineTraits(databaseType: databaseType) + configuration.showRowNumbers = true + configuration.supportsValueFilter = false + configuration.commands = [.freezeColumns, .displayAs] + let grid = HeldRowsGridAdapter(configuration: configuration, sortsHeldRows: true) + grid.setRows(rows) + return grid + } +} diff --git a/apple/TablePro/Core/RoutineDebugging/RoutineDebugOptionStore.swift b/apple/TablePro/Core/RoutineDebugging/RoutineDebugOptionStore.swift new file mode 100644 index 0000000000..168bb475cc --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/RoutineDebugOptionStore.swift @@ -0,0 +1,48 @@ +// +// RoutineDebugOptionStore.swift +// TablePro +// + +import Foundation + +// The start sheet's option values, per connection and field. Device-local: a debugger host or port names this Mac's network. +internal struct RoutineDebugOptionStore: Sendable { + private static let keyPrefix = "RoutineDebugOption." + + private let defaults: @Sendable () -> UserDefaults + + init(defaults: @escaping @Sendable () -> UserDefaults = { AppStorageEnvironment.shared.defaults }) { + self.defaults = defaults + } + + static func key(connectionId: UUID, fieldId: String) -> String { + "\(keyPrefix)\(connectionId.uuidString).\(fieldId)" + } + + func value(for fieldId: String, connectionId: UUID) -> String? { + defaults().string(forKey: Self.key(connectionId: connectionId, fieldId: fieldId)) + } + + func save(_ values: [String: String], connectionId: UUID) { + let store = defaults() + for (fieldId, value) in values { + store.set(value, forKey: Self.key(connectionId: connectionId, fieldId: fieldId)) + } + } + + // Field ids are the plugin's, so a connection's keys are found by prefix. + func remove(for connectionId: UUID) { + removeKeys(withPrefix: "\(Self.keyPrefix)\(connectionId.uuidString).") + } + + func removeAll() { + removeKeys(withPrefix: Self.keyPrefix) + } + + private func removeKeys(withPrefix prefix: String) { + let store = defaults() + for key in store.keys(withPrefix: prefix) { + store.removeObject(forKey: key) + } + } +} diff --git a/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessionSource.swift b/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessionSource.swift new file mode 100644 index 0000000000..6ae474e555 --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessionSource.swift @@ -0,0 +1,146 @@ +// +// RoutineDebugSessionSource.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal struct RoutineDebugDriverHandle: Sendable { + let driver: any PluginDatabaseDriver + let close: @Sendable () -> Void +} + +internal enum RoutineDebugError: LocalizedError, Equatable { + case sessionEnded + case needsSeparateConnection + case unsupported + case connectionChanged + + var errorDescription: String? { + switch self { + case .sessionEnded: + return String(localized: "The debug session has ended.") + case .needsSeparateConnection: + return String(localized: "This connection can't open the separate sessions the debugger needs.") + case .unsupported: + return String(localized: "This connection's driver can't debug routines.") + case .connectionChanged: + return String(localized: "The connection was closed or reconnected while the debugger was starting. Start it again.") + } + } +} + +// Dedicated connections for one debug session: never pooled, never health-pinged, no query timeout, all closed when it ends. +internal actor RoutineDebugSessionSource: PluginDebugSessionSource { + typealias Opener = @Sendable () async throws -> RoutineDebugDriverHandle + + // A paused target waits on a person, so any statement timeout would end the session between two steps. + static let queryTimeoutSeconds = 0 + + private let opener: Opener + // The connection's admission fence: false while its transport is being torn down or after it was replaced. + private let admits: @Sendable () async -> Bool + private var handles: [UUID: RoutineDebugDriverHandle] = [:] + private var isClosed = false + private var permitsCleanup = false + + init(admits: @escaping @Sendable () async -> Bool = { true }, opener: @escaping Opener) { + self.admits = admits + self.opener = opener + } + + // Once the run is stopping, a session the plugin opens to end its target is let through the fence. + func permitCleanup() { + permitsCleanup = true + } + + func openSession() async throws -> any PluginDatabaseDriver { + let handle = try await dial() + handles[UUID()] = handle + return handle.driver + } + + // On a driver of its own, never the person's session: a DDL statement there would commit a transaction they left + // open. The driver sits with the session's others while it runs, so ending the session closes it too. + func run(_ statements: [String]) async throws { + let handle = try await dial() + let id = UUID() + handles[id] = handle + defer { handles.removeValue(forKey: id)?.close() } + for statement in statements { + try Task.checkCancellation() + guard !isClosed else { throw RoutineDebugError.sessionEnded } + _ = try await handle.driver.execute(query: statement) + } + } + + private func dial() async throws -> RoutineDebugDriverHandle { + try await checkAdmission() + let handle = try await opener() + // The session may have ended, or the connection closed its fence, while the driver was being dialed. + do { + try await checkAdmission() + } catch { + handle.close() + throw error + } + return handle + } + + private func checkAdmission() async throws { + guard !isClosed else { throw RoutineDebugError.sessionEnded } + guard !permitsCleanup else { return } + guard await admits() else { throw RoutineDebugError.connectionChanged } + guard !isClosed else { throw RoutineDebugError.sessionEnded } + } + + func closeAll() { + isClosed = true + let closing = handles.values + handles.removeAll() + for handle in closing { + handle.close() + } + } +} + +extension RoutineDebugSessionSource { + typealias ServerOpener = @MainActor @Sendable (DatabaseScope, Int) async throws -> DatabaseDriver + + // `generation` is the connection's admission generation when the start sheet opened. + @MainActor + static func live( + scope: DatabaseScope, + generation: Int, + open: @escaping ServerOpener = dedicatedServerDriver + ) -> RoutineDebugSessionSource { + let connectionId = scope.connectionId + return RoutineDebugSessionSource(admits: { + await RoutineDebugSessions.shared.admits(connectionId, generation: generation) + }) { + let driver = try await open(scope, Self.queryTimeoutSeconds) + guard let adapter = driver as? PluginDriverAdapter else { + driver.disconnect() + throw RoutineDebugError.unsupported + } + return RoutineDebugDriverHandle(driver: adapter.schemaPluginDriver) { adapter.disconnect() } + } + } + + // A second connection to an embedded engine opens a different, empty database, so those engines are refused. + @MainActor + static func dedicatedServerDriver(scope: DatabaseScope, queryTimeoutSeconds: Int) async throws -> DatabaseDriver { + guard let session = DatabaseManager.shared.session(for: scope.connectionId) else { + throw DatabaseError.notConnected + } + guard session.connection.type.supportsConnectionPooling else { + throw RoutineDebugError.needsSeparateConnection + } + return try await MetadataConnectionPool.openServerDriver( + for: scope, + purpose: .session, + queryTimeoutSeconds: queryTimeoutSeconds + ) + } +} diff --git a/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessions.swift b/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessions.swift new file mode 100644 index 0000000000..3796ef6ec7 --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessions.swift @@ -0,0 +1,247 @@ +// +// RoutineDebugSessions.swift +// TablePro +// + +import AppKit +import Foundation +import os +import TableProPluginKit + +// T0 plus the stop limit, fixed when a stop is first asked for. Every wait in that stop, and every caller waiting on +// it, runs against this one instant, so no connection closes before the plugin answered or the instant passed. +internal struct RoutineDebugStopDeadline: Sendable { + let instant: ContinuousClock.Instant + + init(after limit: Duration) { + instant = ContinuousClock.now.advanced(by: limit) + } + + // True when `work` returned first. Work still running at the deadline is left running. + @MainActor + func wait(for work: @escaping @MainActor @Sendable () async -> Void) async -> Bool { + await AppModuleTermination.runCapped(at: ContinuousClock.now.duration(to: instant), work) + } +} + +// Every live debug run, by the tab that shows it. Its connection can move to another window, so nothing here keeps one. +@MainActor +internal final class RoutineDebugSessions { + static let shared = RoutineDebugSessions() + + // Long enough for a reachable server to abort and roll back, short enough that a disconnect never hangs on one that isn't. + static let defaultStopTimeout: Duration = .seconds(5) + + private static let logger = Logger(subsystem: "com.TablePro", category: "RoutineDebugSessions") + + private struct Entry { + let controller: RoutineDebugController + let connectionId: UUID + } + + private var entries: [UUID: Entry] = [:] + private var ending: [UUID: Entry] = [:] + // Per connection: closed while its transport is torn down or replaced, and bumped each time, so a start set up + // against the old transport is refused even after the fence opens again. Replacements nest; the last to end opens it. + private var fenceDepths: [UUID: Int] = [:] + private var admissionGenerations: [UUID: Int] = [:] + // Runs a disconnect ended: the connection's teardown takes their tabs before anyone reads their status. + private var unseenEndTabs: Set = [] + // Choosers, start sheets and preparation alerts still on screen, so turning the module off can take them down. + private var pendingSetups: [ObjectIdentifier: RoutineDebugSetup] = [:] + private var moduleObserver: NSObjectProtocol? + private let isModuleEnabled: @MainActor () -> Bool + private let notificationCenter: NotificationCenter + private let reportUnconfirmedStop: @MainActor (RoutineDebugController, NSWindow?) -> Void + + init( + isModuleEnabled: @escaping @MainActor () -> Bool = { AppModuleSettings.shared.isEnabled(.routineDebugger) }, + notificationCenter: NotificationCenter = .default, + reportUnconfirmedStop: @escaping @MainActor (RoutineDebugController, NSWindow?) -> Void = + RoutineDebugSessions.presentUnconfirmedStop + ) { + self.isModuleEnabled = isModuleEnabled + self.notificationCenter = notificationCenter + self.reportUnconfirmedStop = reportUnconfirmedStop + } + + var hasActiveSessions: Bool { + entries.values.contains { !$0.controller.hasEnded } || !ending.isEmpty + } + + // Refused once the connection's transport went away after `generation` was read; nil means now. + @discardableResult + func register( + _ controller: RoutineDebugController, + tabId: UUID, + connectionId: UUID, + generation: Int? = nil + ) -> Bool { + guard admits(connectionId, generation: generation ?? admissionGeneration(for: connectionId)) else { return false } + entries[tabId] = Entry(controller: controller, connectionId: connectionId) + observeModuleIfNeeded() + return true + } + + // MARK: - Setups + + func beginSetup(_ setup: RoutineDebugSetup) { + pendingSetups[ObjectIdentifier(setup)] = setup + observeModuleIfNeeded() + } + + func endSetup(_ setup: RoutineDebugSetup) { + pendingSetups.removeValue(forKey: ObjectIdentifier(setup)) + stopObservingIfIdle() + } + + // MARK: - Admission + + func admissionGeneration(for connectionId: UUID) -> Int { + admissionGenerations[connectionId] ?? 0 + } + + func admits(_ connectionId: UUID, generation: Int) -> Bool { + fenceDepths[connectionId] == nil && admissionGeneration(for: connectionId) == generation + } + + // After the transport is up again, or gone for good. Starts read before the fence closed stay refused. + func reopenAdmission(for connectionId: UUID) { + guard let depth = fenceDepths[connectionId] else { return } + fenceDepths[connectionId] = depth > 1 ? depth - 1 : nil + } + + private func closeAdmission(for connectionId: UUID) { + fenceDepths[connectionId, default: 0] += 1 + admissionGenerations[connectionId] = admissionGeneration(for: connectionId) + 1 + } + + func controller(forTab tabId: UUID) -> RoutineDebugController? { + entries[tabId]?.controller + } + + // The tab is going away: its run stops and its drivers close, without anyone waiting on it. With no tab left to + // show it, a stop the plugin could not confirm is reported on `window`, the one that held the tab. + func endSession(forTab tabId: UUID, window: NSWindow? = nil) { + guard let entry = entries.removeValue(forKey: tabId) else { return } + entry.controller.onCancelled = nil + guard !entry.controller.hasEnded else { + if unseenEndTabs.remove(tabId) != nil, entry.controller.unconfirmedStop != nil { + let controller = entry.controller + // A turn later, so a window closing with the tab is gone and the alert lands on one that stays. + Task { [weak self, weak window] in + self?.reportUnconfirmedStop(controller, window) + } + } + stopObservingIfIdle() + return + } + unseenEndTabs.remove(tabId) + ending[tabId] = entry + entry.controller.stop() + Task { [weak self, weak window] in + await entry.controller.waitUntilEnded() + guard let self else { return } + self.ending.removeValue(forKey: tabId) + if entry.controller.unconfirmedStop != nil { + self.reportUnconfirmedStop(entry.controller, window) + } + self.stopObservingIfIdle() + } + } + + func endSessions(forTabs tabIds: [UUID], window: NSWindow? = nil) { + for tabId in tabIds { + endSession(forTab: tabId, window: window) + } + } + + // The target may still hold its locks. A quit in progress shows nothing: the process is about to end its connections. + static func presentUnconfirmedStop(of controller: RoutineDebugController, in window: NSWindow?) { + guard let text = controller.unconfirmedStop, + !AppModuleTermination.hasBegun, + !MainContentCoordinator.isAppTerminating + else { return } + let alert = NSAlert() + alert.messageText = String(format: String(localized: "%@ May Still Be Running"), controller.plan.target.name) + alert.informativeText = text + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "OK")) + AlertHelper.present(alert, in: window?.isVisible == true ? window : nil) + } + + // Before the connection's transport goes, so every target is aborted while it can still be reached. Closes the + // connection's admission first, so a start racing this cannot open a driver on the transport being torn down; the + // caller reopens it with `reopenAdmission(for:)` once the new transport is up. + // `tabsCloseAfter`: the caller tears the connection's tabs down next, so an unconfirmed stop is reported then. + func stopAll(connectionId: UUID, timeout: Duration = defaultStopTimeout, tabsCloseAfter: Bool = false) async { + let deadline = RoutineDebugStopDeadline(after: timeout) + closeAdmission(for: connectionId) + let controllers = (entries.values.map { $0 } + ending.values.map { $0 }) + .filter { $0.connectionId == connectionId } + .map(\.controller) + await stopAndWait(controllers, by: deadline) + guard tabsCloseAfter else { return } + for (tabId, entry) in entries where entry.connectionId == connectionId { + unseenEndTabs.insert(tabId) + } + } + + func stopAll(timeout: Duration = defaultStopTimeout) async { + let deadline = RoutineDebugStopDeadline(after: timeout) + let controllers = entries.values.map(\.controller) + ending.values.map(\.controller) + await stopAndWait(controllers, by: deadline) + } + + // The runs close their drivers by the same deadline this waits for, so the caller's transport outlives them. + private func stopAndWait(_ controllers: [RoutineDebugController], by deadline: RoutineDebugStopDeadline) async { + let live = controllers.filter { !$0.hasEnded } + guard !live.isEmpty else { return } + for controller in live { + controller.stop(by: deadline) + } + let finished = await deadline.wait { + for controller in live { + await controller.waitUntilEnded() + } + } + if !finished { + Self.logger.warning("Debug sessions still stopping at their deadline; continuing without them") + } + } + + // MARK: - Module + + // Observed only while a run or a setup exists, so an off module leaves no observer behind. + private func observeModuleIfNeeded() { + guard moduleObserver == nil else { return } + moduleObserver = notificationCenter.addObserver( + forName: .appModulesDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.moduleDidChange() + } + } + } + + private func stopObservingIfIdle() { + guard entries.isEmpty, ending.isEmpty, pendingSetups.isEmpty, let moduleObserver else { return } + notificationCenter.removeObserver(moduleObserver) + self.moduleObserver = nil + } + + private func moduleDidChange() { + guard !isModuleEnabled() else { return } + let setups = Array(pendingSetups.values) + pendingSetups.removeAll() + for setup in setups { + setup.cancel() + } + for coordinator in MainContentCoordinator.activeCoordinators.values { + coordinator.closeRoutineDebugTabs() + } + endSessions(forTabs: Array(entries.keys)) + } +} diff --git a/apple/TablePro/Core/RoutineDebugging/RoutineDebugStartFlow.swift b/apple/TablePro/Core/RoutineDebugging/RoutineDebugStartFlow.swift new file mode 100644 index 0000000000..b3b4217b8a --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/RoutineDebugStartFlow.swift @@ -0,0 +1,82 @@ +// +// RoutineDebugStartFlow.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +// One pending way into a debug run: the chooser, the start sheet, or a preparation alert. Cancelling it, as turning the +// module off does, answers whatever is on screen as declined and stops the flow at its next step. +@MainActor +internal final class RoutineDebugSetup { + private(set) var isCancelled = false + var dismissPresentation: (@MainActor () -> Void)? + + func cancel() { + guard !isCancelled else { return } + isCancelled = true + let dismiss = dismissPresentation + dismissPresentation = nil + dismiss?() + } +} + +// A routine and the database and schema it was chosen in, frozen at that moment: an oid or an owner-qualified name only +// means something there, and another window can move this connection's browse database while a sheet is up. +internal struct RoutineDebugPick: Sendable, Equatable { + let target: PluginDebugTarget + let scope: DatabaseScope + + // A chooser lists one database and schema: the pick keeps that database and the routine's own schema. + init(_ target: PluginDebugTarget, listedIn listed: DatabaseScope) { + self.target = target + self.scope = DatabaseScope( + connectionId: listed.connectionId, + database: listed.database, + schema: target.schema ?? listed.schema + ) + } + + init(target: PluginDebugTarget, scope: DatabaseScope) { + self.target = target + self.scope = scope + } +} + +// From a chosen routine to an open debug tab. Every step that reads the database or shows something asks first +// whether the module is still on and the setup still wanted, because each one can wait on the person for a while. +@MainActor +internal struct RoutineDebugStartFlow { + var isModuleEnabled: @MainActor () -> Bool + var prepare: @MainActor (RoutineDebugPick) async throws -> PluginDebugPlan + var presentStartSheet: @MainActor (PluginDebugPlan, RoutineDebugSetup) async -> RoutineDebugStartChoice? + var reportFailure: @MainActor (PluginDebugTarget, any Error) -> Void + var open: @MainActor (PluginDebugPlan, RoutineDebugStartChoice, DatabaseScope) -> Void + + func choose( + setup: RoutineDebugSetup, + presentChooser: @MainActor (RoutineDebugSetup) async -> RoutineDebugPick? + ) async { + guard proceeds(setup), let pick = await presentChooser(setup) else { return } + await run(pick, setup: setup) + } + + func run(_ pick: RoutineDebugPick, setup: RoutineDebugSetup) async { + guard proceeds(setup) else { return } + let plan: PluginDebugPlan + do { + plan = try await prepare(pick) + } catch { + guard proceeds(setup) else { return } + reportFailure(pick.target, error) + return + } + guard proceeds(setup), let choice = await presentStartSheet(plan, setup), proceeds(setup) else { return } + open(plan, choice, pick.scope) + } + + private func proceeds(_ setup: RoutineDebugSetup) -> Bool { + !setup.isCancelled && isModuleEnabled() + } +} diff --git a/apple/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift b/apple/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift index 67550010d3..32bbab5f40 100644 --- a/apple/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift +++ b/apple/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift @@ -9,8 +9,7 @@ import TableProPluginKit struct CreateTableStatements { let statements: [String] let issues: [SchemaDraftIssue] - /// The name the statement actually creates, trimmed. The draft's own field may carry - /// surrounding whitespace, and opening the tab under that text loads a table nobody made. + // Trimmed: opening a tab under the draft's untrimmed text loads a table nobody made. let tableName: String? var preview: String { @@ -18,8 +17,7 @@ struct CreateTableStatements { } } -/// One statement per element, because a driver is only obliged to run one statement per -/// `execute(query:)`: SQLite prepares with a nil tail, so anything after the first semicolon never runs. +// One statement per element: a driver need only run one per execute, and SQLite drops anything after the first. @MainActor enum CreateTableStatementComposer { static func compose( @@ -33,12 +31,12 @@ enum CreateTableStatementComposer { var issues = plan.issues let columnRefusals = definition.columns.compactMap { driver.schemaOperationRefusal(.addColumn($0)) } - issues += columnRefusals.map { SchemaDraftIssue(tab: .columns, row: nil, message: $0) } - var refusedIndexRows: Set = [] - for (row, index) in plan.indexes.enumerated() { + issues += columnRefusals.map { SchemaDraftIssue(section: .columns, row: nil, message: $0) } + var refusedIndexes: Set = [] + for (position, index) in plan.indexes.enumerated() { guard let reason = driver.schemaOperationRefusal(.addIndex(index)) else { continue } - refusedIndexRows.insert(row) - issues.append(SchemaDraftIssue(tab: .indexes, row: row, message: reason)) + refusedIndexes.insert(position) + issues.append(SchemaDraftIssue(section: .indexes, row: plan.indexRow(at: position), message: reason)) } guard columnRefusals.isEmpty else { return CreateTableStatements(statements: [], issues: issues, tableName: definition.tableName) @@ -46,17 +44,17 @@ enum CreateTableStatementComposer { guard let createTable = driver.generateCreateTableStatements(definition: definition), !createTable.isEmpty else { issues.append(SchemaDraftIssue( - tab: .columns, row: nil, + section: .columns, row: nil, message: String(localized: "This database cannot create a table from the visual editor.") )) return CreateTableStatements(statements: [], issues: issues, tableName: definition.tableName) } var statements = createTable - for (row, index) in plan.indexes.enumerated() where !refusedIndexRows.contains(row) { + for (position, index) in plan.indexes.enumerated() where !refusedIndexes.contains(position) { guard let sql = driver.generateAddIndexSQL(table: definition.tableName, index: index) else { issues.append(SchemaDraftIssue( - tab: .indexes, row: row, + section: .indexes, row: plan.indexRow(at: position), message: String(localized: "Create Table cannot add an index on this database.") )) continue @@ -64,6 +62,17 @@ enum CreateTableStatementComposer { statements.append(sql) } + for (position, constraint) in plan.checkConstraints.enumerated() { + guard let sql = driver.generateAddCheckConstraintSQL(table: definition.tableName, constraint: constraint) else { + issues.append(SchemaDraftIssue( + section: .checkConstraints, row: plan.checkConstraintRow(at: position), + message: String(localized: "Create Table cannot add a check constraint on this database.") + )) + continue + } + statements.append(sql) + } + if let comment = plan.tableComment { if let sql = driver.objectCommentStatement( name: definition.tableName, @@ -74,7 +83,7 @@ enum CreateTableStatementComposer { statements.append(sql) } else { issues.append(SchemaDraftIssue( - tab: .columns, row: nil, + section: .columns, row: nil, message: String(localized: "This database cannot store a table comment.") )) } diff --git a/apple/TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift b/apple/TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift index 1f62c97502..43db81ee1d 100644 --- a/apple/TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift +++ b/apple/TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift @@ -35,13 +35,14 @@ internal enum SchemaOperationRefusal { return driver.schemaOperationRefusal(.modifyColumn(old: old.toPlugin(), new: new.toPlugin())) case .deleteColumn(let column): return driver.schemaOperationRefusal(.dropColumn(column.toPlugin())) - case .addForeignKey, .modifyForeignKey, .deleteForeignKey, .modifyPrimaryKey, .modifyTableComment: + case .addForeignKey, .modifyForeignKey, .deleteForeignKey, .modifyPrimaryKey, .modifyTableComment, + .modifyTableCharacterSet, .modifyAutoIncrement: return nil } } - /// A check constraint is an operation only when it is renamed, and foreign key, primary key and - /// comment changes have no case at all. + /// A check constraint is an operation only when it is renamed, and foreign key, primary key, + /// comment and table option changes have no case at all. static func operations(for change: SchemaChange) -> [PluginSchemaOperation] { switch change { case .addColumn(let column): @@ -60,7 +61,7 @@ internal enum SchemaOperationRefusal { guard old.expression == new.expression, old.name != new.name else { return [] } return [.renameCheckConstraint(from: old.name, to: new.name)] case .addCheckConstraint, .deleteCheckConstraint, .addForeignKey, .modifyForeignKey, .deleteForeignKey, - .modifyPrimaryKey, .modifyTableComment: + .modifyPrimaryKey, .modifyTableComment, .modifyTableCharacterSet, .modifyAutoIncrement: return [] } } diff --git a/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 68bfa6f17e..73082f022b 100644 --- a/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -58,7 +58,7 @@ struct SchemaStatementGenerator { throw NSError( domain: "SchemaStatementGenerator", code: -1, - userInfo: [NSLocalizedDescriptionKey: String(format: String(localized: "Unsupported schema operation: %@"), change.description)] + userInfo: [NSLocalizedDescriptionKey: String(format: String(localized: "Unsupported schema operation: %@"), change.localizedDescription)] ) } for stmt in stmts { @@ -86,14 +86,16 @@ struct SchemaStatementGenerator { private func sortByDependency(_ staged: [SchemaChange]) -> [SchemaChange] { // Execution order for safety: // 1. Drop foreign keys first (includes modify FK, which requires drop+recreate) - // 2. Drop indexes (a modified index drops here and is added at 6, unless the driver + // 2. Drop indexes (a modified index drops here and is added at 8, unless the driver // replaces it in one statement and no column changes in the same save) - // 3. Drop/modify columns - // 4. Add columns - // 5. Modify primary key - // 6. Add indexes - // 7. Add foreign keys - // 8. Comments, last, so every structural statement has run on an engine without transactional DDL + // 3. Drop columns + // 4. Table character set: after the drops a convert trips on, before the columns added under a new default + // 5. Modify columns, then add columns + // 6. Modify primary key + // 7. AUTO_INCREMENT counter, after a MODIFY that turns AUTO_INCREMENT on, which resets it + // 8. Add indexes + // 9. Add foreign keys + // 10. Comments, last, so every structural statement has run on an engine without transactional DDL // Every drop of an index or a check constraint runs before any add or rename of one, and a // rename or add runs after the rename that frees its name. The structure editor counts a // deleted or renamed row's name as free on that basis. @@ -111,6 +113,8 @@ struct SchemaStatementGenerator { var fkAdds: [SchemaChange] = [] var constraintAdds: [SchemaChange] = [] var commentChanges: [SchemaChange] = [] + var characterSetChanges: [SchemaChange] = [] + var autoIncrementChanges: [SchemaChange] = [] let keepsIndexModifiesWhole = !changes.contains(where: Self.changesColumns) for change in changes { @@ -162,15 +166,52 @@ struct SchemaStatementGenerator { fkAdds.append(change) case .modifyTableComment: commentChanges.append(change) + case .modifyTableCharacterSet: + characterSetChanges.append(change) + case .modifyAutoIncrement: + autoIncrementChanges.append(change) } } let constraintHandoffs = Self.inNameOrder(constraintModifies) let indexHandoffs = Self.inNameOrder(indexAdds) + let keyHandoff = Self.keyDropsBeforeColumnDrops(pkChanges, columnDeletes: columnDeletes) return constraintDeletes + constraintHandoffs.drops + constraintHandoffs.ordered + fkDeletes - + indexDeletes + indexHandoffs.drops + columnDeletes + columnModifies + columnAdds + pkChanges - + indexHandoffs.ordered + fkAdds + constraintAdds + commentChanges + + indexDeletes + indexHandoffs.drops + keyHandoff.drops + columnDeletes + characterSetChanges + + columnModifies + columnAdds + keyHandoff.adds + autoIncrementChanges + indexHandoffs.ordered + fkAdds + + constraintAdds + commentChanges + } + + // Dropping a key column takes the key with it, so the key's own DROP would then fail. A key whose every column is + // dropped is left to the database and only the new key is added; one that keeps some is dropped first. + private static func keyDropsBeforeColumnDrops( + _ keyChanges: [SchemaChange], + columnDeletes: [SchemaChange] + ) -> (drops: [SchemaChange], adds: [SchemaChange]) { + let dropped = Set(columnDeletes.compactMap { change -> String? in + guard case .deleteColumn(let column) = change else { return nil } + return column.name + }) + var drops: [SchemaChange] = [] + var adds: [SchemaChange] = [] + for change in keyChanges { + guard case .modifyPrimaryKey(let old, let new) = change, !old.isEmpty, old.contains(where: dropped.contains) else { + adds.append(change) + continue + } + if old.allSatisfy(dropped.contains) { + if !new.isEmpty { + adds.append(.modifyPrimaryKey(old: [], new: new)) + } + continue + } + drops.append(.modifyPrimaryKey(old: old, new: [])) + if !new.isEmpty { + adds.append(.modifyPrimaryKey(old: [], new: new)) + } + } + return (drops, adds) } /// Orders changes that each give up one name and take another in a single step, a rename or a @@ -226,7 +267,8 @@ struct SchemaStatementGenerator { case .addCheckConstraint(let constraint): return (nil, constraint.name) case .addColumn, .modifyColumn, .deleteColumn, .deleteIndex, .addForeignKey, .modifyForeignKey, - .deleteForeignKey, .modifyPrimaryKey, .deleteCheckConstraint, .modifyTableComment: + .deleteForeignKey, .modifyPrimaryKey, .deleteCheckConstraint, .modifyTableComment, + .modifyTableCharacterSet, .modifyAutoIncrement: return (nil, nil) } } @@ -239,7 +281,7 @@ struct SchemaStatementGenerator { return (.deleteCheckConstraint(old), .addCheckConstraint(new)) case .addColumn, .modifyColumn, .deleteColumn, .addIndex, .deleteIndex, .addForeignKey, .modifyForeignKey, .deleteForeignKey, .modifyPrimaryKey, .addCheckConstraint, - .deleteCheckConstraint, .modifyTableComment: + .deleteCheckConstraint, .modifyTableComment, .modifyTableCharacterSet, .modifyAutoIncrement: return nil } } @@ -248,8 +290,11 @@ struct SchemaStatementGenerator { switch change { case .addColumn, .modifyColumn, .deleteColumn, .modifyPrimaryKey: return true + case .modifyTableCharacterSet(_, let new): + return new.convertsColumns case .addIndex, .modifyIndex, .deleteIndex, .addForeignKey, .modifyForeignKey, .deleteForeignKey, - .addCheckConstraint, .modifyCheckConstraint, .deleteCheckConstraint, .modifyTableComment: + .addCheckConstraint, .modifyCheckConstraint, .deleteCheckConstraint, .modifyTableComment, + .modifyAutoIncrement: return false } } @@ -318,6 +363,10 @@ struct SchemaStatementGenerator { return generateDeleteCheckConstraint(constraint).map { [$0] } ?? [] case .modifyTableComment(_, let new): return generateTableComment(new).map { [$0] } ?? [] + case .modifyTableCharacterSet(_, let new): + return generateTableCharacterSet(new).map { [$0] } ?? [] + case .modifyAutoIncrement(_, let new): + return generateAutoIncrement(new).map { [$0] } ?? [] } } @@ -477,6 +526,31 @@ struct SchemaStatementGenerator { ) } + // MARK: - Table Options + + private func generateTableCharacterSet(_ options: TableOptions) -> SchemaStatement? { + let change = PluginTableOptionsChange( + characterSet: options.characterSet, + collation: options.collation, + convertsColumns: options.convertsColumns + ) + guard let sql = pluginDriver.tableOptionsStatement(table: tableName, schema: schema, change: change) else { + return nil + } + let description = options.convertsColumns + ? "Convert '\(tableName)' to character set \(options.characterSet ?? "")" + : "Set default character set of '\(tableName)' to \(options.characterSet ?? "")" + return SchemaStatement(sql: sql, description: description, isDestructive: options.convertsColumns) + } + + private func generateAutoIncrement(_ value: UInt64) -> SchemaStatement? { + let change = PluginTableOptionsChange(autoIncrement: value) + guard let sql = pluginDriver.tableOptionsStatement(table: tableName, schema: schema, change: change) else { + return nil + } + return SchemaStatement(sql: sql, description: "Set AUTO_INCREMENT of '\(tableName)' to \(value)", isDestructive: false) + } + // MARK: - Primary Key Operations private func generateModifyPrimaryKey(old: [String], new: [String]) -> [SchemaStatement] { diff --git a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift index 40bb5e371b..dc271f8c80 100644 --- a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -16,7 +16,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { @Published private(set) var pendingChanges: [SchemaChangeIdentifier: SchemaChange] = [:] private var changeOrder: [SchemaChangeIdentifier] = [] @Published private(set) var validationErrors: [SchemaChangeIdentifier: String] = [:] - var hasChanges: Bool { !pendingChanges.isEmpty } + var hasChanges: Bool { !pendingChanges.isEmpty || holdsInvalidAutoIncrementEntry } @Published var reloadVersion: Int = 0 // Current state (loaded from database) @@ -31,7 +31,9 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { @Published var workingIndexes: [EditableIndexDefinition] = [] @Published var workingForeignKeys: [EditableForeignKeyDefinition] = [] @Published var workingCheckConstraints: [EditableCheckConstraintDefinition] = [] - @Published var workingPrimaryKey: [String] = [] + // Derived from the columns after every change: the ones that stay and are marked Primary Key, the loaded members in + // the loaded key's order, new ones after them in column order. A key nobody changed follows a rename by itself. + @Published private(set) var workingPrimaryKey: [String] = [] @Published var tableName: String? @@ -60,6 +62,12 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { private static var tableCommentActionName: String { String(localized: "Edit Comment") } + @Published private(set) var tableOptions = TableOptionsDraft(original: nil) + // Text in Next Auto Increment that is not a counter. It holds Save and counts as unsaved, so it is never dropped quietly. + @Published private(set) var invalidAutoIncrementEntry: String? + + private static var tableOptionsActionName: String { String(localized: "Edit Table Options") } + // MARK: - Undo/Redo Support /// Owned per manager so its actions cannot outlive the editor. Cmd+Z reaches it through @@ -79,18 +87,41 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { return manager }() - var canUndo: Bool { !isHeldForSave && (undoManager.canUndo || hasTableCommentRun) } - var canRedo: Bool { !isHeldForSave && !hasTableCommentRun && undoManager.canRedo } + var canUndo: Bool { !isHeldForSave && (undoManager.canUndo || hasTableCommentRun || typingRun != nil) } + var canRedo: Bool { !isHeldForSave && !hasTableCommentRun && typingRun == nil && undoManager.canRedo } - /// The DDL and Triggers sub-tabs show no grid, so undo there reaches only the comment. - var undoesTableCommentNext: Bool { - hasTableCommentRun || (undoManager.canUndo && undoManager.undoActionName == Self.tableCommentActionName) + var undoMenuItemTitle: String { + if hasTableCommentRun { return undoManager.undoMenuTitle(forUndoActionName: Self.tableCommentActionName) } + if let typingRun { return undoManager.undoMenuTitle(forUndoActionName: typingRun.actionName) } + return undoManager.undoMenuItemTitle } - var redoesTableCommentNext: Bool { - !hasTableCommentRun && undoManager.canRedo && undoManager.redoActionName == Self.tableCommentActionName + // Typing in one field of one definition: every keystroke is staged, so Save, Refresh and the close prompt see it, + // and the whole run undoes as one step. Anything else that registers an undo ends it first. + private var typingRun: (key: String, actionName: String)? + private var isStagingTyping = false + + func stageTyping(in key: String, actionName: String, _ body: () -> Void) { + guard !isHeldForSave else { return } + if typingRun?.key != key { endTypingRun() } + if typingRun == nil { + endTableCommentRun() + undoManager.beginUndoGrouping() + typingRun = (key, actionName) + } + isStagingTyping = true + defer { isStagingTyping = false } + body() + } + + func endTypingRun() { + guard typingRun != nil else { return } + typingRun = nil + undoManager.endUndoGrouping() } + var redoMenuItemTitle: String { undoManager.redoMenuItemTitle } + /// Mirrors `DataChangeManager.registerUndo`. The `groupingLevel` check is what lets /// `performAsOneUndoStep` nest: inside one, a group is already open and this adds to it rather /// than closing a group the batch still needs. An open comment run ends first, so an edit made @@ -98,6 +129,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { private func registerUndo(_ actionName: String, _ handler: @escaping (StructureChangeManager) -> Void) { if !undoManager.isUndoing, !undoManager.isRedoing { endTableCommentRun() + if !isStagingTyping { endTypingRun() } } let opensOwnGroup = !undoManager.groupsByEvent && undoManager.groupingLevel == 0 if opensOwnGroup { undoManager.beginUndoGrouping() } @@ -112,6 +144,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { func performAsOneUndoStep(_ body: () -> Void) { guard !isHeldForSave else { return } endTableCommentRun() + if !isStagingTyping { endTypingRun() } undoManager.beginUndoGrouping() defer { undoManager.endUndoGrouping() } body() @@ -126,12 +159,17 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { foreignKeys: [ForeignKeyInfo], checkConstraints: [CheckConstraintInfo] = [], primaryKey: [String], - tableComment: String? = nil + tableComment: String? = nil, + tableOptions: TableOptions? = nil, + autoIncrementConfirmed: Bool = true ) { guard !isHeldForSave else { return } + endTypingRun() self.tableName = tableName self.tableComment = ObjectCommentDraft(original: tableComment) tableCommentRunStart = nil + self.tableOptions = TableOptionsDraft(original: tableOptions, isAutoIncrementConfirmed: autoIncrementConfirmed) + invalidAutoIncrementEntry = nil self.currentColumns = columns.map { EditableColumnDefinition.from($0) } @@ -145,6 +183,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { self.currentForeignKeys = EditableForeignKeyDefinition.grouping(foreignKeys).sorted { $0.name < $1.name } self.currentCheckConstraints = checkConstraints.map { EditableCheckConstraintDefinition.from($0) } self.currentPrimaryKey = primaryKey + keepIdentitiesAcrossReload() resetWorkingState() @@ -159,6 +198,34 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { reloadVersion += 1 } + // Every fetch mints fresh ids; a definition that comes back under a name it had keeps its id, so the grid + // selection and the inspector survive a refresh or a save. + private func keepIdentitiesAcrossReload() { + currentColumns = Self.keepingIDs(of: currentColumns, from: workingColumns, \.name, \.id) + currentIndexes = Self.keepingIDs(of: currentIndexes, from: workingIndexes, \.name, \.id) + currentForeignKeys = Self.keepingIDs(of: currentForeignKeys, from: workingForeignKeys, \.name, \.id) + currentCheckConstraints = Self.keepingIDs(of: currentCheckConstraints, from: workingCheckConstraints, \.name, \.id) + } + + private static func keepingIDs( + of loaded: [Entity], + from earlier: [Entity], + _ name: KeyPath, + _ id: WritableKeyPath + ) -> [Entity] { + var ids: [String: UUID] = [:] + for entity in earlier where !entity[keyPath: name].isEmpty && ids[entity[keyPath: name]] == nil { + ids[entity[keyPath: name]] = entity[keyPath: id] + } + var used = Set() + return loaded.map { entity in + guard let previous = ids[entity[keyPath: name]], used.insert(previous).inserted else { return entity } + var kept = entity + kept[keyPath: id] = previous + return kept + } + } + private func resetWorkingState() { workingColumns = currentColumns workingIndexes = currentIndexes @@ -302,7 +369,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { let key = operations.identifier(id) if let currentIndex = self[keyPath: operations.current].firstIndex(where: { $0.id == id }) { let oldEntity = self[keyPath: operations.current][currentIndex] - if oldEntity != newEntity { + if operations.needsStatement(oldEntity, newEntity) { pendingChanges[key] = operations.modification(oldEntity, newEntity) trackChangeKey(key) } else { @@ -357,7 +424,12 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { deletionUndo: { SchemaUndoAction.columnDelete(column: $0, at: $1) }, addActionName: String(localized: "Add Column"), editActionName: String(localized: "Edit Column"), - deleteActionName: String(localized: "Delete Column") + deleteActionName: String(localized: "Delete Column"), + needsStatement: { loaded, working in + var working = working + working.isPrimaryKey = loaded.isPrimaryKey + return loaded != working + } ) private static let indexOperations = SchemaEntityOperations( @@ -408,45 +480,45 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { // MARK: - Row-Specific Undo Delete - /// Clear the deletion mark for the entity at `row` in `tab`. Mirrors - /// `DataChangeManager.undoRowDeletion(rowID:)`: the global NSUndoManager - /// stack is intentionally left alone. The original `applySchemaUndo(...)` - /// handler the deletion registered remains on the stack; if global Cmd+Z - /// later invokes it, the handler finds `pendingChanges` no longer marks - /// this row as deleted and treats the redo as a no-op for this entity. The - /// row-specific affordance and the global undo stack are independent - /// affordances. The data tab uses the same separation. - func undoDelete(for tab: StructureTab, at row: Int) { + // The row's own Undo Delete leaves the global stack alone, like the data tab's; an edit made before the + // delete is staged again, since the working copy still shows it. + func undoDelete(section: StructureSection, id: UUID) { guard !isHeldForSave else { return } - let key: SchemaChangeIdentifier - switch tab { - case .columns: - guard row < workingColumns.count else { return } - key = .column(workingColumns[row].id) - case .indexes: - guard row < workingIndexes.count else { return } - key = .index(workingIndexes[row].id) - case .foreignKeys: - guard row < workingForeignKeys.count else { return } - key = .foreignKey(workingForeignKeys[row].id) - case .checkConstraints: - guard row < workingCheckConstraints.count else { return } - key = .checkConstraint(workingCheckConstraints[row].id) - case .ddl, .parts, .triggers: - return + switch section { + case .columns: undoDeletion(id: id, using: Self.columnOperations) + case .indexes: undoDeletion(id: id, using: Self.indexOperations) + case .foreignKeys: undoDeletion(id: id, using: Self.foreignKeyOperations) + case .checkConstraints: undoDeletion(id: id, using: Self.checkConstraintOperations) } + } + + private func undoDeletion(id: UUID, using operations: SchemaEntityOperations) { + let key = operations.identifier(id) guard pendingChanges[key]?.isDelete == true else { return } - pendingChanges.removeValue(forKey: key) - untrackChangeKey(key) + restageEdit(id: id, using: operations) workingCopyDidChange() } + private func restageEdit(id: UUID, using operations: SchemaEntityOperations) { + let key = operations.identifier(id) + guard let loaded = self[keyPath: operations.current].first(where: { $0.id == id }), + let working = self[keyPath: operations.working].first(where: { $0.id == id }), + operations.needsStatement(loaded, working) else { + pendingChanges.removeValue(forKey: key) + untrackChangeKey(key) + return + } + pendingChanges[key] = operations.modification(loaded, working) + trackChangeKey(key) + } + // MARK: - Validation /// Runs after every change to the working copy, undo and redo included: first what the change /// decides for rows it did not touch, then validation. private func workingCopyDidChange() { settleClusteredAdditions() + restagePrimaryKey() validate() } @@ -555,6 +627,45 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { format: String(localized: "Primary key references a column that does not exist: %@"), columnName ) } + + validateAutoIncrement() + validateConvert() + } + + /// A MODIFY restates the loaded type, narrowing a TEXT the convert widened, and a replaced key is converted before it drops. + private func validateConvert() { + guard case .modifyTableCharacterSet(_, let new)? = pendingChanges[.tableCharacterSet], new.convertsColumns else { + return + } + let restatesColumns = pendingChanges.values.contains { change in + switch change { + case .modifyColumn, .modifyPrimaryKey: true + default: false + } + } + guard restatesColumns else { return } + validationErrors[.tableCharacterSet] = String( + localized: "Save column and primary key changes before converting existing columns." + ) + } + + /// Against the column as the save leaves it: a widening in the same save keeps a larger counter (MySQL 8.4, MariaDB 11.4). + private func validateAutoIncrement() { + if holdsInvalidAutoIncrementEntry, let invalidAutoIncrementEntry { + validationErrors[.autoIncrement] = invalidAutoIncrementEntry + return + } + guard case .modifyAutoIncrement(_, let value)? = pendingChanges[.autoIncrement] else { return } + guard let range = autoIncrementRange else { + validationErrors[.autoIncrement] = String( + localized: "Next Auto Increment needs a column with AUTO_INCREMENT" + ) + return + } + guard !range.contains(value) else { return } + validationErrors[.autoIncrement] = String( + format: String(localized: "Next Auto Increment must be between 1 and %@"), String(range.upperBound) + ) } /// Every column the table keeps after this save, whatever state its name and type are in. @@ -562,6 +673,17 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { workingColumns.filter { !isPendingDeletion(.column($0.id)) } } + var autoIncrementColumn: EditableColumnDefinition? { + columnsAfterSave.first { $0.autoIncrement } + } + + /// Nil without an AUTO_INCREMENT column; a type the range does not know is held to the counter's width. + var autoIncrementRange: ClosedRange? { + guard let column = autoIncrementColumn else { return nil } + let maximum = AutoIncrementRange.maximum(forColumnType: column.dataType, unsigned: column.unsigned) + return 1...(maximum ?? UInt64.max) + } + /// Only a column this save adds or changes is held to being complete. /// /// An untouched column is the database's own, and a typeless SQLite column or an empty MongoDB @@ -692,8 +814,13 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { func discardChanges() { guard !isHeldForSave else { return } + endTypingRun() tableComment = ObjectCommentDraft(original: tableComment.original) tableCommentRunStart = nil + tableOptions = TableOptionsDraft( + original: tableOptions.original, isAutoIncrementConfirmed: tableOptions.isAutoIncrementConfirmed + ) + invalidAutoIncrementEntry = nil pendingChanges.removeAll() changeOrder.removeAll() validationErrors.removeAll() @@ -713,6 +840,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { /// Taken before the save's first suspension, which is what refuses a second press. func holdForSave() -> StructureSaveSnapshot? { endTableCommentRun() + endTypingRun() guard heldSave == nil, hasChanges else { return nil } let snapshot = StructureSaveSnapshot(changes: getChangesArray()) heldSave = snapshot @@ -731,15 +859,41 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { for case .modifyTableComment(_, let savedComment) in snapshot.changes { tableComment = ObjectCommentDraft(original: savedComment) } + var writtenOptions = tableOptions.original + var isAutoIncrementConfirmed = tableOptions.isAutoIncrementConfirmed + for change in snapshot.changes { + switch change { + case .modifyTableCharacterSet(_, let saved): + writtenOptions = writtenOptions?.adoptingCharacterSet(of: saved) + case .modifyAutoIncrement: + // Left to the re-read: the server silently raises a value below the next free one. + isAutoIncrementConfirmed = false + default: + continue + } + } + tableOptions = TableOptionsDraft(original: writtenOptions, isAutoIncrementConfirmed: isAutoIncrementConfirmed) + adoptWrittenDefinitions() discardChanges() return true } + // The written definitions stand in until the re-read lands, so a renamed or new definition keeps its id, its + // selection and the name the re-read is matched by. + private func adoptWrittenDefinitions() { + currentColumns = workingColumns.filter { !isPendingDeletion(.column($0.id)) } + currentIndexes = workingIndexes.filter { !isPendingDeletion(.index($0.id)) } + currentForeignKeys = workingForeignKeys.filter { !isPendingDeletion(.foreignKey($0.id)) } + currentCheckConstraints = workingCheckConstraints.filter { !isPendingDeletion(.checkConstraint($0.id)) } + currentPrimaryKey = workingPrimaryKey + } + // MARK: - Undo/Redo Operations func undo() { guard !isHeldForSave else { return } endTableCommentRun() + endTypingRun() guard undoManager.canUndo else { return } undoManager.undo() } @@ -747,6 +901,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { func redo() { guard !isHeldForSave else { return } endTableCommentRun() + endTypingRun() guard undoManager.canRedo else { return } undoManager.redo() } @@ -791,6 +946,55 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { restageTableComment() } + // MARK: - Table Options + + /// One whole-value write, so a collation never resolves against a stale character set. + func stageTableOptions(_ options: TableOptions) { + guard !isHeldForSave, let previous = tableOptions.current, options != previous else { return } + // An unconfirmed baseline may be a counter the server already moved past. + guard tableOptions.isAutoIncrementConfirmed || options.autoIncrement == previous.autoIncrement else { return } + registerUndo(Self.tableOptionsActionName) { target in + target.applySchemaUndo(.tableOptionsEdit(old: previous, new: options)) + } + tableOptions.current = options + restageTableOptions() + workingCopyDidChange() + } + + // The field goes with the last AUTO_INCREMENT column, and so does what was typed in it. + private var holdsInvalidAutoIncrementEntry: Bool { + invalidAutoIncrementEntry != nil && autoIncrementRange != nil + } + + func noteAutoIncrementEntry(invalid message: String?) { + guard !isHeldForSave, invalidAutoIncrementEntry != message else { return } + invalidAutoIncrementEntry = message + workingCopyDidChange() + } + + private func restageTableOptions() { + restage(.tableCharacterSet, as: tableOptions.characterSetChange) + restage(.autoIncrement, as: tableOptions.autoIncrementChange) + } + + private func restage(_ key: SchemaChangeIdentifier, as change: SchemaChange?) { + if let change { + pendingChanges[key] = change + trackChangeKey(key) + } else { + pendingChanges.removeValue(forKey: key) + untrackChangeKey(key) + } + } + + private func applyTableOptionsUndo(old: TableOptions, new: TableOptions) { + registerUndo(Self.tableOptionsActionName) { target in + target.applySchemaUndo(.tableOptionsEdit(old: new, new: old)) + } + tableOptions.current = old + restageTableOptions() + } + private func applySchemaUndo(_ action: SchemaUndoAction) { switch action { case .columnEdit(let id, let old, let new): @@ -817,10 +1021,12 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { applyAdditionUndo(constraint, using: Self.checkConstraintOperations) case .checkConstraintDelete(let constraint, let at): applyDeletionUndo(constraint, at: at, using: Self.checkConstraintOperations) - case .primaryKeyChange(let old, _): - applyPrimaryKeyChangeUndo(old: old) + case .primaryKeyChange: + restagePrimaryKey() case .tableCommentEdit(let old, let new): applyTableCommentUndo(old: old, new: new) + case .tableOptionsEdit(let old, let new): + applyTableOptionsUndo(old: old, new: new) } workingCopyDidChange() @@ -844,7 +1050,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { return } let current = self[keyPath: operations.current][currentIndex] - if old != current { + if operations.needsStatement(current, old) { pendingChanges[key] = operations.modification(current, old) trackChangeKey(key) } else { @@ -879,8 +1085,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { } let key = operations.identifier(entity.id) if self[keyPath: operations.current].contains(where: { $0.id == entity.id }) { - pendingChanges.removeValue(forKey: key) - untrackChangeKey(key) + restageEdit(id: entity.id, using: operations) } else { if let row, row < self[keyPath: operations.working].count { self[keyPath: operations.working].insert(entity, at: row) @@ -892,19 +1097,31 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { } } - private func applyPrimaryKeyChangeUndo(old: [String]) { - let current = workingPrimaryKey - registerUndo(String(localized: "Change Primary Key")) { target in - target.applySchemaUndo(.primaryKeyChange(old: current, new: old)) + // MARK: - Primary key + + private func restagePrimaryKey() { + let loadedIDs = currentPrimaryKey.compactMap { name in currentColumns.first { $0.name == name }?.id } + let key = SchemaChangeIdentifier.primaryKey + // A key naming a column that was not read (a failed read, an expression key) is left as it was loaded. + guard loadedIDs.count == currentPrimaryKey.count else { + workingPrimaryKey = currentPrimaryKey + pendingChanges.removeValue(forKey: key) + untrackChangeKey(key) + return } - workingPrimaryKey = old - let pkKey = SchemaChangeIdentifier.primaryKey - if workingPrimaryKey != currentPrimaryKey { - pendingChanges[pkKey] = .modifyPrimaryKey(old: currentPrimaryKey, new: workingPrimaryKey) - trackChangeKey(pkKey) + let members = workingColumns.filter { $0.isPrimaryKey && !isPendingDeletion(.column($0.id)) } + let ordered = loadedIDs.compactMap { id in members.first { $0.id == id } } + + members.filter { !loadedIDs.contains($0.id) } + workingPrimaryKey = ordered.map(\.name) + // A key that only loses columns being dropped needs no statement: the database takes them out with the column, + // and on MySQL an AUTO_INCREMENT key cannot be dropped before its column. + let survivingLoadedIDs = loadedIDs.filter { !isPendingDeletion(.column($0)) } + if ordered.map(\.id) == loadedIDs || ordered.map(\.id) == survivingLoadedIDs { + pendingChanges.removeValue(forKey: key) + untrackChangeKey(key) } else { - pendingChanges.removeValue(forKey: pkKey) - untrackChangeKey(pkKey) + pendingChanges[key] = .modifyPrimaryKey(old: currentPrimaryKey, new: workingPrimaryKey) + trackChangeKey(key) } } @@ -916,7 +1133,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { /// merges the result of this method with `modifiedColumns` from /// `StructureEditingSupport` field-diff helpers to build the final /// `RowVisualState`. - func deleteInsertState(for row: Int, tab: StructureTab) -> (isDeleted: Bool, isInserted: Bool) { + func deleteInsertState(for row: Int, tab: StructureSection) -> (isDeleted: Bool, isInserted: Bool) { switch tab { case .columns: return rowState(at: row, using: Self.columnOperations) @@ -926,8 +1143,6 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { return rowState(at: row, using: Self.foreignKeyOperations) case .checkConstraints: return rowState(at: row, using: Self.checkConstraintOperations) - case .ddl, .parts, .triggers: - return (false, false) } } @@ -982,6 +1197,7 @@ enum SchemaUndoAction { case primaryKeyChange(old: [String], new: [String]) /// The field's raw text, so undo restores exactly what was typed. case tableCommentEdit(old: String, new: String) + case tableOptionsEdit(old: TableOptions, new: TableOptions) } /// The staged edits a save read when it was pressed, and so the only edits it may clear. diff --git a/apple/TablePro/Core/Scripting/ScriptEnumerations.swift b/apple/TablePro/Core/Scripting/ScriptEnumerations.swift index ba71e21420..98af280154 100644 --- a/apple/TablePro/Core/Scripting/ScriptEnumerations.swift +++ b/apple/TablePro/Core/Scripting/ScriptEnumerations.swift @@ -32,7 +32,8 @@ internal enum ScriptEnumerations { } } - internal static func code(for tabType: TabType) -> FourCharCode { + // Nil for a tab scripts never see: a debug tab belongs to a module, which adds no AppleScript surface. + internal static func code(for tabType: TabType) -> FourCharCode? { switch tabType { case .query: fourCharCode("TPk1") case .table: fourCharCode("TPk2") @@ -44,6 +45,7 @@ internal enum ScriptEnumerations { case .objectSource: fourCharCode("TPk8") case .versionHistory: fourCharCode("TPk9") case .tableListing: fourCharCode("TPkl") + case .routineDebug: nil } } diff --git a/apple/TablePro/Core/Scripting/ScriptTab.swift b/apple/TablePro/Core/Scripting/ScriptTab.swift index 42282cb8f3..3ff2d77e6a 100644 --- a/apple/TablePro/Core/Scripting/ScriptTab.swift +++ b/apple/TablePro/Core/Scripting/ScriptTab.swift @@ -29,12 +29,13 @@ internal final class ScriptTab: NSObject, ScriptCommandReceiving { /// `objectSpecifier`, which Cocoa calls without any isolation of its own. private let container: ScriptingBox - internal init(tab: QueryTab, connectionId: UUID, container: NSScriptObjectSpecifier?) { + internal init?(tab: QueryTab, connectionId: UUID, container: NSScriptObjectSpecifier?) { + guard let kind = ScriptEnumerations.code(for: tab.tabType) else { return nil } self.tabId = tab.id self.connectionId = connectionId self.uniqueId = tab.id.uuidString self.name = tab.title - self.kind = ScriptEnumerations.code(for: tab.tabType) + self.kind = kind self.tableName = tab.tableContext.tableName self.databaseName = tab.tableContext.databaseName.isEmpty ? nil : tab.tableContext.databaseName self.schemaName = tab.tableContext.schemaName diff --git a/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift b/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift index fb798aa997..13b8df1972 100644 --- a/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift +++ b/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift @@ -39,7 +39,7 @@ internal enum ScriptingSnapshot { guard isVisibleToScripts(connectionId: connectionId) else { return [] } let container = ScriptingSpecifiers.connection(uniqueId: connectionId.uuidString) return coordinators(forConnection: connectionId).flatMap { coordinator in - coordinator.tabManager.tabs.map { + coordinator.tabManager.tabs.compactMap { ScriptTab(tab: $0, connectionId: connectionId, container: container) } } diff --git a/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift b/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift index 43fabd37bd..7a13e22f1a 100644 --- a/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift +++ b/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift @@ -148,7 +148,8 @@ private extension PostgreSQLDashboardProvider { label: String(localized: "Uptime"), unit: "", icon: "clock", - query: "SELECT date_trunc('second', now() - pg_postmaster_start_time())::text" + // KingbaseES in Oracle mode subtracts timestamps into an Oracle interval, printed as +000000000 00:20:19. + query: "SELECT date_trunc('second', CAST(now() - pg_postmaster_start_time() AS pg_catalog.interval))::text" ), MetricDefinition( id: "active_queries", diff --git a/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift b/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift index e79f232f0e..03c6fe62f4 100644 --- a/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift +++ b/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift @@ -67,7 +67,7 @@ private enum DashboardEngine { init?(_ databaseType: DatabaseType) { switch databaseType { - case .postgresql: + case .postgresql, .kingbasees: self = .postgresql case .redshift: self = .redshift diff --git a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift index 998e44d9f0..d46a043edc 100644 --- a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift +++ b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift @@ -59,7 +59,7 @@ struct CellExpression: Hashable, Sendable { enum CellExpressionVocabulary { static func expressions(for databaseType: DatabaseType) -> [CellExpression] { switch databaseType { - case .postgresql, .pglite: postgreSQL + case .postgresql, .pglite, .kingbasees: postgreSQL case .cockroachdb: cockroachDB case .redshift: redshift case .mysql, .mariadb, .tidb, .oceanbase: mySQL diff --git a/apple/TablePro/Core/Services/Execution/PluginSessionTransactionState+Execution.swift b/apple/TablePro/Core/Services/Execution/PluginSessionTransactionState+Execution.swift index cbc1b84be1..11512c99bd 100644 --- a/apple/TablePro/Core/Services/Execution/PluginSessionTransactionState+Execution.swift +++ b/apple/TablePro/Core/Services/Execution/PluginSessionTransactionState+Execution.swift @@ -37,6 +37,18 @@ internal extension PluginSessionTransactionState { } } + /// Whether the session is inside a transaction the user opened, aborted or not. + var holdsUserTransaction: Bool { + switch self { + case .inTransaction, .abortedTransaction: + return true + case .idle, .holdsSessionLocks, .unknown: + return false + @unknown default: + return false + } + } + /// What the user has to be told about the transaction this session is holding, or nil when it /// is holding none. /// diff --git a/apple/TablePro/Core/Services/Execution/Providers/OperationAuthenticating.swift b/apple/TablePro/Core/Services/Execution/Providers/OperationAuthenticating.swift index 020c216a08..26314a7da9 100644 --- a/apple/TablePro/Core/Services/Execution/Providers/OperationAuthenticating.swift +++ b/apple/TablePro/Core/Services/Execution/Providers/OperationAuthenticating.swift @@ -14,9 +14,14 @@ internal struct BiometricOperationAuthenticating: OperationAuthenticating { private static let logger = Logger(subsystem: "com.TablePro", category: "ExecutionGate") func authenticate(reason: String) async -> Bool { - let context = LAContext() + // A caller that gives up invalidates the context, which cancels the evaluation and dismisses its prompt. + nonisolated(unsafe) let context = LAContext() do { - return try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) + return try await withTaskCancellationHandler { + try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) + } onCancel: { + context.invalidate() + } } catch { Self.logger.warning("Biometric authentication failed: \(error.publicLogShape, privacy: .public)") return false diff --git a/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift b/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift index a9b20f2aa9..eab4eb4f42 100644 --- a/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift +++ b/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift @@ -104,9 +104,16 @@ internal struct AlertOperationConfirming: OperationConfirming { preferring: NSApp.keyWindow ).map(Self.frontmostSheet(of:)) + guard !Task.isCancelled else { return false } guard let statement = OperationConfirmationPrompt.statement(of: request) else { let alert = OperationConfirmationPrompt.makeAlert(for: request) - return await AlertHelper.response(to: alert, in: window) == .alertFirstButtonReturn + let withdrawal = AlertWithdrawal(alert) + // A caller that gives up (a debug run stopped while it waits) takes the alert down as Cancel. + return await withTaskCancellationHandler { + await AlertHelper.response(to: alert, in: window) == .alertFirstButtonReturn + } onCancel: { + withdrawal.withdraw() + } } return await AlertHelper.runStatementConfirmation( @@ -132,3 +139,23 @@ internal struct AlertOperationConfirming: OperationConfirming { return frontmost } } + +// Lets a cancellation, which can arrive on any thread, answer an alert on the main actor. +private final class AlertWithdrawal: @unchecked Sendable { + private weak var alert: NSAlert? + + init(_ alert: NSAlert) { + self.alert = alert + } + + func withdraw() { + Task { @MainActor [weak alert] in + guard let alert else { return } + if let parent = alert.window.sheetParent { + parent.endSheet(alert.window, returnCode: .cancel) + } else if NSApp.modalWindow === alert.window { + NSApp.stopModal(withCode: .cancel) + } + } + } +} diff --git a/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift b/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift index e0594f8a7b..bc761bd61d 100644 --- a/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift +++ b/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift @@ -70,6 +70,7 @@ internal enum TransactionEngineFamily: String, Hashable, Sendable, CaseIterable "AlloyDB": .postgres, "Citus": .postgres, "Greenplum": .postgres, + "KingbaseES": .postgres, "Redshift": .redshift, "CockroachDB": .cockroach, "MySQL": .mysql, diff --git a/apple/TablePro/Core/Services/Export/ImportService.swift b/apple/TablePro/Core/Services/Export/ImportService.swift index c7b26a9aee..079b902492 100644 --- a/apple/TablePro/Core/Services/Export/ImportService.swift +++ b/apple/TablePro/Core/Services/Export/ImportService.swift @@ -97,7 +97,7 @@ final class ImportService: ObservableObject { source = SqlFileImportSource( url: url, encoding: encoding, - grammar: connection.type.lexicalGrammar, + grammar: SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id), family: TransactionEngineFamily.of(connection.type), decompressedURL: decompressedURL, ownsDecompressedFile: ownsDecompressedFile diff --git a/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift b/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift index c9c235ec9b..4215ed31d6 100644 --- a/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift +++ b/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift @@ -133,7 +133,10 @@ internal struct RowImportTableMaker { OperationRequest( connectionId: connection.id, databaseType: connection.type, - sql: SQLScriptText(databaseType: connection.type).script(statements), + sql: SQLScriptText( + databaseType: connection.type, + grammar: SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id) + ).script(statements), kind: clears ? .destructiveQuery : .schemaMutation, caller: .userInterface, capabilities: .interactiveUser, diff --git a/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift b/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift index 17451b68eb..e6ea542363 100644 --- a/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift +++ b/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift @@ -1,5 +1,6 @@ import Foundation import TableProPluginKit +import TableProSQLGrammar struct QueryFormatResult { let text: String @@ -12,16 +13,24 @@ protocol QueryFormatting { struct SQLQueryFormatter: QueryFormatting { private let dialect: DatabaseType + private let grammar: SQLLexicalGrammar? private let options: SQLFormatterOptions private let formatter = SQLFormatterService() - init(dialect: DatabaseType, options: SQLFormatterOptions = .default) { + init(dialect: DatabaseType, grammar: SQLLexicalGrammar? = nil, options: SQLFormatterOptions = .default) { self.dialect = dialect + self.grammar = grammar self.options = options } func format(_ text: String, cursorOffset: Int?) throws -> QueryFormatResult { - let result = try formatter.format(text, dialect: dialect, cursorOffset: cursorOffset, options: options) + let result = try formatter.format( + text, + dialect: dialect, + grammar: grammar, + cursorOffset: cursorOffset, + options: options + ) return QueryFormatResult(text: result.formattedSQL, cursorOffset: result.cursorOffset) } } @@ -32,9 +41,14 @@ enum QueryFormatterFactory { make(for: databaseType) { AppSettingsManager.shared.formatterOptions } } - // Throws when the linked folder's rules file can't be read, rather than format with Settings. - static func make(for databaseType: DatabaseType?, sourceFileURL: URL?) throws -> QueryFormatting? { - try make(for: databaseType) { try SQLFormatterRuleSource.options(forFileAt: sourceFileURL) } + // Throws when the linked folder's rules file can't be read, rather than format with Settings. `grammar` is the one + // the tab's statements are split with, so a literal or a comment ends where the connected engine ends it. + static func make( + for databaseType: DatabaseType?, + sourceFileURL: URL?, + grammar: SQLLexicalGrammar? = nil + ) throws -> QueryFormatting? { + try make(for: databaseType, grammar: grammar) { try SQLFormatterRuleSource.options(forFileAt: sourceFileURL) } } // Nil for a language with no formatter of its own. The SQL formatter reads a Redis key `user:1` as @@ -42,6 +56,7 @@ enum QueryFormatterFactory { // so running it there changes what the command does. private static func make( for databaseType: DatabaseType?, + grammar: SQLLexicalGrammar? = nil, sqlOptions: () throws -> SQLFormatterOptions ) rethrows -> QueryFormatting? { let dialect = databaseType ?? .mysql @@ -54,7 +69,7 @@ enum QueryFormatterFactory { switch PluginManager.shared.editorLanguage(for: dialect) { case .sql: - return SQLQueryFormatter(dialect: dialect, options: try sqlOptions()) + return SQLQueryFormatter(dialect: dialect, grammar: grammar, options: try sqlOptions()) case .javascript: return ConsoleRequestFormatter() case .bash, .custom: diff --git a/apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift b/apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift index 7800cecfc4..f87ac83045 100644 --- a/apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift +++ b/apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift @@ -41,21 +41,43 @@ struct SQLFormatterFolderRulesError: LocalizedError { } } +// The npm sql-formatter reads the same `.sql-formatter.json`, so a file either tool writes drives both. A rule both +// tools have uses sql-formatter's key and values; a TablePro-only rule uses a key sql-formatter ignores, never one it +// refuses (it throws on `commaPosition`, `aliasAs`, `tabulateAlias`, `multilineLists`, `newlineBeforeOpenParen`, +// `newlineBeforeCloseParen`); a sql-formatter option TablePro does not have, such as `language`, is ignored. enum SQLFormatterRulesFile { static let fileName = ".sql-formatter.json" static let exportFileName = "sql-formatter.json" static let maxSize = 64 * 1_024 + enum Key: String, CodingKey, CaseIterable { + case tabWidth + case useTabs + case keywordCase + case functionCase + case dataTypeCase + case logicalOperatorNewline + case commaPlacement + case lineWidth + case joinCondition + case alignAliases + case clauseItems + } + + // sql-formatter's own key. TablePro has no tabular layout, so a value sql-formatter documents is read and ignored. + static let indentStyleKey = "indentStyle" + static let sqlFormatterIndentStyles: Set = ["standard", "tabularLeft", "tabularRight"] + static func encode(_ options: SQLFormatterOptions) throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - return try encoder.encode(options) + return try encoder.encode(FileRules(options: options)) } static func decode(_ data: Data) throws -> SQLFormatterOptions { guard data.count <= maxSize else { throw SQLFormatterRulesError.tooLarge } do { - return try JSONDecoder().decode(SQLFormatterOptions.self, from: data) + return try JSONDecoder().decode(FileRules.self, from: data).options } catch let error as DecodingError { throw rulesError(for: error) } @@ -117,6 +139,101 @@ enum SQLFormatterRulesFile { } } +// Strict: a value this build cannot read throws and names its key, a key it does not know is ignored. +private struct FileRules: Codable { + typealias Key = SQLFormatterRulesFile.Key + + let options: SQLFormatterOptions + + init(options: SQLFormatterOptions) { + self.options = options + } + + private struct AnyKey: CodingKey { + let stringValue: String + var intValue: Int? { nil } + + init(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + nil + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Key.self) + var options = SQLFormatterOptions.default + if let useTabs = try container.decodeIfPresent(Bool.self, forKey: .useTabs) { + options.indentStyle = useTabs ? .tabs : .spaces + } + options.indentWidth = try Self.decodeInt(container, .tabWidth, in: SQLFormatterOptions.indentWidthRange) + ?? options.indentWidth + options.keywordCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .keywordCase) + ?? options.keywordCase + options.functionCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .functionCase) + ?? options.functionCase + options.dataTypeCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .dataTypeCase) + ?? options.dataTypeCase + options.logicalOperatorNewline = try container.decodeIfPresent( + SQLFormatterLogicalOperatorNewline.self, forKey: .logicalOperatorNewline + ) ?? options.logicalOperatorNewline + options.commaPosition = try container.decodeIfPresent(SQLFormatterCommaPosition.self, forKey: .commaPlacement) + ?? options.commaPosition + options.lineWidth = try Self.decodeInt(container, .lineWidth, in: SQLFormatterOptions.lineWidthRange) + ?? options.lineWidth + options.joinCondition = try container.decodeIfPresent(SQLFormatterJoinCondition.self, forKey: .joinCondition) + ?? options.joinCondition + options.alignAliases = try container.decodeIfPresent(Bool.self, forKey: .alignAliases) ?? options.alignAliases + options.clauseItems = try container.decodeIfPresent(SQLFormatterClauseItems.self, forKey: .clauseItems) + ?? options.clauseItems + + let shared = try decoder.container(keyedBy: AnyKey.self) + let indentStyleKey = AnyKey(stringValue: SQLFormatterRulesFile.indentStyleKey) + if let indentStyle = try shared.decodeIfPresent(String.self, forKey: indentStyleKey), + !SQLFormatterRulesFile.sqlFormatterIndentStyles.contains(indentStyle) { + throw DecodingError.dataCorruptedError( + forKey: indentStyleKey, + in: shared, + debugDescription: "indentStyle must be one of sql-formatter's values" + ) + } + self.options = options + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: Key.self) + try container.encode(options.indentWidth, forKey: .tabWidth) + try container.encode(options.indentStyle == .tabs, forKey: .useTabs) + try container.encode(options.keywordCase, forKey: .keywordCase) + try container.encode(options.functionCase, forKey: .functionCase) + try container.encode(options.dataTypeCase, forKey: .dataTypeCase) + try container.encode(options.logicalOperatorNewline, forKey: .logicalOperatorNewline) + try container.encode(options.commaPosition, forKey: .commaPlacement) + try container.encode(options.lineWidth, forKey: .lineWidth) + try container.encode(options.joinCondition, forKey: .joinCondition) + try container.encode(options.alignAliases, forKey: .alignAliases) + try container.encode(options.clauseItems, forKey: .clauseItems) + } + + private static func decodeInt( + _ container: KeyedDecodingContainer, + _ key: Key, + in range: ClosedRange + ) throws -> Int? { + guard let value = try container.decodeIfPresent(Int.self, forKey: key) else { return nil } + guard range.contains(value) else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must be between \(range.lowerBound) and \(range.upperBound)" + ) + } + return value + } +} + @MainActor enum SQLFormatterRuleSource { // A rules file in the linked folder wins over Settings, so every Mac formats that folder alike. diff --git a/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift b/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift index 59e894d9ba..deddba5282 100644 --- a/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -4,11 +4,13 @@ // import Foundation +import TableProSQLGrammar protocol SQLFormatterProtocol { func format( _ sql: String, dialect: DatabaseType, + grammar: SQLLexicalGrammar?, cursorOffset: Int?, options: SQLFormatterOptions ) throws -> SQLFormatterResult @@ -17,14 +19,16 @@ protocol SQLFormatterProtocol { struct SQLFormatterService: SQLFormatterProtocol { private static let maxInputSize = 10 * 1_024 * 1_024 + // `grammar` is the one the tab's statements are split with; nil reads the engine's default. func format( _ sql: String, dialect: DatabaseType, + grammar: SQLLexicalGrammar? = nil, cursorOffset: Int? = nil, options: SQLFormatterOptions = .default ) throws -> SQLFormatterResult { guard sql.utf8.count <= Self.maxInputSize else { - throw SQLFormatterError.internalError("SQL too large (max \(Self.maxInputSize / 1_024 / 1_024)MB)") + throw SQLFormatterError.inputTooLarge(maxBytes: Self.maxInputSize) } let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) @@ -42,15 +46,21 @@ struct SQLFormatterService: SQLFormatterProtocol { .union(dialectProvider.functions) .union(dialectProvider.dataTypes) - let tokenizer = SQLTokenizer(dialectKeywords: dialectKeywords) - let tokens = tokenizer.tokenize(sql) + let tokenizer = SQLTokenizer(dialectKeywords: dialectKeywords, operators: dialectProvider.operators) + let readGrammar = grammar ?? dialect.lexicalGrammar + let tokens = tokenizer.tokenize(sql, grammar: readGrammar) var tokenFormatter = SQLTokenFormatter( options: options, dialectFunctions: dialectProvider.functions, dialectDataTypes: dialectProvider.dataTypes, - rewritesFunctionCase: dialectProvider.functionNamesAreCaseInsensitive + rewritesFunctionCase: dialectProvider.functionNamesAreCaseInsensitive, + rewritesDataTypeCase: dialectProvider.typeNamesAreCaseInsensitive + ) + let readsDirectives = !readGrammar.isDisjoint(with: [.batchSeparatorLines, .delimiterDirective, .slashLineTerminators]) + let formatted = tokenFormatter.format( + tokens, + directiveLines: readsDirectives ? { tokenizer.directiveLines(in: $0, grammar: readGrammar) } : nil ) - let formatted = tokenFormatter.format(tokens) let newCursor = cursorOffset.map { original in mapCursorPosition(original: original, oldLength: sql.utf16.count, newLength: formatted.utf16.count) @@ -66,8 +76,9 @@ struct SQLFormatterService: SQLFormatterProtocol { } } -// SELECT, SET and WHERE lists are written inline and kept open until their clause ends, when one that -// overflows the line width gets its separators rewritten broken and its aliases aligned. +// SELECT, SET, WHERE and HAVING lists are written inline and kept open until their clause ends, when one that +// overflows the line width gets its separators rewritten broken and its aliases aligned. A block's body sits one level +// under the line its `(` is on, and a CASE lines up with the line it starts on. internal struct SQLTokenFormatter { let options: SQLFormatterOptions @@ -84,6 +95,8 @@ internal struct SQLTokenFormatter { "BLOB", "CLOB", "BINARY", "VARBINARY", "JSON", "JSONB", "XML", "UUID", "SERIAL", "BIGSERIAL", + "DOUBLE PRECISION", "CHARACTER VARYING", "CHAR VARYING", "NATIONAL CHARACTER", "NATIONAL CHAR", "BIT VARYING", + "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITHOUT TIME ZONE", "TIME WITH TIME ZONE", "TIME WITHOUT TIME ZONE", ] // Width of `SELECT ` and `SET `, which the items after the first line up under. @@ -92,47 +105,84 @@ internal struct SQLTokenFormatter { private let functions: Set private let dataTypes: Set + // Multi-word types by their first word, longest first: `CHARACTER VARYING`, `TIMESTAMP WITH TIME ZONE`. + private let typePhrases: [String: [[String]]] private let rewritesFunctionCase: Bool + private let rewritesDataTypeCase: Bool private let indentUnit: String init( options: SQLFormatterOptions, dialectFunctions: Set = [], dialectDataTypes: Set = [], - rewritesFunctionCase: Bool + rewritesFunctionCase: Bool, + rewritesDataTypeCase: Bool = false ) { self.options = options self.functions = Self.builtinFunctions.union(dialectFunctions) self.dataTypes = Self.builtinDataTypes.union(dialectDataTypes) + var phrases: [String: [[String]]] = [:] + for type in dataTypes where type.contains(" ") { + let words = type.uppercased().split(separator: " ").map(String.init) + if let first = words.first { phrases[first, default: []].append(words) } + } + self.typePhrases = phrases.mapValues { $0.sorted { $0.count > $1.count } } self.rewritesFunctionCase = rewritesFunctionCase + self.rewritesDataTypeCase = rewritesDataTypeCase self.indentUnit = options.indentUnit } private var output = FormatterOutput() + private var stream: [SQLToken] = [] + private var spacedBefore: [Bool] = [] + private var lineBrokenBefore: [Bool] = [] + private var position = 0 private var clauseStack: [ClauseContext] = [] private var afterNewline = true private var selectColumnPad = Pad(levels: 0) private var inSelectColumns = false private var skipCount = 0 - private var suppressNextSpace = false - private var caseBasePad = Pad(levels: 0) + private var gluePosition: Int? + private var casePads: [Pad] = [] + // The pad of the line each open block's `(` sits on. + private var blockOpeners: [Pad] = [] + // The pad of the line being written, as it reads once its list breaks. + private var linePad = Pad(levels: 0) + private var pendingLead: (kind: ListKind, pad: Pad)? private var selectColumnPadStack: [Pad] = [] private var openLists: [PendingList] = [] + // True between CREATE ... TABLE and its column list, so only that paren becomes a table body. + private var awaitsTableBody = false + // True between CREATE ... FUNCTION or PROCEDURE and its parameter list. + private var awaitsParameterList = false + // Where `@x ` declares a variable: a DECLARE, and a routine's header up to its AS or BEGIN. Anywhere else + // the word after a variable is an alias (`FROM @rows date`). + private var inDeclare = false + private var inRoutineHeader = false + // The word before each open inline paren, keyed by its index in `clauseStack`. + private var parenOpeners: [Int: String] = [:] + // The last token of the multi-word type being written, which takes the type's case as one word does. + private var typePhraseEnd = -1 + private var directiveOffsets: [Int] = [] private enum ClauseContext { case select, from, where_, join, caseExpr, subquery, with case insert, update, set, createTable, createTableBody - case inlineParen, windowParen + case inlineParen, windowParen, clause } // Indent levels render as the indent unit, alignment spaces as spaces after them. private struct Pad { var levels: Int var spaces: Int = 0 + + func adding(levels extraLevels: Int = 0, spaces extraSpaces: Int = 0) -> Pad { + Pad(levels: levels + extraLevels, spaces: spaces + extraSpaces) + } } private enum ListKind { - case select, set, conditions + case select, set, conditions, cte } private struct Separator { @@ -146,63 +196,209 @@ internal struct SQLTokenFormatter { let lineStart: Int var separators: [Separator] = [] var aliasOffsets: [Int: Int] = [:] + // The break between the clause keyword and its first item, when items go below the keyword. + var hasLead = false + var itemPad: Pad? } // True after BETWEEN, so the next AND stays inline. private var afterBetween = false - private var newlinesAfterSemicolon: [Int: Int] = [:] + private var newlinesAfterTerminator: [Int: Int] = [:] + + // The next token writes with no space before it only while nothing was written since this was set. + private var suppressNextSpace: Bool { + get { gluePosition == output.count } + set { gluePosition = newValue ? output.count : nil } + } // MARK: - Public - mutating func format(_ tokens: [SQLToken]) -> String { - newlinesAfterSemicolon.removeAll() - var meaningfulIndex = -1 + // `directiveLines` reads the lines a text holds client directives on, under the grammar the tokens came from. + mutating func format(_ tokens: [SQLToken], directiveLines: ((String) -> Set)? = nil) -> String { + stream.removeAll() + spacedBefore.removeAll() + lineBrokenBefore.removeAll() + newlinesAfterTerminator.removeAll() + var pendingSpace = false + var pendingBreak = false for (i, token) in tokens.enumerated() { - if token.type == .whitespace { continue } - meaningfulIndex += 1 - if token.type == .punctuation && token.value == ";" { - var nlCount = 0 + if token.type == .whitespace { + pendingSpace = true + pendingBreak = pendingBreak || token.value.utf16.contains(0x0A) + continue + } + if Self.isTerminator(token) { + var count = 0 for j in (i + 1).. SQLToken? in - t.type == .whitespace ? nil : t - } - - for mi in meaningful.indices { + for index in stream.indices { if skipCount > 0 { skipCount -= 1 continue } + position = index + processToken(stream[index], prev: token(at: index - 1), next: token(at: index + 1)) + } - let token = meaningful[mi] - let next: SQLToken? = mi + 1 < meaningful.count ? meaningful[mi + 1] : nil - let prev: SQLToken? = mi > 0 ? meaningful[mi - 1] : nil - let next2: SQLToken? = mi + 2 < meaningful.count ? meaningful[mi + 2] : nil + closeLists(atOrDeeperThan: 0) + var text = output.string + if let directiveLines { + text = Self.joiningManufacturedDirectives( + in: text, + written: writtenDirectiveLines(), + directiveLines: directiveLines + ) + } + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } - processToken(token, mi: mi, prev: prev, next: next, next2: next2) + // The line each directive was written on, counted in one pass: a script can hold tens of thousands of `GO` lines. + private func writtenDirectiveLines() -> Set { + var lines: Set = [] + var line = 0 + var cursor = 0 + for offset in directiveOffsets { + while cursor < offset { + if output.bytes[cursor] == UInt8(ascii: "\n") { line += 1 } + cursor += 1 + } + lines.insert(line) } + return lines + } - closeLists(atOrDeeperThan: 0) - return output.string.trimmingCharacters(in: .whitespacesAndNewlines) + // A word alone on a line can read as a client directive the input never had: a column named `go` left alone by + // a leading comma is a `GO` batch separator, and the server then gets two broken batches. Such a line joins the + // line before it, or the line after it when the one before is a line comment, so formatting adds no directive. + private static func joiningManufacturedDirectives( + in text: String, + written: Set, + directiveLines: (String) -> Set + ) -> String { + var lines = text.components(separatedBy: "\n") + var kept = written + // A join can leave `go` before a line that starts with a number, which reads as `GO 5`; one more pass fixes it. + for _ in 0..<3 { + let manufactured = directiveLines(lines.joined(separator: "\n")).subtracting(kept) + guard !manufactured.isEmpty else { break } + var joined: [String] = [] + joined.reserveCapacity(lines.count) + var joinedKept: Set = [] + var index = 0 + while index < lines.count { + let line = lines[index] + defer { index += 1 } + guard manufactured.contains(index) else { + if kept.contains(index) { joinedKept.insert(joined.count) } + joined.append(line) + continue + } + if let previous = joined.last, !joinedKept.contains(joined.count - 1), canTakeJoinedCode(previous) { + joined[joined.count - 1] = previous + " " + line.drop { $0 == " " || $0 == "\t" } + } else if index + 1 < lines.count, !kept.contains(index + 1), canTakeJoinedCode(lines[index + 1]) { + joined.append(line + " " + lines[index + 1].drop { $0 == " " || $0 == "\t" }) + index += 1 + } else { + joinedKept.insert(joined.count) + joined.append(line) + } + } + lines = joined + kept = joinedKept + } + return lines.joined(separator: "\n") + } + + private static func canTakeJoinedCode(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + return !trimmed.isEmpty && !trimmed.hasPrefix("--") && !trimmed.hasPrefix("#") && !trimmed.hasPrefix("//") + } + + private func token(at index: Int) -> SQLToken? { + stream.indices.contains(index) ? stream[index] : nil + } + + private var next2: SQLToken? { + token(at: position + 2) + } + + // The next token that is not a comment, for decisions that skip nothing. + private var nextCode: SQLToken? { + stream[(position + 1)...].first { $0.type != .comment } + } + + // A custom delimiter (`DELIMITER //`) arrives as punctuation, like the `;` it stands in for. + private static func isTerminator(_ token: SQLToken) -> Bool { + token.type == .punctuation && !["(", ")", ",", "."].contains(token.value) } // MARK: - Token Processing - private mutating func processToken(_ token: SQLToken, mi: Int, prev: SQLToken?, next: SQLToken?, next2: SQLToken?) { + private mutating func processToken(_ token: SQLToken, prev: SQLToken?, next: SQLToken?) { + trackDeclarations(token) + if let lead = pendingLead, token.type != .comment, + !(lead.kind == .select && ["DISTINCT", "ALL"].contains(token.upperValue)), + !(lead.kind == .cte && token.upperValue == "RECURSIVE") { + pendingLead = nil + if let index = openLists.lastIndex(where: { $0.kind == lead.kind && $0.depth == indent }) { + openLists[index].hasLead = true + } + appendSeparator(kind: lead.kind, inline: " ", broken: "\n" + render(lead.pad)) + afterNewline = false + suppressNextSpace = true + } + if position <= typePhraseEnd || startsTypePhrase() { + appendToken(typeCased(token)) + return + } switch token.type { case .comment: handleComment(token) + case .directive: + handleDirective(token) case .punctuation: - handlePunctuation(token, mi: mi, prev: prev, next: next) + handlePunctuation(token, prev: prev) case .keyword: - handleKeyword(token, prev: prev, next: next, next2: next2) + handleKeyword(token, prev: prev, next: next) + case .operator where token.value == "[": + if afterNewline { + output += indentStr() + "[" + } else { + output += suppressNextSpace || !spacedBefore[position] ? "[" : " [" + } + afterNewline = false + suppressNextSpace = true + // An array's elements are not the items of the list it sits in: `ARRAY[1, 2]` stays on one line. + parenOpeners[clauseStack.count] = "[" + clauseStack.append(.inlineParen) + case .operator where token.value == "]": + if currentContext == .inlineParen, parenOpeners[clauseStack.count - 1] == "[" { + clauseStack.removeLast() + } + writeGlued("]") + case .string where prev?.type == .string && lineBrokenBefore[position] && !afterNewline: + // SQL joins two string literals only across a line break, and PostgreSQL refuses `'foo' 'bar'` on one line. + let pad = linePad + newline() + linePad = pad + output += render(pad) + token.value + afterNewline = false + case .string where !spacedBefore[position] && prev?.type == .identifier && !afterNewline: + // `r'raw'`, `b'0101'`: a word written against a quote may introduce the literal, and apart it would not. + output += token.value + gluePosition = nil default: appendToken(token.value) } @@ -210,62 +406,108 @@ internal struct SQLTokenFormatter { private mutating func handleComment(_ token: SQLToken) { if !afterNewline { newline() } - output += token.value - output += "\n" + output += indentStr() + token.value + "\n" afterNewline = true } + // A `GO` or `DELIMITER` line ends what came before it and stays a line of its own. + private mutating func handleDirective(_ token: SQLToken) { + closeLists(atOrDeeperThan: 0) + if !afterNewline { newline() } + directiveOffsets.append(output.count) + output += token.value + "\n" + afterNewline = true + resetStatement() + } + + private static let statementStarters: Set = [ + "SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "SET", "WITH", "IF", "WHILE", "BEGIN", "END", "RETURN", + "EXEC", "EXECUTE", "PRINT", "OPEN", "FETCH", "CLOSE", "CREATE", "ALTER", "DROP", "TRUNCATE", + ] + + private mutating func trackDeclarations(_ token: SQLToken) { + guard Self.isWord(token) else { return } + let upper = token.upperValue + if currentContext == .createTable, upper == "FUNCTION" || upper == "PROCEDURE" { + awaitsParameterList = true + inRoutineHeader = true + } else if upper == "AS" || upper == "BEGIN" { + inRoutineHeader = false + } + if upper == "DECLARE" { + inDeclare = true + } else if Self.statementStarters.contains(upper) { + inDeclare = false + } + } + + private mutating func resetStatement() { + clauseStack.removeAll() + selectColumnPadStack.removeAll() + blockOpeners.removeAll() + casePads.removeAll() + linePad = Pad(levels: 0) + pendingLead = nil + inSelectColumns = false + awaitsTableBody = false + awaitsParameterList = false + inDeclare = false + inRoutineHeader = false + afterBetween = false + } + // MARK: - Punctuation - private mutating func handlePunctuation(_ token: SQLToken, mi: Int, prev: SQLToken?, next: SQLToken?) { + private mutating func handlePunctuation(_ token: SQLToken, prev: SQLToken?) { switch token.value { - case ";": - output += ";" - closeLists(atOrDeeperThan: 0) - let originalNewlines = newlinesAfterSemicolon[mi] ?? 0 - let newlineCount = min(max(originalNewlines, 1), 3) - output += String(repeating: "\n", count: newlineCount) - afterNewline = true - clauseStack.removeAll() - selectColumnPadStack.removeAll() - inSelectColumns = false - case ",": handleComma() - case "(": - handleOpenParen(next: next, prev: prev) - + handleOpenParen(prev: prev) case ")": handleCloseParen() - case ".": output += "." afterNewline = false suppressNextSpace = true - default: - appendToken(token.value) + handleTerminator(token) } } + private mutating func handleTerminator(_ token: SQLToken) { + if afterNewline { + output += indentStr() + token.value + } else { + output += token.value == ";" ? ";" : " " + token.value + } + closeLists(atOrDeeperThan: 0) + let originalNewlines = newlinesAfterTerminator[position] ?? 0 + let newlineCount = min(max(originalNewlines, 1), 3) + output += String(repeating: "\n", count: newlineCount) + afterNewline = true + resetStatement() + } + private mutating func handleComma() { if inSelectColumns && currentContext == .select { appendCommaSeparator(kind: .select, alignedTo: selectColumnPad) + } else if currentContext == .with && options.clauseItems == .newLine { + appendCommaSeparator(kind: .cte, alignedTo: base.adding(levels: 1)) } else if currentContext == .set { - appendCommaSeparator(kind: .set, alignedTo: Pad(levels: indent, spaces: Self.setAlignment)) + appendCommaSeparator(kind: .set, alignedTo: setItemPad) } else if currentContext == .createTableBody { switch options.commaPosition { case .trailing: output += ",\n" afterNewline = true case .leading: - output += "\n" + render(Pad(levels: indent)) + "," + output += "\n" + indentStr() + "," afterNewline = false suppressNextSpace = false } } else { - output += "," + writeGlued(",") } } @@ -278,13 +520,14 @@ internal struct SQLTokenFormatter { broken = "\n" + render(Pad(levels: pad.levels, spaces: max(pad.spaces - 2, 0))) + ", " } appendSeparator(kind: kind, inline: ", ", broken: broken) + linePad = pad afterNewline = false suppressNextSpace = true } - private mutating func handleOpenParen(next: SQLToken?, prev: SQLToken?) { - if next?.upperValue == "SELECT" { - output += suppressNextSpace ? "(" : " (" + private mutating func handleOpenParen(prev: SQLToken?) { + if nextCode?.upperValue == "SELECT" { + writeOpener() openBlock() return } @@ -293,74 +536,90 @@ internal struct SQLTokenFormatter { openBlock() return } - if clauseStack.contains(.createTable) && !clauseStack.contains(.createTableBody) { - output += " (" - output += "\n" + if awaitsTableBody { + awaitsTableBody = false + output += " (\n" afterNewline = true - suppressNextSpace = false + blockOpeners.append(linePad) clauseStack.append(.createTableBody) + linePad = base return } if prev?.upperValue == "OVER" { - output += "(" - afterNewline = false + writeGlued("(") suppressNextSpace = true clauseStack.append(.windowParen) return } + let opener = awaitsParameterList ? Self.parameterListOpener : prev?.upperValue ?? "" + awaitsParameterList = false // A structural keyword takes a space before its paren (`VALUES (`, `IN (`); a function call // or a sized type does not (`COUNT(*)`, `VARCHAR(255)`). - if let prevUpper = prev?.upperValue, prev?.type == .keyword, - !functions.contains(prevUpper), !dataTypes.contains(prevUpper) { - output += suppressNextSpace ? "(" : " (" - } else if currentContext == .insert && prev?.type == .identifier { - output += " (" + if typePhraseEnd == position - 1 { + writeGlued("(") + } else if let prev, prev.type == .keyword, !functions.contains(prev.upperValue), !dataTypes.contains(prev.upperValue) { + writeOpener() + } else if currentContext == .insert && Self.isQuotedOrPlainName(prev) { + writeOpener() + } else if prev?.value == "," { + writeOpener() } else { - output += "(" + writeGlued("(") } - afterNewline = false suppressNextSpace = true + parenOpeners[clauseStack.count] = opener clauseStack.append(.inlineParen) } + private static func isQuotedOrPlainName(_ token: SQLToken?) -> Bool { + guard let token else { return false } + return token.type == .identifier || (token.type == .string && token.value.hasPrefix("\"")) + } + + private mutating func writeOpener() { + if afterNewline { + output += indentStr() + "(" + } else { + output += suppressNextSpace ? "(" : " (" + } + afterNewline = false + } + private mutating func openBlock() { output += "\n" selectColumnPadStack.append(selectColumnPad) afterNewline = true - suppressNextSpace = false + blockOpeners.append(linePad) clauseStack.append(.subquery) + linePad = base } private mutating func handleCloseParen() { if currentContext == .inlineParen || currentContext == .windowParen { clauseStack.removeLast() - output += ")" - afterNewline = false - suppressNextSpace = false + writeGlued(")") return } if let idx = clauseStack.lastIndex(where: Self.isBlockContext) { closeLists(atOrDeeperThan: indent) clauseStack.removeSubrange(idx...) - output += "\n" + indentStr() + ")" + let opener = blockOpeners.popLast() ?? Pad(levels: 0) + output += (afterNewline ? "" : "\n") + render(opener) + ")" + linePad = opener afterNewline = false inSelectColumns = clauseStack.contains(.select) if let saved = selectColumnPadStack.popLast() { selectColumnPad = saved } - suppressNextSpace = false return } - output += ")" - afterNewline = false - suppressNextSpace = false + writeGlued(")") } // MARK: - Keywords - private mutating func handleKeyword(_ token: SQLToken, prev: SQLToken?, next: SQLToken?, next2: SQLToken?) { - // A word beside a dot is part of a qualified name (`e.end`, `dbo.len(x)`, `key.id`), and a - // name keeps its spelling on an engine with case-sensitive identifiers. + private mutating func handleKeyword(_ token: SQLToken, prev: SQLToken?, next: SQLToken?) { + // A word beside a dot is part of a qualified name (`e.end`, `dbo.len(x)`, `key.id`). if prev?.value == "." || next?.value == "." { appendToken(token.value) return @@ -379,15 +638,17 @@ internal struct SQLTokenFormatter { handleAndOr(kw: kw, upper: upper) case "JOIN": handleClauseKeyword(kw: kw, context: .join) - case "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL": - handleJoinPrefix(token, kw: kw, next: next, next2: next2) - case "OUTER": - break // absorbed by the JOIN prefix handler + case "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", "OUTER": + handleJoinPrefix(token, prev: prev, next: next) case "ON": handleOn(kw: kw) - case "OVER", "PARTITION", "BY", "ALL", "INTO", "TABLE": - appendToken(kw) + case "OVER", "PARTITION", "BY", "ALL", "INTO": + appendToken(isName(token) ? token.value : kw) + case "TABLE": + if currentContext == .createTable { awaitsTableBody = true } + appendToken(isName(token) ? token.value : kw) case "AS": + awaitsTableBody = false recordAliasIfTopLevel() appendToken(kw) case "BETWEEN": @@ -395,8 +656,10 @@ internal struct SQLTokenFormatter { appendToken(kw) case "ORDER", "GROUP": handleOrderGroup(kw: kw, next: next) - case "HAVING", "LIMIT", "OFFSET": - handleClauseKeyword(kw: kw, context: nil) + case "HAVING": + handleClauseKeyword(kw: kw, context: .where_, list: .conditions) + case "LIMIT", "OFFSET": + handleClauseKeyword(kw: kw, context: .clause) case "UNION", "INTERSECT", "EXCEPT", "MINUS": handleSetOperation(upper: upper, kw: kw, next: next) case "CASE": @@ -404,8 +667,7 @@ internal struct SQLTokenFormatter { case "WHEN", "ELSE": handleWhenElse(kw: kw) case "THEN": - output += " " + kw - afterNewline = false + appendToken(kw) case "END": handleEnd(kw: kw) case "WITH": @@ -414,7 +676,7 @@ internal struct SQLTokenFormatter { handleInsert(kw: kw, prev: prev, next: next) case "VALUES": // `a = VALUES(a)` in MySQL's ON DUPLICATE KEY UPDATE is a function call. - if prev?.type != .operator { newline() } + if prev?.type != .operator && !isInsideNestedExpression { breakLine() } appendToken(kw) case "UPDATE": handleStatementStart(kw: kw, prev: prev, context: .update) @@ -425,50 +687,247 @@ internal struct SQLTokenFormatter { case "CREATE": handleStatementStart(kw: kw, prev: prev, context: .createTable) default: - appendToken(cased(token, next: next)) + appendToken(cased(token, prev: prev, next: next)) } } - // `count` and `date` can name columns, so only a call takes the function case, and only where the - // engine accepts any spelling of the function. - private func cased(_ token: SQLToken, next: SQLToken?) -> String { + // `count` and `date` can name columns, so only a call takes the function case, and a word standing where a name + // stands keeps the spelling it was written in. + private func cased(_ token: SQLToken, prev: SQLToken?, next: SQLToken?) -> String { let upper = token.upperValue - if functions.contains(upper) { - guard rewritesFunctionCase, next?.value == "(" else { return token.value } - return options.functionCase.applied(upper: upper, original: token.value) + if functions.contains(upper), next?.value == "(" { + return rewritesFunctionCase ? options.functionCase.applied(upper: upper, original: token.value) : token.value + } + if isName(token) { + return token.value } if dataTypes.contains(upper) { + return isTypePosition(position) ? typeCased(token) : token.value + } + if functions.contains(upper) { return token.value } return options.keywordCase.applied(upper: upper, original: token.value) } + // MARK: - Types + + private static let parameterListOpener = "(parameters)" + private static let castFunctions: Set = ["CAST", "TRY_CAST", "SAFE_CAST"] + private static let parameterModes: Set = ["IN", "OUT", "INOUT", "VARIADIC"] + + // A word spelled like a type is a type only where the grammar puts one: a column definition, `CAST(x AS t)`, + // `x::t`, `RETURNS t`, `TYPE t`, a variable or parameter declaration, and the words that continue one + // (`DOUBLE PRECISION`). Anywhere else it may name a column (`date + 1`, `ORDER BY date`), and a name keeps its + // spelling. + private func isTypePosition(_ index: Int) -> Bool { + guard let prev = token(at: index - 1) else { return false } + let prevUpper = prev.upperValue + let beforePrev = token(at: index - 2) + switch prevUpper { + case "::", "RETURNS", "SETOF": + return true + case "TYPE": + return beforePrev.map { ($0.type != .keyword && Self.isNameToken($0)) || $0.upperValue == "DATA" } ?? false + case "AS": + if let beforePrev, Self.isVariable(beforePrev), declaresVariables { return true } + guard currentContext == .inlineParen, let opener = parenOpeners[clauseStack.count - 1] else { return false } + return Self.castFunctions.contains(opener) + default: + break + } + if Self.isVariable(prev), declaresVariables { return true } + let beforeUpper = beforePrev?.upperValue ?? "" + // A table variable's or table type's columns: `DECLARE @t TABLE (a int)`, `RETURNS @t TABLE (a int)`. + if currentContext == .inlineParen, parenOpeners[clauseStack.count - 1] == "TABLE", + ["(", ","].contains(beforeUpper), Self.isNameToken(prev) { + return true + } + if ["DECLARE", "COLUMN", "ADD", "MODIFY"].contains(beforeUpper), Self.isNameToken(prev), prevUpper != "IF" { + return true + } + if currentContext == .createTableBody, ["(", ","].contains(beforeUpper), Self.isNameToken(prev) { + return true + } + guard currentContext == .inlineParen, parenOpeners[clauseStack.count - 1] == Self.parameterListOpener else { + return false + } + if Self.isNameToken(prev), ["(", ","].contains(beforeUpper) || Self.parameterModes.contains(beforeUpper) { + return true + } + let next = token(at: index + 1)?.value + return ["(", ","].contains(prevUpper) && (next == "," || next == ")") + } + + private static func isWord(_ token: SQLToken) -> Bool { + token.type == .identifier || token.type == .keyword + } + + // A name as a declaration writes it: a word, or a quoted identifier (`"id"`, `[id]`, `` `id` ``). The tokenizer + // keeps `"..."` a string, as the query fingerprint reads it. + private static func isNameToken(_ token: SQLToken) -> Bool { + isWord(token) || (token.type == .string && token.value.hasPrefix("\"")) + } + + private static func isVariable(_ token: SQLToken) -> Bool { + token.type == .placeholder && token.value.hasPrefix("@") && !token.value.hasPrefix("@@") + } + + private var declaresVariables: Bool { + inDeclare || inRoutineHeader + } + + private func typeCased(_ token: SQLToken) -> String { + rewritesDataTypeCase ? options.dataTypeCase.applied(upper: token.upperValue, original: token.value) : token.value + } + + // A multi-word type in a type position is one type: every word takes its case, and the `WITH` of + // `TIMESTAMP WITH TIME ZONE` starts no statement. + private mutating func startsTypePhrase() -> Bool { + guard let candidates = typePhrases[stream[position].upperValue], Self.isWord(stream[position]) else { return false } + let match = candidates.first { words in + words.indices.dropFirst().allSatisfy { offset in + token(at: position + offset).map { Self.isWord($0) && $0.upperValue == words[offset] } ?? false + } + } + guard let match, isTypePosition(position) else { return false } + typePhraseEnd = position + match.count - 1 + return true + } + + // MARK: - Names + + // Values, actions and modifiers wherever they stand, never names (`SELECT DISTINCT *`, `ARRAY[1]`). + private static let valueWords: Set = [ + "NULL", "TRUE", "FALSE", "UNKNOWN", "DEFAULT", "MATCHED", "CASCADE", "RESTRICT", "DISTINCT", "ALL", "ARRAY", + "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_USER", "SESSION_USER", + "LOCALTIME", "LOCALTIMESTAMP", "SYSDATE", "SYSTIMESTAMP", "ROWNUM", "LEVEL", + ] + + private static let operandStarters: Set = [ + "SELECT", "DISTINCT", ",", "(", "BY", "WHERE", "AND", "OR", "ON", "HAVING", "WHEN", "THEN", "ELSE", "SET", + "RETURNING", "FROM", "JOIN", "INTO", "UPDATE", "WITH", + "=", "<>", "!=", "<", ">", "<=", ">=", "<=>", "+", "-", "*", "/", "%", "||", + ] + + private static let operandEnders: Set = [ + ",", ")", "FROM", "AS", "ASC", "DESC", "NULLS", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", + "UNION", "INTERSECT", "EXCEPT", "MINUS", "WINDOW", "AND", "OR", "THEN", "ELSE", "END", "WHEN", "ON", "USING", + "SET", "VALUES", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", + "IN", "IS", "LIKE", "ILIKE", "BETWEEN", "NOT", + "=", "<>", "!=", "<", ">", "<=", ">=", "<=>", "+", "-", "*", "/", "%", "||", "::", "[", + ] + + private static let tableIntroducers: Set = ["FROM", "JOIN", "UPDATE", "INTO"] + + // Words that come between those and the table: `FROM ONLY t`, `JOIN LATERAL f(x) a`, ClickHouse's `INTO TABLE t`. + private static let tableModifiers: Set = [ + "ONLY", "LATERAL", "TABLE", "TEMPORARY", "TEMP", "UNLOGGED", "IGNORE", "LOW_PRIORITY", "HIGH_PRIORITY", + "DELAYED", "OUTFILE", "DUMPFILE", + ] + + // What can end a select item or a table reference, so a word after it is that item's alias. + private static func endsOperand(_ token: SQLToken) -> Bool { + switch token.type { + case .identifier, .number, .string, .placeholder: + return true + case .keyword: + return token.upperValue == "END" || valueWords.contains(token.upperValue) + default: + return token.value == ")" || token.value == "]" + } + } + + private static let aliasEnders: Set = [ + ",", ")", "FROM", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", "UNION", "INTERSECT", "EXCEPT", + "MINUS", "WINDOW", "QUALIFY", "INTO", "ON", "USING", "SET", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", + "NATURAL", "OUTER", "PREWHERE", "SETTINGS", "FORMAT", "FETCH", + ] + + // Words that follow a table reference without naming it: ClickHouse's `FROM t FINAL`, `GLOBAL ANY JOIN`. + private static let tableReferenceModifiers: Set = ["FINAL", "GLOBAL", "ANY", "ASOF", "SEMI", "ANTI", "PASTE"] + + // `SELECT 1 rows`, `SELECT x date FROM t`, `FROM t first WHERE`: an alias written without AS keeps its spelling. + private func isImplicitAlias(_ upper: String, prev: SQLToken?, next: SQLToken?) -> Bool { + guard [.select, .from, .join].contains(currentContext), let prev, Self.endsOperand(prev), + !Self.tableReferenceModifiers.contains(upper) + else { + return false + } + guard let next else { return true } + return Self.isTerminator(next) || Self.aliasEnders.contains(next.upperValue) + } + + private static let constraintStarters: Set = [ + "CONSTRAINT", "PRIMARY", "FOREIGN", "UNIQUE", "CHECK", "EXCLUDE", "PERIOD", "LIKE", + ] + + // MySQL's `KEY idx (a)` against ClickHouse's `key String`. + private static let indexStarters: Set = ["KEY", "INDEX", "FULLTEXT", "SPATIAL"] + + // Engines that match names by exact case (ClickHouse, BigQuery tables, MySQL tables on Unix, SQL Server under a + // case-sensitive collation) read a re-cased name as another name, and MySQL titles a result column with the + // spelling it was selected by. So a keyword standing where a name stands keeps its spelling on every engine: an + // operand alone between a clause or operator and what ends it (`SELECT database, table`, `table = 'hits'`), an + // alias, a column in a column list. + private func isName(_ token: SQLToken) -> Bool { + let prev = stream[.. Int? { + var index = start + func word(_ offset: Int) -> String? { + stream.indices.contains(offset) && stream[offset].type == .keyword ? stream[offset].upperValue : nil + } + if word(index) == "NATURAL" { index += 1 } + if let side = word(index), ["LEFT", "RIGHT", "FULL", "INNER", "CROSS"].contains(side) { index += 1 } + if word(index) == "OUTER" { index += 1 } + guard index > start, let last = token(at: index), Self.isWord(last) else { return nil } + if last.type == .keyword, last.upperValue == "JOIN" { return index - start + 1 } + // APPLY is a word anywhere but right after CROSS or OUTER (`SELECT 1 apply`). + if last.upperValue == "APPLY", let before = word(index - 1), before == "CROSS" || before == "OUTER" { + return index - start + 1 + } + return nil + } + private mutating func handleOn(kw: String) { if currentContext == .join && options.joinCondition == .newLine { - newline() - output += render(Pad(levels: indent + 1)) + kw + breakLine() + linePad = base.adding(levels: 1) + output += render(linePad) + kw + afterNewline = false } else { - output += " " + kw + appendToken(kw) } - afterNewline = false } private mutating func handleOrderGroup(kw: String, next: SQLToken?) { + // `WITHIN GROUP (...)` + guard let next, next.upperValue == "BY" else { + appendToken(kw) + return + } + let byKw = options.keywordCase.applied(upper: "BY", original: next.value) + skipCount = 1 // Window specs and aggregates keep it inline: `OVER (ORDER BY x)`, `STRING_AGG(a, ',' ORDER BY a)` if clauseStack.contains(.windowParen) || isInsideNestedExpression { - if next?.upperValue == "BY" { - let byKw = options.keywordCase.synthesized("BY") - output += " " + kw + " " + byKw - afterNewline = false - skipCount = 1 - } else { - appendToken(kw) - } + appendToken(kw) + appendToken(byKw) return } closeLists(atOrDeeperThan: indent) inSelectColumns = false - if next?.upperValue == "BY" { - let byKw = options.keywordCase.synthesized("BY") - newline() - output += indentStr() + kw + " " + byKw - afterNewline = false - skipCount = 1 - } else { - appendToken(kw) - } + replaceTop(with: .clause) + breakLine() + appendToken(kw) + appendToken(byKw) } private mutating func handleSetOperation(upper: String, kw: String, next: SQLToken?) { @@ -586,49 +1081,63 @@ internal struct SQLTokenFormatter { } var line = kw - if upper == "UNION" && next?.upperValue == "ALL" { - let allKw = options.keywordCase.synthesized("ALL") - line += " " + allKw + if let next, next.upperValue == "ALL" || next.upperValue == "DISTINCT" { + line += " " + options.keywordCase.applied(upper: next.upperValue, original: next.value) skipCount = 1 } if clauseStack.contains(where: Self.isBlockContext) { - output += "\n" + indentStr() + line + "\n" + output += (afterNewline ? "" : "\n") + indentStr() + line + "\n" } else { - output += "\n\n" + line + "\n\n" + output += (afterNewline ? "\n" : "\n\n") + line + "\n\n" } afterNewline = true + linePad = base } private mutating func handleCase(kw: String) { - caseBasePad = inSelectColumns ? selectColumnPad : Pad(levels: indent) + casePads.append(linePad) appendToken(kw) clauseStack.append(.caseExpr) } private mutating func handleWhenElse(kw: String) { - let whenPad = Pad(levels: caseBasePad.levels + 1, spaces: caseBasePad.spaces) - output += "\n" + render(whenPad) + kw + let whenPad = (casePads.last ?? base).adding(levels: 1) + linePad = whenPad + output += (afterNewline ? "" : "\n") + render(whenPad) + kw afterNewline = false - suppressNextSpace = false } private mutating func handleEnd(kw: String) { - if clauseStack.last == .caseExpr { clauseStack.removeLast() } - output += "\n" + render(caseBasePad) + kw + guard clauseStack.last == .caseExpr else { + // `END` of a `BEGIN` block, `END IF`, `END LOOP` + breakLine() + appendToken(kw) + return + } + clauseStack.removeLast() + let casePad = casePads.popLast() ?? base + linePad = casePad + output += (afterNewline ? "" : "\n") + render(casePad) + kw afterNewline = false } private mutating func handleWith(kw: String, prev: SQLToken?) { // `TIMESTAMP WITH TIME ZONE` in a column list or a cast is a type, not a CTE. + let lineStart = output.count + (afterNewline ? 0 : 1) guard startStatement(kw: kw, prev: prev) else { return } clauseStack.append(.with) + if options.clauseItems == .newLine { + openLists.append(PendingList(kind: .cte, depth: indent, lineStart: lineStart)) + linePad = base.adding(levels: 1) + pendingLead = (.cte, linePad) + } } private mutating func handleInsert(kw: String, prev: SQLToken?, next: SQLToken?) { guard startStatement(kw: kw, prev: prev) else { return } - if next?.upperValue == "INTO" { - output += " " + options.keywordCase.synthesized("INTO") + if let next, next.upperValue == "INTO" { + output += " " + options.keywordCase.applied(upper: "INTO", original: next.value) skipCount = 1 } clauseStack.append(.insert) @@ -636,7 +1145,7 @@ internal struct SQLTokenFormatter { private mutating func handleStatementStart(kw: String, prev: SQLToken?, context: ClauseContext?) { guard startStatement(kw: kw, prev: prev) else { return } - if let ctx = context { clauseStack.append(ctx) } + if let context { clauseStack.append(context) } } // Inside parentheses, a column list or after a word it modifies, a statement keyword is part of a @@ -653,7 +1162,6 @@ internal struct SQLTokenFormatter { if !afterNewline { newline() } output += indentStr() + kw afterNewline = false - suppressNextSpace = false return true } @@ -689,6 +1197,8 @@ internal struct SQLTokenFormatter { private mutating func close(_ list: PendingList) { guard !list.separators.isEmpty else { return } let end = output.count + let items = list.separators.count - (list.hasLead ? 1 : 0) + if items == 0, !output.containsNewline(in: list.lineStart.. 0, fitsOnOneLine(list.lineStart..) -> Bool { @@ -749,12 +1260,18 @@ internal struct SQLTokenFormatter { ctx == .subquery || ctx == .createTableBody } + // Open blocks, which is what a list is keyed by. private var indent: Int { clauseStack.reduce(into: 0) { depth, ctx in if Self.isBlockContext(ctx) { depth += 1 } } } + // Where a clause of the innermost block starts: one level under the line its `(` is on. + private var base: Pad { + blockOpeners.last.map { $0.adding(levels: 1) } ?? Pad(levels: 0) + } + private mutating func replaceTop(with ctx: ClauseContext) { if !clauseStack.isEmpty { clauseStack.removeLast() } clauseStack.append(ctx) @@ -765,12 +1282,27 @@ internal struct SQLTokenFormatter { } private func indentStr() -> String { - render(Pad(levels: indent)) + render(base) } private mutating func newline() { output += "\n" afterNewline = true + linePad = base + } + + private mutating func breakLine() { + if afterNewline { + linePad = base + } else { + newline() + } + } + + // Written with no space before it: a closing paren, a comma, a call's paren. + private mutating func writeGlued(_ value: String) { + output += afterNewline ? indentStr() + value : value + afterNewline = false } private mutating func appendToken(_ value: String) { @@ -782,7 +1314,7 @@ internal struct SQLTokenFormatter { output += " " + value } afterNewline = false - suppressNextSpace = false + gluePosition = nil } } diff --git a/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift b/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift index 5549c5e8b0..31bb4a1c81 100644 --- a/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift +++ b/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift @@ -5,7 +5,6 @@ import Foundation -// A word the formatter writes itself, the `BY` of `ORDER BY`, has no spelling to preserve: lower case. enum SQLFormatterCase: String, Codable, CaseIterable, Sendable { case upper case lower @@ -18,13 +17,6 @@ enum SQLFormatterCase: String, Codable, CaseIterable, Sendable { case .preserve: return original } } - - func synthesized(_ upper: String) -> String { - switch self { - case .upper: return upper - case .lower, .preserve: return upper.lowercased() - } - } } enum SQLFormatterIndentStyle: String, Codable, CaseIterable, Sendable { @@ -42,7 +34,18 @@ enum SQLFormatterJoinCondition: String, Codable, CaseIterable, Sendable { case sameLine } -// The rule set the formatter runs with, and the JSON a rules file holds. +enum SQLFormatterClauseItems: String, Codable, CaseIterable, Sendable { + case sameLine + case newLine +} + +enum SQLFormatterLogicalOperatorNewline: String, Codable, CaseIterable, Sendable { + case before + case after +} + +// The rule set the formatter runs with. Settings store it under their own keys (`SQLFormatterSettings`), a rules +// file under sql-formatter's (`SQLFormatterRulesFile`). struct SQLFormatterOptions: Equatable, Sendable { static let indentWidthRange = 1...16 static let lineWidthRange = 0...1_000 @@ -56,6 +59,10 @@ struct SQLFormatterOptions: Equatable, Sendable { var lineWidth: Int = 0 var joinCondition: SQLFormatterJoinCondition = .newLine var alignAliases: Bool = false + var clauseItems: SQLFormatterClauseItems = .sameLine + var logicalOperatorNewline: SQLFormatterLogicalOperatorNewline = .before + // Applies only where the engine reads a type name in any case; ClickHouse's `String` is not `STRING`. + var dataTypeCase: SQLFormatterCase = .preserve static let `default` = SQLFormatterOptions() @@ -64,65 +71,6 @@ struct SQLFormatterOptions: Equatable, Sendable { } } -extension SQLFormatterOptions: Codable { - enum CodingKeys: String, CodingKey, CaseIterable { - case indentStyle - case indentWidth - case keywordCase - case functionCase - case commaPosition - case lineWidth - case joinCondition - case alignAliases - } - - // Strict, for files: a value this build can't read throws, a key it doesn't know is ignored. - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = Self.default - indentStyle = try container.decodeIfPresent(SQLFormatterIndentStyle.self, forKey: .indentStyle) - ?? fallback.indentStyle - indentWidth = try Self.decodeInt(container, .indentWidth, in: Self.indentWidthRange) ?? fallback.indentWidth - keywordCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .keywordCase) ?? fallback.keywordCase - functionCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .functionCase) - ?? fallback.functionCase - commaPosition = try container.decodeIfPresent(SQLFormatterCommaPosition.self, forKey: .commaPosition) - ?? fallback.commaPosition - lineWidth = try Self.decodeInt(container, .lineWidth, in: Self.lineWidthRange) ?? fallback.lineWidth - joinCondition = try container.decodeIfPresent(SQLFormatterJoinCondition.self, forKey: .joinCondition) - ?? fallback.joinCondition - alignAliases = try container.decodeIfPresent(Bool.self, forKey: .alignAliases) ?? fallback.alignAliases - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(indentStyle, forKey: .indentStyle) - try container.encode(indentWidth, forKey: .indentWidth) - try container.encode(keywordCase, forKey: .keywordCase) - try container.encode(functionCase, forKey: .functionCase) - try container.encode(commaPosition, forKey: .commaPosition) - try container.encode(lineWidth, forKey: .lineWidth) - try container.encode(joinCondition, forKey: .joinCondition) - try container.encode(alignAliases, forKey: .alignAliases) - } - - private static func decodeInt( - _ container: KeyedDecodingContainer, - _ key: CodingKeys, - in range: ClosedRange - ) throws -> Int? { - guard let value = try container.decodeIfPresent(Int.self, forKey: key) else { return nil } - guard range.contains(value) else { - throw DecodingError.dataCorruptedError( - forKey: key, - in: container, - debugDescription: "\(key.stringValue) must be between \(range.lowerBound) and \(range.upperBound)" - ) - } - return value - } -} - struct SQLFormatterResult { let formattedSQL: String let cursorOffset: Int? @@ -135,20 +83,23 @@ struct SQLFormatterResult { enum SQLFormatterError: LocalizedError { case emptyInput + case inputTooLarge(maxBytes: Int) case dialectUnsupported(DatabaseType) case invalidCursorPosition(Int, max: Int) - case internalError(String) var errorDescription: String? { switch self { case .emptyInput: return String(localized: "Cannot format empty SQL") + case .inputTooLarge(let maxBytes): + return String( + format: String(localized: "The query is larger than %@, too large to format."), + ByteCountFormatter.string(fromByteCount: Int64(maxBytes), countStyle: .file) + ) case .dialectUnsupported(let type): return String(format: String(localized: "Formatting not supported for %@"), type.rawValue) case .invalidCursorPosition(let pos, let max): return String(format: String(localized: "Cursor position %d exceeds SQL length (%d)"), pos, max) - case .internalError(let message): - return String(format: String(localized: "Formatter error: %@"), message) } } } @@ -160,6 +111,10 @@ protocol SQLDialectProvider { var identifierQuote: String { get } // False where the engine rejects a built-in function spelled in another case (ClickHouse). var functionNamesAreCaseInsensitive: Bool { get } + // False where a type name is matched by exact case (ClickHouse's `String`), or the dialect does not say. + var typeNamesAreCaseInsensitive: Bool { get } + // Symbols the engine reads as one operator, such as PostgreSQL's `@>` and `#>>`. + var operators: Set { get } func isKeyword(_ token: String) -> Bool func isFunction(_ token: String) -> Bool diff --git a/apple/TablePro/Core/Services/Formatting/SQLTokenizer.swift b/apple/TablePro/Core/Services/Formatting/SQLTokenizer.swift index 0bf2a778a7..2debcf7357 100644 --- a/apple/TablePro/Core/Services/Formatting/SQLTokenizer.swift +++ b/apple/TablePro/Core/Services/Formatting/SQLTokenizer.swift @@ -2,13 +2,9 @@ // SQLTokenizer.swift // TablePro // -// Character-by-character SQL lexer producing a flat token stream. -// No regex — handles string escapes, comments, operators, and quoted identifiers. -// import Foundation - -// MARK: - Token Types +import TableProSQLGrammar enum SQLTokenType: Equatable { case keyword @@ -20,14 +16,14 @@ enum SQLTokenType: Equatable { case whitespace case comment case placeholder + // A line the client reads and the server never sees: `GO` in a T-SQL script, `DELIMITER //` in a MySQL one, + // a lone `/` in a SQL*Plus one. + case directive } -// MARK: - Token - struct SQLToken: Equatable { let type: SQLTokenType let value: String - /// Pre-computed uppercase value for keyword comparison let upperValue: String init(type: SQLTokenType, value: String) { @@ -37,11 +33,12 @@ struct SQLToken: Equatable { } } -// MARK: - Tokenizer - +// Comments, literals, quoted identifiers and batch lines end where the engine's grammar says (`SQLNonCodeSpan`), so a +// formatted query keeps every one of them byte for byte. Only code between them is split into words and operators. +// +// Without a grammar the text is read exactly as before the formatter read grammars, because the query fingerprint is +// the one caller and its hashes are stored: a different reading would split or merge history groups after an upgrade. struct SQLTokenizer { - /// Standard SQL keywords used for keyword detection. - /// Dialect-specific keywords are handled separately via SQLDialectProvider. private static let standardKeywords: Set = [ // DML "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "IS", "NULL", @@ -82,16 +79,59 @@ struct SQLTokenizer { "TOP", "PERCENT", ] - /// Additional keywords from the dialect provider + // No engine reads one of these as two valid operators, so a run written together stays together whatever the + // dialect declares: T-SQL's `+=`, SQLite's `==`, Oracle's `^=` and `**`, DuckDB's `//`. `<@` is not here: T-SQL + // reads `a<@b` as `a < @b`. + private static let commonOperators: Set = [ + ">=", "<=", "<>", "!=", "!<", "!>", "||", "::", "->", "->>", ":=", "=>", "<=>", "&&", "<<", ">>", + "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "~=", "==", "**", "//", + ] + private let dialectKeywords: Set + private let operatorsByFirstUnit: [UInt16: [String]] - init(dialectKeywords: Set = []) { + init(dialectKeywords: Set = [], operators: Set = []) { self.dialectKeywords = dialectKeywords + let multiCharacter = Self.commonOperators.union(operators.filter { symbol in + symbol.utf16.count > 1 && symbol.utf16.allSatisfy(Lexer.isOperatorUnit) + }) + var byFirst: [UInt16: [String]] = [:] + for symbol in multiCharacter { + guard let first = symbol.utf16.first else { continue } + byFirst[first, default: []].append(symbol) + } + self.operatorsByFirstUnit = byFirst.mapValues { $0.sorted { $0.utf16.count > $1.utf16.count } } } - // MARK: - Public API - func tokenize(_ sql: String) -> [SQLToken] { + readsWithoutGrammar(sql) + } + + func tokenize(_ sql: String, grammar: SQLLexicalGrammar) -> [SQLToken] { + var lexer = Lexer(text: sql as NSString, grammar: grammar, tokenizer: self) + return lexer.run() + } + + // The 0-based lines on which `text` holds a client directive under `grammar`. + func directiveLines(in text: String, grammar: SQLLexicalGrammar) -> Set { + var lines: Set = [] + var line = 0 + for token in tokenize(text, grammar: grammar) { + if token.type == .directive { lines.insert(line) } + line += token.value.utf16.reduce(0) { $0 + ($1 == 0x0A ? 1 : 0) } + } + return lines + } + + fileprivate func isKeyword(_ upperWord: String) -> Bool { + Self.standardKeywords.contains(upperWord) || dialectKeywords.contains(upperWord) + } + + fileprivate func operatorCandidates(startingWith unit: UInt16) -> [String] { + operatorsByFirstUnit[unit] ?? [] + } + + private func readsWithoutGrammar(_ sql: String) -> [SQLToken] { var tokens: [SQLToken] = [] let chars = Array(sql) let count = chars.count @@ -245,7 +285,7 @@ struct SQLTokenizer { continue } - // Unknown character — treat as operator + // Unknown character, treated as an operator tokens.append(SQLToken(type: .operator, value: String(ch))) i += 1 } @@ -253,3 +293,408 @@ struct SQLTokenizer { return tokens } } + +private struct Lexer { + private static let singleQuote = UInt16(UnicodeScalar("'").value) + private static let doubleQuote = UInt16(UnicodeScalar("\"").value) + private static let backtick = UInt16(UnicodeScalar("`").value) + private static let openBracket = UInt16(UnicodeScalar("[").value) + private static let closeBracket = UInt16(UnicodeScalar("]").value) + private static let closeParen = UInt16(UnicodeScalar(")").value) + private static let plus = UInt16(UnicodeScalar("+").value) + private static let minus = UInt16(UnicodeScalar("-").value) + private static let dot = UInt16(UnicodeScalar(".").value) + private static let slash = UInt16(UnicodeScalar("/").value) + private static let underscore = UInt16(UnicodeScalar("_").value) + private static let dollar = UInt16(UnicodeScalar("$").value) + private static let hash = UInt16(UnicodeScalar("#").value) + private static let at = UInt16(UnicodeScalar("@").value) + private static let colon = UInt16(UnicodeScalar(":").value) + private static let question = UInt16(UnicodeScalar("?").value) + private static let ampersand = UInt16(UnicodeScalar("&").value) + private static let newline = UInt16(UnicodeScalar("\n").value) + private static let carriageReturn = UInt16(UnicodeScalar("\r").value) + private static let space = UInt16(UnicodeScalar(" ").value) + private static let tab = UInt16(UnicodeScalar("\t").value) + private static let punctuationUnits: Set = Set("(),;.".utf16) + private static let operatorUnits: Set = Set("=<>+-*/%&|^~!@#?:$".utf16) + private static let delimiterWord = "DELIMITER" + + static func isOperatorUnit(_ unit: UInt16) -> Bool { + operatorUnits.contains(unit) + } + + let text: NSString + let length: Int + let grammar: SQLLexicalGrammar + let tokenizer: SQLTokenizer + private let batchSeparatorEnds: [Int: Int] + private var delimiter = ";" + private var atStatementStart = true + private var tokens: [SQLToken] = [] + private var index = 0 + + init(text: NSString, grammar: SQLLexicalGrammar, tokenizer: SQLTokenizer) { + self.text = text + self.length = text.length + self.grammar = grammar + self.tokenizer = tokenizer + var ends: [Int: Int] = [:] + for separator in SQLStatementScanner.batchSeparators(in: text as String, grammar: grammar) { + ends[separator.range.location] = NSMaxRange(separator.range) + } + self.batchSeparatorEnds = ends + } + + mutating func run() -> [SQLToken] { + while index < length { + step() + } + return tokens + } + + private mutating func step() { + let unit = text.character(at: index) + + if isBlank(unit) { + let start = index + while index < length, isBlank(text.character(at: index)) { + index += 1 + } + emit(.whitespace, from: start) + return + } + if let end = batchSeparatorEnds[index] { + let start = index + index = end + emit(.directive, from: start) + return + } + if unit == Self.slash, grammar.contains(.slashLineTerminators), + SQLStatementScanner.isSlashLine(text, at: index, length: length) { + let start = index + index += 1 + emit(.directive, from: start) + return + } + if readDelimiterDirective() { return } + if delimiter != ";", matches(delimiter, at: index) { + let start = index + index += (delimiter as NSString).length + emit(.punctuation, from: start) + return + } + if let span = SQLNonCodeSpan.span(at: index, in: text, grammar: grammar) { + emitSpan(span) + return + } + if isDigit(unit) || startsFraction(unit) { + readNumber() + return + } + if isWordStart(unit) { + readWord() + return + } + if readPrefixedName(unit) { return } + readOperatorOrPunctuation(unit) + } + + // MARK: - Spans + + private mutating func emitSpan(_ span: SQLNonCodeSpan.Span) { + let start = index + index = max(span.end, index + 1) + switch span.kind { + case .lineComment: + var value = substring(from: start, to: index) + while value.hasSuffix("\r") { + value.removeLast() + } + append(SQLToken(type: .comment, value: value)) + case .blockComment: + emit(.comment, from: start) + case .executableComment: + emit(.string, from: start) + case .parameter: + emit(.placeholder, from: start) + case .quoted: + let opener = text.character(at: start) + emit(opener == Self.backtick || opener == Self.openBracket ? .identifier : .string, from: start) + } + } + + // MARK: - Client directives + + // `DELIMITER //` at the start of a statement, on a line of its own, changes what ends a statement, as the mysql + // client and the import read a script. + private mutating func readDelimiterDirective() -> Bool { + guard grammar.contains(.delimiterDirective), atStatementStart, isAtLineStart(index) else { return false } + let wordLength = (Self.delimiterWord as NSString).length + guard index + wordLength < length, + substring(from: index, to: index + wordLength).uppercased() == Self.delimiterWord, + isLineBlank(text.character(at: index + wordLength)) + else { + return false + } + var end = index + wordLength + while end < length, text.character(at: end) != Self.newline { + end += 1 + } + let line = substring(from: index, to: end).trimmingCharacters(in: .whitespaces) + let newDelimiter = String(line.dropFirst(Self.delimiterWord.count)).trimmingCharacters(in: .whitespaces) + guard !newDelimiter.isEmpty else { return false } + delimiter = newDelimiter + append(SQLToken(type: .directive, value: line)) + index = end + return true + } + + private func isAtLineStart(_ position: Int) -> Bool { + var cursor = position - 1 + while cursor >= 0 { + let unit = text.character(at: cursor) + if unit == Self.newline { return true } + guard isLineBlank(unit) else { return false } + cursor -= 1 + } + return true + } + + private func matches(_ word: String, at position: Int) -> Bool { + let wordLength = (word as NSString).length + guard position + wordLength <= length else { return false } + return text.substring(with: NSRange(location: position, length: wordLength)) == word + } + + // MARK: - Numbers and words + + // A word glued to a number belongs to it: `0xFF` and `0b101` are literals, and MySQL's `2fa_codes` is a name. + private mutating func readNumber() { + let start = index + while index < length, isDigit(text.character(at: index)) || text.character(at: index) == Self.dot { + index += 1 + } + if index + 2 < length, isExponentMarker(text.character(at: index)), + [Self.plus, Self.minus].contains(text.character(at: index + 1)), isDigit(text.character(at: index + 2)) { + index += 2 + } + while index < length, continuesWord(at: index) { + index += 1 + } + let value = substring(from: start, to: index) + append(SQLToken(type: Self.isNumberLiteral(value) ? .number : .identifier, value: value)) + } + + // `.5` is a number, but the `.2fa` of MySQL's `db.2fa` is the second half of a name. + private func startsFraction(_ unit: UInt16) -> Bool { + guard unit == Self.dot, index + 1 < length, isDigit(text.character(at: index + 1)) else { return false } + guard index > 0 else { return true } + let before = text.character(at: index - 1) + return !isWordPart(before) && ![Self.backtick, Self.doubleQuote, Self.closeBracket, Self.closeParen].contains(before) + } + + // `0xFF` and `0b101` are literals; MySQL's `0bar` and `0xyz` are names, since their digits are not hex or binary. + private static func isNumberLiteral(_ value: String) -> Bool { + let lower = value.lowercased() + if lower.count > 2, lower.hasPrefix("0x") { return lower.dropFirst(2).allSatisfy(\.isHexDigit) } + if lower.count > 2, lower.hasPrefix("0b") { return lower.dropFirst(2).allSatisfy { $0 == "0" || $0 == "1" } } + return lower.allSatisfy { $0.isNumber || $0 == "." || $0 == "e" || $0 == "+" || $0 == "-" } + } + + // A literal introducer stays on its literal (`N'abc'`, `X'4D'`, `B'101'`, `_utf8mb4'a'`, `U&'d\0061'`). Any other + // word before a quote is a word (`SELECT a'alias'` is the column `a` and its alias); the formatter still writes + // the two together, so an introducer this list lacks keeps its literal. `E'...'` and `q'[...]'` are the grammar's. + private static let literalIntroducers: Set = ["N", "X", "B"] + + // MySQL's character sets, which `_name'...'` introduces (SHOW CHARACTER SET, 8.4). + private static let mySQLCharacterSets: Set = [ + "ARMSCII8", "ASCII", "BIG5", "BINARY", "CP1250", "CP1251", "CP1256", "CP1257", "CP850", "CP852", "CP866", + "CP932", "DEC8", "EUCJPMS", "EUCKR", "GB18030", "GB2312", "GBK", "GEOSTD8", "GREEK", "HEBREW", "HP8", + "KEYBCS2", "KOI8R", "KOI8U", "LATIN1", "LATIN2", "LATIN5", "LATIN7", "MACCE", "MACROMAN", "SJIS", "SWE7", + "TIS620", "UCS2", "UJIS", "UTF16", "UTF16LE", "UTF32", "UTF8", "UTF8MB3", "UTF8MB4", + ] + + private static func isLiteralIntroducer(_ upperWord: String) -> Bool { + literalIntroducers.contains(upperWord) + || (upperWord.hasPrefix("_") && mySQLCharacterSets.contains(String(upperWord.dropFirst()))) + } + + private mutating func readWord() { + let start = index + index += 1 + while index < length, continuesWord(at: index) { + index += 1 + } + let word = substring(from: start, to: index) + let upper = word.uppercased() + if tokenizer.isKeyword(upper) { + append(SQLToken(type: .keyword, value: word)) + return + } + if upper == "U", index + 1 < length, text.character(at: index) == Self.ampersand, + let span = quotedSpan(at: index + 1) { + let opener = text.character(at: index + 1) + index = span.end + append(SQLToken(type: opener == Self.singleQuote ? .string : .identifier, value: substring(from: start, to: index))) + return + } + if Self.isLiteralIntroducer(upper), index < length, text.character(at: index) == Self.singleQuote, + let span = quotedSpan(at: index) { + index = span.end + append(SQLToken(type: .string, value: substring(from: start, to: index))) + return + } + append(SQLToken(type: .identifier, value: word)) + } + + private func quotedSpan(at position: Int) -> SQLNonCodeSpan.Span? { + guard position < length else { return nil } + let unit = text.character(at: position) + guard unit == Self.singleQuote || unit == Self.doubleQuote, + let span = SQLNonCodeSpan.span(at: position, in: text, grammar: grammar), span.kind == .quoted + else { + return nil + } + return span + } + + // `@name`, `@@version`, `:name`, `$1`, `?1` and T-SQL's `#temp` and `##global` are one token each. + private mutating func readPrefixedName(_ unit: UInt16) -> Bool { + let start = index + switch unit { + case Self.question: + if let symbol = longestOperator(at: index) { + index += (symbol as NSString).length + emit(.operator, from: start) + return true + } + index += 1 + while index < length, isDigit(text.character(at: index)) { + index += 1 + } + emit(.placeholder, from: start) + return true + case Self.colon: + guard index + 1 < length, isWordPart(text.character(at: index + 1)) else { return false } + index += 1 + consumeWordParts() + emit(.placeholder, from: start) + return true + case Self.at, Self.hash: + var cursor = index + 1 + if cursor < length, text.character(at: cursor) == unit { + cursor += 1 + } + // SQLite's `@1` and T-SQL's `@1st` are parameters; `#` starts a temp table name only before a letter. + guard cursor < length, + unit == Self.at ? isWordPart(text.character(at: cursor)) : isWordStart(text.character(at: cursor)) + else { + return false + } + index = cursor + consumeWordParts() + emit(unit == Self.at ? .placeholder : .identifier, from: start) + return true + case Self.dollar: + guard index + 1 < length, isWordPart(text.character(at: index + 1)) else { return false } + index += 1 + consumeWordParts() + emit(.placeholder, from: start) + return true + default: + return false + } + } + + private mutating func consumeWordParts() { + while index < length, continuesWord(at: index) { + index += 1 + } + } + + // A custom delimiter ends a word it is glued to (`END$$`), as the mysql client and the import find it. + private func continuesWord(at position: Int) -> Bool { + guard isWordPart(text.character(at: position)) else { return false } + return delimiter == ";" || !matches(delimiter, at: position) + } + + // MARK: - Operators + + private mutating func readOperatorOrPunctuation(_ unit: UInt16) { + let start = index + if let symbol = longestOperator(at: index) { + index += (symbol as NSString).length + emit(.operator, from: start) + return + } + index += 1 + emit(Self.punctuationUnits.contains(unit) ? .punctuation : .operator, from: start) + } + + private func longestOperator(at position: Int) -> String? { + tokenizer.operatorCandidates(startingWith: text.character(at: position)).first { matches($0, at: position) } + } + + // MARK: - Characters + + private func isBlank(_ unit: UInt16) -> Bool { + switch unit { + case Self.space, Self.tab, Self.newline, Self.carriageReturn, 0x0B, 0x0C, 0x2028, 0x2029: + return true + default: + return SQLSeparatingCharacter.kind(of: unit) == .nonASCIISpace + } + } + + private func isLineBlank(_ unit: UInt16) -> Bool { + unit == Self.space || unit == Self.tab + } + + private func isDigit(_ unit: UInt16) -> Bool { + unit >= 0x30 && unit <= 0x39 + } + + private func isExponentMarker(_ unit: UInt16) -> Bool { + unit == UInt16(UnicodeScalar("e").value) || unit == UInt16(UnicodeScalar("E").value) + } + + private func isWordStart(_ unit: UInt16) -> Bool { + if unit < 0x80 { + return SqlDollarQuote.isIdentifierStart(unit) + } + return !isBlank(unit) && !SQLSeparatingCharacter.isSeparating(unit) + } + + // `$` continues a name on every engine that has one in a name; `#` and `@` only where the grammar says (Oracle's + // `v$session` and `SYS#`, T-SQL's `Part#` and `c@d`), since MySQL reads `a#b` as `a` and a comment. + private func isWordPart(_ unit: UInt16) -> Bool { + if isWordStart(unit) || isDigit(unit) || unit == Self.dollar { return true } + if unit == Self.at { return grammar.contains(.hashAndAtInIdentifiers) } + return unit == Self.hash && !grammar.isDisjoint(with: [.dollarAndHashInIdentifiers, .hashAndAtInIdentifiers]) + } + + // MARK: - Output + + private mutating func emit(_ type: SQLTokenType, from start: Int) { + append(SQLToken(type: type, value: substring(from: start, to: index))) + } + + private mutating func append(_ token: SQLToken) { + tokens.append(token) + switch token.type { + case .whitespace, .comment: + break + case .directive: + atStatementStart = true + case .punctuation: + atStatementStart = token.value == delimiter + default: + atStatementStart = false + } + } + + private func substring(from start: Int, to end: Int) -> String { + text.substring(with: NSRange(location: start, length: end - start)) + } +} diff --git a/apple/TablePro/Core/Services/Infrastructure/EditorTabDetachPolicy.swift b/apple/TablePro/Core/Services/Infrastructure/EditorTabDetachPolicy.swift index 872a5337c6..850d679e24 100644 --- a/apple/TablePro/Core/Services/Infrastructure/EditorTabDetachPolicy.swift +++ b/apple/TablePro/Core/Services/Infrastructure/EditorTabDetachPolicy.swift @@ -5,28 +5,22 @@ import Foundation -/// Whether a tab may be moved into a window of its own, kept apart from the window machinery so -/// the rule can be tested without one. -/// -/// The rule is narrow on purpose. Detaching moves a `QueryTab`, which carries what is persisted; -/// it does not carry a coordinator's live edit state, so a tab with work that has not been written -/// yet would arrive in the new window with that work silently gone. Refusing is the honest answer, -/// and the same one the strip gives for a reorder it cannot complete: the command dims rather than -/// destroying something quietly. +// Whether a tab may move into a window of its own. Detaching carries the `QueryTab` and nothing the coordinator +// holds beside it, so anything that would be lost or left behind refuses rather than moving quietly. internal enum EditorTabDetachPolicy { internal static func canDetach( + tabType: TabType, tabCount: Int, hasUnsavedWork: Bool, isBusy: Bool, isConnected: Bool ) -> Bool { - /// The last tab has nowhere to go. Moving it would empty this window and fill an identical - /// one, which is what `Move Connection to New Window` already does and says. + // A debug run's prompts and its close on a declined start act on the coordinator that opened it. + guard tabType != .routineDebug else { return false } + // Moving the last tab is Move Connection to New Window. guard tabCount > 1 else { return false } guard !hasUnsavedWork else { return false } - /// A query or a table load in flight is claimed by the coordinator that started it. Moving - /// the tab out from under one leaves the completion with no tab to write into and the new - /// window with no claim, so it fetches the same page again. + // The completion of a query or table load in flight would find no tab, and the new window would fetch again. guard !isBusy else { return false } return isConnected } diff --git a/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift b/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift index 0e7719819a..ff7903361e 100644 --- a/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift +++ b/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift @@ -108,6 +108,9 @@ internal enum EditorTabOpener { databaseName: payload.databaseName ?? openingDatabaseName, schemaName: payload.schemaName ) + case .routineDebug: + // A payload cannot carry a live session; the coordinator opens debug tabs itself. + return } } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DebugMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DebugMenuActions.swift new file mode 100644 index 0000000000..db9fe8340e --- /dev/null +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DebugMenuActions.swift @@ -0,0 +1,86 @@ +import AppKit +import TableProPluginKit + +struct RoutineDebugMenuState: Equatable { + var canChooseRoutine = false + var performableCommands: Set = [] + var canStop = false + // The debug tab in front has source on screen with a caret, and is not ending. + var canToggleBreakpoint = false + var canRemoveAllBreakpoints = false +} + +extension MainSplitViewController { + static let routineDebugSelectors: Set = [ + #selector(chooseRoutineToDebug(_:)), + #selector(debugContinue(_:)), + #selector(debugStepOver(_:)), + #selector(debugStepInto(_:)), + #selector(debugStepOut(_:)), + #selector(debugStop(_:)), + #selector(toggleBreakpoint(_:)), + #selector(removeAllBreakpoints(_:)), + ] + + @objc func chooseRoutineToDebug(_ sender: Any?) { + guard let coordinator = routineDebugCoordinator, coordinator.canChooseRoutineToDebug() else { return } + coordinator.chooseRoutineToDebug() + } + + @objc func debugContinue(_ sender: Any?) { + performDebugCommand(.continueExecution) + } + + @objc func debugStepOver(_ sender: Any?) { + performDebugCommand(.stepOver) + } + + @objc func debugStepInto(_ sender: Any?) { + performDebugCommand(.stepInto) + } + + @objc func debugStepOut(_ sender: Any?) { + performDebugCommand(.stepOut) + } + + @objc func debugStop(_ sender: Any?) { + guard let controller = frontRoutineDebugController, controller.canStop else { return } + controller.stop() + } + + @objc func toggleBreakpoint(_ sender: Any?) { + frontRoutineDebugController?.toggleBreakpointAtCursor() + } + + @objc func removeAllBreakpoints(_ sender: Any?) { + guard let controller = frontRoutineDebugController, controller.canRemoveAllBreakpoints else { return } + controller.removeAllBreakpoints() + } + + var routineDebugMenuState: RoutineDebugMenuState { + guard let coordinator = routineDebugCoordinator else { return RoutineDebugMenuState() } + var state = RoutineDebugMenuState(canChooseRoutine: coordinator.canChooseRoutineToDebug()) + guard let controller = coordinator.frontRoutineDebugController else { return state } + let commands: [PluginDebugCommand] = [.continueExecution, .stepOver, .stepInto, .stepOut] + state.performableCommands = Set(commands.filter { controller.canPerform($0) }) + state.canStop = controller.canStop + state.canToggleBreakpoint = controller.canToggleBreakpoint + state.canRemoveAllBreakpoints = controller.canRemoveAllBreakpoints + return state + } + + // Nil while the module is off, so no command reaches the debugger's code. + private var routineDebugCoordinator: MainContentCoordinator? { + guard AppModuleSettings.shared.isEnabled(.routineDebugger) else { return nil } + return commandActions?.coordinator + } + + private var frontRoutineDebugController: RoutineDebugController? { + routineDebugCoordinator?.frontRoutineDebugController + } + + private func performDebugCommand(_ command: PluginDebugCommand) { + guard let controller = frontRoutineDebugController, controller.canPerform(command) else { return } + controller.perform(command) + } +} diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift index ba189144e1..879cd2c8c3 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift @@ -14,6 +14,10 @@ extension MainSplitViewController { commandActions?.saveFileAs() } + @objc func discardChanges(_ sender: Any?) { + commandActions?.discardChanges() + } + /// New Tab and Close Tab used to run AppKit's own `newWindowForTab:` and `performClose:`, /// which named windows because a tab was a window. They act on the tab list now, so the /// menu and the strip's own controls cannot disagree. diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 94cef339a1..ffed17eda9 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProPluginKit // A plain value keeps `isEnabled` pure and testable, the same split `ToolbarContextResolver` uses for the toolbar. struct MenuValidationContext: Equatable { @@ -42,6 +43,8 @@ struct MenuValidationContext: Equatable { // A batch whose COMMIT is on the wire is executing yet unstoppable, and Cmd+. must dim rather than fire into it. var isQueryStoppable = false var hasQueryText = false + // A Create Table tab runs its statement with Execute (Cmd+Return), as its old Create Table button did. + var canCreateDraftTable = false var canRunAIQueryActions = false var canClearQuery = false var canClearResults = false @@ -49,6 +52,8 @@ struct MenuValidationContext: Equatable { // Save commits an edit still open in the grid before it looks for changes. var hasOpenGridEdit = false var hasDataPendingChanges = false + // The selected tab's staged structure edits, or its grid edits; Create Table has nothing to return to. + var canDiscardChanges = false var hasRowSelection = false // Copy with headers and Copy as JSON read the data grid's columns, so the structure grid's selection does not count. var hasDataGridRowSelection = false @@ -125,6 +130,8 @@ struct MenuValidationContext: Equatable { var hasDatabaseFilter = false var canFilterSchemas = false var hasSchemaFilter = false + // Filled only when a Debug menu item is validated; the menu exists only while the module is on. + var routineDebug = RoutineDebugMenuState() // The validation and the window's handler both read it, so a lit Use Selection for Find never lands on an editor with no selection. var usesGridSelectionForFind: Bool { @@ -140,6 +147,7 @@ extension MainSplitViewController: NSMenuItemValidation { if let query = isQueryCommandEnabled(selector, context: context) { return query } if let chooser = isContainerCommandEnabled(selector, context: context) { return chooser } if let creation = isCreationCommandEnabled(selector, context: context) { return creation } + if let debug = isRoutineDebugCommandEnabled(selector, context: context) { return debug } switch selector { case #selector(exportTables(_:)), @@ -171,6 +179,8 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(saveDocument(_:)): return context.isConnected && !context.isReadOnly && (context.hasPendingChanges || context.hasOpenGridEdit) + case #selector(discardChanges(_:)): + return context.isConnected && context.canDiscardChanges case #selector(saveDocumentAs(_:)): return context.isConnected && context.isQueryTab case #selector(exportQueryResults(_:)): @@ -326,6 +336,27 @@ extension MainSplitViewController: NSMenuItemValidation { } } + // Agent mode does not draw the debug tab these act on. + private static func isRoutineDebugCommandEnabled( + _ selector: Selector, + context: MenuValidationContext + ) -> Bool? { + guard routineDebugSelectors.contains(selector) else { return nil } + guard !context.isAgentMode else { return false } + let debug = context.routineDebug + switch selector { + case #selector(chooseRoutineToDebug(_:)): return context.isConnected && debug.canChooseRoutine + case #selector(debugContinue(_:)): return debug.performableCommands.contains(.continueExecution) + case #selector(debugStepOver(_:)): return debug.performableCommands.contains(.stepOver) + case #selector(debugStepInto(_:)): return debug.performableCommands.contains(.stepInto) + case #selector(debugStepOut(_:)): return debug.performableCommands.contains(.stepOut) + case #selector(debugStop(_:)): return debug.canStop + case #selector(toggleBreakpoint(_:)): return debug.canToggleBreakpoint + case #selector(removeAllBreakpoints(_:)): return debug.canRemoveAllBreakpoints + default: return false + } + } + // The sidebar's add button sends these too. New Folder records local state only, so read-only does not dim it. private static func isCreationCommandEnabled( _ selector: Selector, @@ -409,6 +440,7 @@ extension MainSplitViewController: NSMenuItemValidation { private static let browseContentSelectors: Set = [ #selector(refreshDatabase(_:)), #selector(saveDocument(_:)), + #selector(discardChanges(_:)), #selector(addRow(_:)), #selector(restorePreviousValues(_:)), #selector(editDocument(_:)), @@ -452,8 +484,9 @@ extension MainSplitViewController: NSMenuItemValidation { private static func isQueryCommandEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool? { switch selector { - case #selector(executeQuery(_:)), - #selector(executeAllStatements(_:)), + case #selector(executeQuery(_:)): + return context.isConnected && (context.hasQueryText || context.canCreateDraftTable) + case #selector(executeAllStatements(_:)), #selector(executeQueryWithoutLimit(_:)): return context.isConnected && context.hasQueryText case #selector(formatQuery(_:)): @@ -557,12 +590,14 @@ extension MainSplitViewController: NSMenuItemValidation { isQueryExecuting: actions.isQueryExecuting, isQueryStoppable: actions.isQueryStoppable, hasQueryText: actions.hasQueryText, + canCreateDraftTable: actions.canCreateDraftTable, canRunAIQueryActions: actions.aiQueryActionAvailability.isEnabled, canClearQuery: actions.canClearQuery, canClearResults: actions.canClearResults, hasPendingChanges: actions.hasPendingChanges, hasOpenGridEdit: actions.hasOpenGridEdit, hasDataPendingChanges: actions.hasDataPendingChanges, + canDiscardChanges: actions.canDiscardChanges, hasRowSelection: actions.hasRowSelection, hasDataGridRowSelection: actions.hasDataGridRowSelection, hasTableSelection: actions.hasTableSelection, @@ -651,6 +686,9 @@ extension MainSplitViewController: NSMenuItemValidation { if action == #selector(selectNumberedTab(_:)) { context.requestedTabNumber = menuItem.tag } + if Self.routineDebugSelectors.contains(action) { + context.routineDebug = routineDebugMenuState + } guard Self.agentSessionSelectors.contains(action) else { return context } context.agentSessionTarget = agentSessionTarget(for: menuItem)?.status return context diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift index 09b4329423..a77ad56643 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift @@ -213,6 +213,8 @@ internal extension MainSplitViewController { return "clock.arrow.circlepath" case .tableListing: return "list.bullet.rectangle" + case .routineDebug: + return "ladybug" } } } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift index 38be9092cd..06e7a84ac3 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift @@ -157,22 +157,16 @@ internal extension MainSplitViewController { }, canTearOff: { [weak workspace] id in guard let workspace, - let sessionState = workspace.sessionState + let sessionState = workspace.sessionState, + let tab = sessionState.tabManager.tabs.first(where: { $0.id == id }) else { return false } return EditorTabDetachPolicy.canDetach( + tabType: tab.tabType, tabCount: sessionState.tabManager.tabs.count, hasUnsavedWork: sessionState.coordinator.hasUnsavedWork(forTab: id), - /// `isBusy`, never `isExecuting`. Work that cannot claim the tab still runs on - /// it: Fetch All registers unclaimed work, and an exact row count lives in the - /// tab's own pagination state. Moving a tab in either of those leaves the - /// copied `isLoadingMore` or `isCountingExact` raised in the new window with - /// the completion still owned by the old one, so it never comes down. - isBusy: sessionState.coordinator.tabExecution.isBusy(id) - || sessionState.tabManager.tabs.first { $0.id == id }?.pagination.isBusy == true, - /// `reportedStatus`, never the driver handle. An installed driver is a handle, - /// not a live connection: it stays put through a reconnect and after the health - /// monitor gives up, so reading it moved tabs into windows that could not use - /// them. + // Not `isExecuting`: Fetch All and an exact row count run on the tab without claiming it. + isBusy: sessionState.coordinator.tabExecution.isBusy(id) || tab.pagination.isBusy, + // Not the driver handle, which stays installed through a reconnect and after the monitor gives up. isConnected: DatabaseManager.shared.activeSessions[workspace.connectionId]? .reportedStatus.isConnected == true ) diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index d5e220c969..f05c1dfc92 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -523,6 +523,7 @@ internal final class MainSplitViewController: NSSplitViewController { owner.repoint(to: coordinator) guard window.toolbar !== owner.managedToolbar else { return } window.toolbar = owner.managedToolbar + owner.reconcileDebugItems() /// The item set every window starts from is the full one, because `isHidden` is never /// persisted, so the context is applied the moment the items exist rather than on the first /// change to it. Measured on macOS 27, that moment is this assignment: `NSToolbar.items` is @@ -650,15 +651,8 @@ internal final class MainSplitViewController: NSSplitViewController { /// Switching workspace repaints the window in place. The rail used to raise a different /// window instead, which is what made several connections mean several windows. internal func applySelectedWorkspace() { - /// Switching workspace is this window's key-window change as far as a coordinator is - /// concerned. Only the selected one receives the real `windowDidBecomeKey`, so without - /// this the outgoing connection keeps `isKeyWindow` true and never schedules the eviction - /// that frees its row buffers. - /// - /// The outgoing side is read from each coordinator rather than from `lastActiveCoordinator`, - /// which only a switch sets. The window's first connection is made key by the window itself, - /// so the first switch away from it found no cached coordinator to resign: it kept - /// `isKeyWindow` and never scheduled the eviction. + // Only the selected connection gets the real windowDidBecomeKey, so a switch hands key status over itself. + // Each coordinator is asked, not `lastActiveCoordinator`: the window made its first connection key without a switch. let incoming = workspaces.selected?.sessionState?.coordinator var handedOffKey = false for workspace in workspaces.workspaces { @@ -668,10 +662,7 @@ internal final class MainSplitViewController: NSSplitViewController { coordinator.handleWindowDidResignKey() handedOffKey = true } - /// Key status is handed over, never invented. A window that is not key can still change its - /// selection, and marking that connection key kept its row eviction from ever being - /// scheduled and let a deferred restore load in a window nobody was looking at. The real - /// `windowDidBecomeKey` reaches whichever connection is selected when the window comes forward. + // Handed over, never invented: marking a connection key in a background window ran its deferred restore load unseen. if lastActiveCoordinator !== incoming { if handedOffKey || view.window?.isKeyWindow == true { incoming?.handleWindowDidBecomeKey() @@ -982,7 +973,6 @@ internal final class MainSplitViewController: NSSplitViewController { windowTitle: windowTitleBinding(for: workspace), windowSubtitle: windowSubtitleBinding(for: workspace), windowRepresentedURL: windowRepresentedURLBinding(for: workspace), - sidebarState: SharedSidebarState.forConnection(session.connection.id), pendingTruncates: sessionBinding(for: workspace, get: { $0.pendingTruncates }, set: { $0.pendingTruncates = $1 }, defaultValue: []), pendingDeletes: sessionBinding(for: workspace, get: { $0.pendingDeletes }, set: { $0.pendingDeletes = $1 }, defaultValue: []), tableOperationOptions: sessionBinding(for: workspace, get: { $0.tableOperationOptions }, set: { $0.tableOperationOptions = $1 }, defaultValue: [:]), @@ -1396,7 +1386,7 @@ internal final class MainSplitViewController: NSSplitViewController { case .usersRoles: return UsersRolesLayoutMetrics.tabMinimumWidth case .query, .table, .createTable, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, - .tableListing: + .tableListing, .routineDebug: return defaultDetailMinThickness } } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift index 33e6f4111b..88b2140121 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift @@ -5,6 +5,7 @@ import AppKit import Combine +import TableProPluginKit extension MainWindowToolbar { /// Straight to the window, because the switcher is the window's. Every other item here needs @@ -66,6 +67,26 @@ extension MainWindowToolbar { coordinator?.commandActions?.exportTables() } + @objc func performDebugContinue(_ sender: Any?) { + coordinator?.frontRoutineDebugController?.perform(.continueExecution) + } + + @objc func performDebugStepOver(_ sender: Any?) { + coordinator?.frontRoutineDebugController?.perform(.stepOver) + } + + @objc func performDebugStepInto(_ sender: Any?) { + coordinator?.frontRoutineDebugController?.perform(.stepInto) + } + + @objc func performDebugStepOut(_ sender: Any?) { + coordinator?.frontRoutineDebugController?.perform(.stepOut) + } + + @objc func performDebugStop(_ sender: Any?) { + coordinator?.frontRoutineDebugController?.stop() + } + /// The Inspector item on macOS 13, forwarded because the action belongs to the split controller /// and a toolbar item's explicit target has to respond to its own selector to validate. @objc func forwardToggleInspector(_ sender: Any?) { diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Context.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Context.swift index d1cbd18cba..904d5d0c84 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Context.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Context.swift @@ -46,7 +46,8 @@ internal extension MainWindowToolbar { supportsServerDashboard: databaseType.map { ServerDashboardQueryProviderFactory.supportsDashboard(for: $0) } ?? false, isAIEnabled: AppSettingsManager.shared.ai.enabled, - stagesObjectChanges: tab?.tabType == .objectSource && tab?.display.objectRef?.kind == .userType + stagesObjectChanges: tab?.tabType == .objectSource && tab?.display.objectRef?.kind == .userType, + showsDebugItems: debugItemsEnabled() ) } @@ -58,6 +59,7 @@ internal extension MainWindowToolbar { func currentContext() -> ToolbarContext { let host = self.host let state = coordinator?.toolbarState + let debugger = (host?.contentMode ?? .browse) == .browse ? coordinator?.frontRoutineDebugController : nil return ToolbarContext( key: currentVisibilityKey(), pane: host?.currentPane ?? .empty, @@ -70,7 +72,11 @@ internal extension MainWindowToolbar { canAddRow: coordinator?.canAddRow ?? false, canRestorePreviousValues: coordinator?.canRewindSelectedTab ?? false, canNavigateBack: coordinator?.canNavigateBack ?? false, - canNavigateForward: coordinator?.canNavigateForward ?? false + canNavigateForward: coordinator?.canNavigateForward ?? false, + debugCommands: debugger.map { controller in + Set(PluginDebugCommand.hostCommands.filter { controller.canPerform($0) }) + } ?? [], + canStopDebugging: debugger?.canStop ?? false ) } } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift index 61ff054a90..004adfc3e4 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift @@ -126,6 +126,10 @@ extension MainWindowToolbar { return makeNewTabItem() case Self.quickSwitcher: return makeQuickSwitcherItem() + case Self.debugContinue, Self.debugStepOver, Self.debugStepInto, Self.debugStepOut, Self.debugStop: + // Answering nil while the module is off is what prunes the controls from a saved arrangement. + guard debugItemsEnabled() else { return nil } + return makeDebugItem(itemIdentifier) default: return nil } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift index 2f1a2f296b..57800ec5ea 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift @@ -286,6 +286,53 @@ extension MainWindowToolbar { return item } + func makeDebugItem(_ identifier: NSToolbarItem.Identifier) -> NSToolbarItem? { + switch identifier { + case Self.debugContinue: + return menuOnlyItem( + id: identifier, + label: String(localized: "Continue"), + symbol: "play", + action: #selector(performDebugContinue(_:)), + shortcut: .debugContinue + ) + case Self.debugStepOver: + return menuOnlyItem( + id: identifier, + label: String(localized: "Step Over"), + symbol: "arrow.uturn.forward", + action: #selector(performDebugStepOver(_:)), + shortcut: .debugStepOver + ) + case Self.debugStepInto: + return menuOnlyItem( + id: identifier, + label: String(localized: "Step Into"), + symbol: "arrow.down.to.line", + action: #selector(performDebugStepInto(_:)), + shortcut: .debugStepInto + ) + case Self.debugStepOut: + return menuOnlyItem( + id: identifier, + label: String(localized: "Step Out"), + symbol: "arrow.up.to.line", + action: #selector(performDebugStepOut(_:)), + shortcut: .debugStepOut + ) + case Self.debugStop: + return menuOnlyItem( + id: identifier, + label: String(localized: "Stop"), + symbol: "stop", + action: #selector(performDebugStop(_:)), + description: String(localized: "Stop Debugging") + ) + default: + return nil + } + } + // MARK: - Helpers /// The label is what the customization palette and the overflow menu show, so it stays short. diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 1e9efd2d10..be3598b496 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -23,7 +23,9 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// record on the next launch. A user who customized keeps their arrangement and still receives /// new items where they belong; a bump would throw that away along with their display mode. /// The record is six keys and `isHidden` is not among them, since it is runtime state that is - /// never persisted, so a context-sensitive item set is no reason to bump either. + /// never persisted, so a context-sensitive item set is no reason to bump either. A reordered + /// default list needs no bump: a toolbar nobody customized stores no item list and takes the + /// new order, and a customized one keeps the user's (`scripts/probes/check-toolbar-autosave-reorder.sh`). /// /// Bump only for a change that diff cannot express, and say which. Neither fact was measured on /// macOS 13 or 14. @@ -81,6 +83,13 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// Coalesces the passes Customize Toolbar asks for, so a drop of several items is one pass. private var isVisibilityReapplyScheduled = false + // Whether the Routine Debugger module is on; its controls are vended only then, so turning it off prunes them. + internal var debugItemsEnabled: @MainActor () -> Bool = { + AppModuleSettings.shared.isEnabled(.routineDebugger) + } + + internal var debugItemsRecord = ToolbarDebugItemsRecord() + /// `NSMenu.delegate` is weak, and the safe-mode control's menu is built here rather than by the /// menu bar, so this toolbar is what keeps its delegate alive. internal let safeModeMenuDelegate = SafeModeMenuDelegate() @@ -348,7 +357,8 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { internal func apply(_ visibility: ToolbarVisibility) { guard #available(macOS 15.0, *) else { return } self.visibility = visibility - for item in managedToolbar.items where ToolbarContextResolver.hideableIdentifiers.contains(item.itemIdentifier) { + let hideable = ToolbarContextResolver.hideableItems(showsDebugItems: debugItemsEnabled()) + for item in managedToolbar.items where hideable.contains(item.itemIdentifier) { item.isHidden = visibility.hides(item.itemIdentifier) } managedToolbar.validateVisibleItems() @@ -406,13 +416,29 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// The pull-down that carries the long tail of a context's commands. One control whose menu /// changes with the tab, instead of one permanent titlebar slot per command. nonisolated static let actions = NSToolbarItem.Identifier("com.TablePro.toolbar.actions") + nonisolated static let debugContinue = NSToolbarItem.Identifier("com.TablePro.toolbar.debug.continue") + nonisolated static let debugStepOver = NSToolbarItem.Identifier("com.TablePro.toolbar.debug.stepOver") + nonisolated static let debugStepInto = NSToolbarItem.Identifier("com.TablePro.toolbar.debug.stepInto") + nonisolated static let debugStepOut = NSToolbarItem.Identifier("com.TablePro.toolbar.debug.stepOut") + nonisolated static let debugStop = NSToolbarItem.Identifier("com.TablePro.toolbar.debug.stop") + nonisolated static let debugItemIdentifiers: [NSToolbarItem.Identifier] = [ + debugContinue, debugStepOver, debugStepInto, debugStepOut, debugStop, + ] + // Xcode's pair. The steps live in the debug tab's own bar and the palette, which keeps a debug tab at eight hit targets. + nonisolated static let debugDefaultItemIdentifiers: [NSToolbarItem.Identifier] = [debugContinue, debugStop] // MARK: - NSToolbarDelegate /// Eight controls, and the zones are the panes. AppKit's own sidebar toggle stands alone ahead of /// `.sidebarTrackingSeparator`, so it lays out in the sidebar's titlebar strip and follows the - /// divider; the connection and its container are centred; Refresh, the commit verb, the Actions - /// pull-down and Safe Mode are the content run; the trailing-pane toggle closes the row. + /// divider; the connection and its container are centred; Refresh, the Actions pull-down, Safe + /// Mode and the commit verb are the content run; the trailing-pane toggle closes the row. + /// + /// The commit verb comes last because the HIG gives a critical action like Save "dedicated, + /// familiar, and visually distinct sections" and puts the primary action on the trailing side. + /// Measured on macOS 27, AppKit already gives it a glass section of its own there: Safe Mode's + /// menu indicator ends the shared capsule before it, so no `.space` is needed, and one drew + /// pixel-identical. /// /// A tracking separator divides the toolbar into pane-aligned sections; it does not align the /// items inside one. Everything after `.inspectorTrackingSeparator` therefore lays out from @@ -444,9 +470,9 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { database, .flexibleSpace, refresh, - saveChanges, actions, safeMode, + saveChanges, ] if #available(macOS 14.0, *) { items.append(.inspectorTrackingSeparator) @@ -478,11 +504,77 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { quickSwitcher, ] + // Ahead of the Actions pull-down, in the content run Refresh and the commit control leave empty on a debug tab. + nonisolated internal static func defaultItems(includingDebugItems: Bool) -> [NSToolbarItem.Identifier] { + let items = defaultItemIdentifiers + guard includingDebugItems, let index = items.firstIndex(of: actions) else { return items } + return Array(items[.. [NSToolbarItem.Identifier] { - Self.defaultItemIdentifiers + Self.defaultItems(includingDebugItems: debugItemsEnabled()) } internal func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - Self.allowedItemIdentifiers + debugItemsEnabled() ? Self.allowedItemIdentifiers + Self.debugItemIdentifiers : Self.allowedItemIdentifiers + } + + // Replaced under test, where no window hosts the toolbar. + internal var isInWindow: @MainActor () -> Bool = { false } + + // Pushed by the one writer of a module's state, so no toolbar listens for module changes while the module is off. + internal static func applyModuleChange() { + for window in NSApp.windows { + guard let owner = window.toolbar?.delegate as? MainWindowToolbar else { continue } + owner.reconcileDebugItems() + owner.refreshContext(forcing: true) + } + } + + // Run when the module changes and when the toolbar reaches a window, after it restored its saved arrangement: a + // change made while no connection window was open would otherwise never reach the arrangement. Inserted or removed + // in place, because reassigning the toolbar rebuilds the whole titlebar. + internal func reconcileDebugItems() { + guard windowController?.view.window != nil || isInWindow() else { return } + let wanted = debugItemsEnabled() + guard debugItemsRecord.isApplied != wanted else { return } + debugItemsRecord.record(wanted) + let debugIdentifiers = Set(Self.debugItemIdentifiers) + if wanted { + let present = managedToolbar.items.contains { debugIdentifiers.contains($0.itemIdentifier) } + if !present, let index = managedToolbar.items.firstIndex(where: { $0.itemIdentifier == Self.actions }) { + for (offset, identifier) in Self.debugDefaultItemIdentifiers.enumerated() { + managedToolbar.insertItem(withItemIdentifier: identifier, at: index + offset) + } + } + } else { + for index in managedToolbar.items.indices.reversed() + where debugIdentifiers.contains(managedToolbar.items[index].itemIdentifier) { + managedToolbar.removeItem(at: index) + } + } + } +} + +// The module state last written into the shared arrangement. Toolbars sharing an identifier copy each other's edits, so +// a change is written once, by the first toolbar in a window to see it, and a person who then removes the pair keeps it +// removed. Kept beside the arrangement, in the domain AppKit autosaves it to, so it outlives the launch: a module turned on +// before any connection window opened still reaches a customized arrangement. Missing reads as off, which is what an +// arrangement saved before the module existed holds. +internal struct ToolbarDebugItemsRecord { + static let key = "com.TablePro.main.toolbar.v4.debugItems" + + let defaults: UserDefaults + + init(defaults: UserDefaults = AppStorageEnvironment.shared.defaults) { + self.defaults = defaults + } + + var isApplied: Bool { + defaults.bool(forKey: Self.key) + } + + func record(_ applied: Bool) { + defaults.set(applied, forKey: Self.key) } } diff --git a/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift b/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift index c85b3461fe..055a6197b2 100644 --- a/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift +++ b/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift @@ -1,6 +1,5 @@ import Foundation import os -import TableProUtilities /// The subsystem work a launch has to do but the first window does not need. /// @@ -28,8 +27,6 @@ internal enum PostLaunchWork { hasStarted = true logger.debug("post-launch work starting") - MemoryPressureAdvisor.startMonitoring() - Task { await PluginManager.shared.sweepPluginSignatures() } Task { await QueryHistoryManager.shared.performStartupCleanup() } diff --git a/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index 805183ecb7..ae3c2dce75 100644 --- a/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -382,8 +382,12 @@ private final class SidebarFilterField: NSSearchField { override func becomeFirstResponder() -> Bool { let became = super.becomeFirstResponder() - if became { onFocus?() } - return became + guard became else { return false } + onFocus?() + // AppKit sets up a field editor's selection navigation on its first caret move, about 15 ms once per launch; + // asking for it here keeps that cost out of the first keystroke of a search over a large catalog. + _ = (currentEditor() as? NSTextView)?.textLayoutManager?.textSelectionNavigation + return true } } diff --git a/apple/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/apple/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index a5adafcf9f..1d8cc113dd 100644 --- a/apple/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/apple/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -98,6 +98,7 @@ internal final class TabPersistenceCoordinator: ObservableObject { selectedTabId: UUID?, path: String ) -> (tabs: [PersistedTab], selectedTabId: UUID?, database: String?, schema: String?)? { + let tabs = tabs.filter { !$0.tabType.isTransient } guard !tabs.isEmpty else { Self.logger.debug( "[persist] \(path, privacy: .private(mask: .hash)) skipped empty tab set connId=\(self.connectionId, privacy: .public)" diff --git a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift index 74185086c2..5fb912893c 100644 --- a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift +++ b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProPluginKit /// Which of the connection window's toolbar items a context shows, and which of them answer. /// @@ -38,12 +39,20 @@ internal enum ToolbarContextResolver { /// from early at about 2.5ns a write. internal static let hideableIdentifiers = Set(MainWindowToolbar.defaultItemIdentifiers) + // The debug controls belong to their module, so all of them, the palette's steps included, may leave the titlebar + // while it is on; with it off none is vended. + internal static func hideableItems(showsDebugItems: Bool) -> Set { + showsDebugItems ? hideableIdentifiers.union(MainWindowToolbar.debugItemIdentifiers) : hideableIdentifiers + } + /// What this context takes out of the titlebar, already confined to what may be taken. /// /// The confinement lives here rather than at the call site, so it is a property of the answer /// and a second caller cannot skip it. internal static func visibility(for key: ToolbarContext.VisibilityKey) -> ToolbarVisibility { - ToolbarVisibility(hidden: contextualHidden(key).intersection(hideableIdentifiers)) + ToolbarVisibility( + hidden: contextualHidden(key).intersection(hideableItems(showsDebugItems: key.showsDebugItems)) + ) } /// The commit control's label: the verb its tab commits with. @@ -60,7 +69,8 @@ internal enum ToolbarContextResolver { String(localized: "Create Table") case .usersRoles: String(localized: "Apply Changes") - case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing, nil: + case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing, + .routineDebug, nil: String(localized: "Save Changes") } } @@ -74,6 +84,11 @@ internal enum ToolbarContextResolver { hidden.insert(MainWindowToolbar.database) } + // The debug controls exist only while the module is on, and mean something only over a debug tab. + if key.contentMode == .agent || (key.tabKind != nil && key.tabKind != .routineDebug) { + hidden.formUnion(MainWindowToolbar.debugItemIdentifiers) + } + switch key.contentMode { case .agent: /// No grid and no object browser are on screen, and the commit control's gate is frozen @@ -98,6 +113,9 @@ internal enum ToolbarContextResolver { case .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing: /// None of these can stage a change, so the commit control could only ever be dim. return [MainWindowToolbar.saveChanges] + case .routineDebug: + // A debug run has nothing to reload or commit; its own controls take that place. + return [MainWindowToolbar.refresh, MainWindowToolbar.saveChanges] case .query, .table, .usersRoles, .erDiagram: return [] } @@ -188,6 +206,16 @@ internal enum ToolbarContextResolver { return context.isConnected case MainWindowToolbar.importTables: return context.isConnected && !context.blocksAllWrites && context.supportsImport + case MainWindowToolbar.debugContinue: + return context.debugCommands.contains(.continueExecution) + case MainWindowToolbar.debugStepOver: + return context.debugCommands.contains(.stepOver) + case MainWindowToolbar.debugStepInto: + return context.debugCommands.contains(.stepInto) + case MainWindowToolbar.debugStepOut: + return context.debugCommands.contains(.stepOut) + case MainWindowToolbar.debugStop: + return context.canStopDebugging default: return false } diff --git a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift index f12e17e0cb..648ce75104 100644 --- a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift +++ b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift @@ -5,17 +5,17 @@ import Foundation -/// The glyphs a window toolbar draws for the actions every toolbar shares. +/// The glyphs a toolbar draws for the actions every toolbar shares, in both apps. /// -/// From macOS 26 a toolbar item sits in its own glass container, and the HIG asks for symbols -/// without borders there: the container is the border, so a circled glyph draws two. Earlier -/// releases give an item no container, and the circled form is the one their own toolbars use. -/// Each answer is a function of that one fact, so both forms can be tested on either release. -internal enum ToolbarSymbols { +/// From macOS 26 and iOS 26 a toolbar item sits in its own glass container, and the HIG asks for +/// symbols without borders there: the container is the border, so a circled glyph draws two. +/// Earlier releases give an item no container, and the circled form is the one their own toolbars +/// use. Each answer is a function of that one fact, so both forms can be tested on either release. +nonisolated internal enum ToolbarSymbols { internal static let commit = "checkmark" internal static var itemsHaveContainer: Bool { - if #available(macOS 26.0, *) { + if #available(macOS 26.0, iOS 26.0, *) { return true } return false @@ -42,6 +42,17 @@ internal enum ToolbarSymbols { return itemsHaveContainer ? "line.3.horizontal.decrease" : "line.3.horizontal.decrease.circle" } + /// The filled disc is state, a sort in effect, as it is for the filter. + internal static func sort( + isActive: Bool = false, + itemsHaveContainer: Bool = ToolbarSymbols.itemsHaveContainer + ) -> String { + if isActive { + return "arrow.up.arrow.down.circle.fill" + } + return itemsHaveContainer ? "arrow.up.arrow.down" : "arrow.up.arrow.down.circle" + } + internal static func disconnect(itemsHaveContainer: Bool = ToolbarSymbols.itemsHaveContainer) -> String { itemsHaveContainer ? "xmark" : "xmark.circle" } diff --git a/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift b/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift index e793c326bd..372c2f8d78 100644 --- a/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift +++ b/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift @@ -165,6 +165,11 @@ enum WindowTitleResolver { return explicitTitle } return String(localized: "Tables") + case .routineDebug: + if let explicitTitle, !explicitTitle.isBlank { + return explicitTitle + } + return String(localized: "Debug") default: break } diff --git a/apple/TablePro/Core/Services/Query/CatalogChangeService.swift b/apple/TablePro/Core/Services/Query/CatalogChangeService.swift index 223ef4e0b2..9609750904 100644 --- a/apple/TablePro/Core/Services/Query/CatalogChangeService.swift +++ b/apple/TablePro/Core/Services/Query/CatalogChangeService.swift @@ -131,7 +131,11 @@ final class CatalogChangeService { private func recordStatements(_ statements: [String], databaseType: DatabaseType, connectionId: UUID) { recordNameHazards(of: statements, databaseType: databaseType, connectionId: connectionId) - let effect = CatalogChangeClassifier.effect(ofStatements: statements, databaseType: databaseType) + let effect = CatalogChangeClassifier.effect( + ofStatements: statements, + databaseType: databaseType, + grammar: SQLLexicalResolver.executionGrammar(for: databaseType, connectionId: connectionId) + ) var kinds = effect.kinds if effect.endsTransaction { kinds.formUnion(Self.transactionEndKinds) @@ -145,14 +149,14 @@ final class CatalogChangeService { /// chain and a drop of a name another statement just freed both land on the right table. private func recordCommittedTableEdits(of succeeded: SucceededStatements) { let connectionId = succeeded.scope.connectionId + let grammar = SQLLexicalResolver.executionGrammar(for: succeeded.databaseType, connectionId: connectionId) recordNameHazards( of: DatabaseManager.startupStatements( - from: startupCommands(connectionId), databaseType: succeeded.databaseType + from: startupCommands(connectionId), databaseType: succeeded.databaseType, grammar: grammar ), databaseType: succeeded.databaseType, connectionId: connectionId ) - let grammar = SQLLexicalResolver.executionGrammar(for: succeeded.databaseType, connectionId: connectionId) var hazards = nameHazards[connectionId] ?? TableNameHazards() let edits = CommittedTableEdits.edits(in: succeeded, grammar: grammar, hazards: &hazards) nameHazards[connectionId] = hazards diff --git a/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift b/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift index 2c9d318404..b5aaadfd58 100644 --- a/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift +++ b/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift @@ -13,7 +13,7 @@ import TableProPluginKit enum ExplainPlanFormatDefaults { static func format(for databaseType: DatabaseType) -> ExplainPlanFormat { switch databaseType { - case .postgresql, .pglite: + case .postgresql, .pglite, .kingbasees: return .postgresJson case .mysql, .mariadb: return .mysqlComposite diff --git a/apple/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/apple/TablePro/Core/Services/Query/MetadataConnectionPool.swift index c8c7da5d2e..1b51ce9465 100644 --- a/apple/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/apple/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -411,11 +411,17 @@ final class MetadataConnectionPool { /// Opens a second connection to the session's server, on `scope`'s database, through the /// transport the session already holds. Every tunnel manager closes its tunnel before building a /// new one, so dialing the session's effective endpoint is what keeps the other connections up. - /// A database lane opens its user connection here too, as `.session`. - internal static func openServerDriver(for scope: DatabaseScope, purpose: DriverPurpose) async throws -> DatabaseDriver { + /// A database lane opens its user connection here too, as `.session`. `queryTimeoutSeconds` + /// replaces the session's timeout, for a debug target that waits on a person between steps. + internal static func openServerDriver( + for scope: DatabaseScope, + purpose: DriverPurpose, + queryTimeoutSeconds: Int? = nil + ) async throws -> DatabaseDriver { guard let session = DatabaseManager.shared.session(for: scope.connectionId) else { throw DatabaseError.notConnected } + let queryTimeout = queryTimeoutSeconds ?? session.effectiveQueryTimeoutSeconds var connection = session.effectiveConnection ?? session.connection let plan = Self.planConnection( configuredDatabase: connection.database, @@ -443,14 +449,14 @@ final class MetadataConnectionPool { purpose: purpose, deadline: deadline, timeoutEndpoint: timeoutEndpoint, - effectiveQueryTimeoutSeconds: session.effectiveQueryTimeoutSeconds, + effectiveQueryTimeoutSeconds: queryTimeout, preparedConfiguration: preparedConfiguration ) do { try await driver.connect() try await Self.prepareSession( driver, - queryTimeoutSeconds: session.effectiveQueryTimeoutSeconds, + queryTimeoutSeconds: queryTimeout, startupCommands: session.connection.startupCommands, connectionName: session.connection.name, timeoutSeconds: operationTimeoutSeconds diff --git a/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift b/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift index 3967c77aa8..f7d2f6faeb 100644 --- a/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift +++ b/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift @@ -16,6 +16,8 @@ struct PluginDialectAdapter: SQLDialectProvider { let functions: Set let dataTypes: Set let functionNamesAreCaseInsensitive: Bool + let typeNamesAreCaseInsensitive: Bool + let operators: Set init(descriptor: SQLDialectDescriptor) { self.identifierQuote = descriptor.identifierQuote @@ -23,6 +25,8 @@ struct PluginDialectAdapter: SQLDialectProvider { self.functions = descriptor.functions self.dataTypes = descriptor.dataTypes self.functionNamesAreCaseInsensitive = descriptor.functionNamesAreCaseInsensitive + self.typeNamesAreCaseInsensitive = descriptor.typeNamesAreCaseInsensitive + self.operators = Set(descriptor.operators.map(\.symbol)) } } @@ -34,6 +38,8 @@ private struct EmptyDialect: SQLDialectProvider { let functions: Set = [] let dataTypes: Set = [] let functionNamesAreCaseInsensitive = false + let typeNamesAreCaseInsensitive = false + let operators: Set = [] } // MARK: - Dialect Factory diff --git a/apple/TablePro/Core/Storage/AppSettingsManager.swift b/apple/TablePro/Core/Storage/AppSettingsManager.swift index f17d83f2a4..253cc4f055 100644 --- a/apple/TablePro/Core/Storage/AppSettingsManager.swift +++ b/apple/TablePro/Core/Storage/AppSettingsManager.swift @@ -279,5 +279,6 @@ final class AppSettingsManager: ObservableObject { storage.resetToDefaults() connectionListPreferences.resetShowsRecent() SoftwareUpdater.shared.resetUpdatePreferences() + AppModuleActivation.resetToDefaults() } } diff --git a/apple/TablePro/Core/Storage/AppSettingsStorage.swift b/apple/TablePro/Core/Storage/AppSettingsStorage.swift index add50ed0f8..6e789f10e3 100644 --- a/apple/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/apple/TablePro/Core/Storage/AppSettingsStorage.swift @@ -315,6 +315,7 @@ final class AppSettingsStorage: Sendable { defaults.removeObject(forKey: PreferenceKeys.rowInspectorGeometryFieldHeight.name) defaults.removeObject(forKey: PreferenceKeys.rowInspectorGeometryFieldMode.name) defaults.removeObject(forKey: SidebarPersistenceKey.defaultLayout) + RoutineDebugOptionStore(defaults: { [defaults] in defaults }).removeAll() } // MARK: - Helpers diff --git a/apple/TablePro/Core/Storage/ConnectionLocalState.swift b/apple/TablePro/Core/Storage/ConnectionLocalState.swift index 63a4c790c8..c77bd3a5ef 100644 --- a/apple/TablePro/Core/Storage/ConnectionLocalState.swift +++ b/apple/TablePro/Core/Storage/ConnectionLocalState.swift @@ -57,6 +57,7 @@ internal enum ConnectionLocalState { HistoryPanelPreferencesStorage.remove(for: connectionId) QueryInsightsPreferencesStorage.remove(for: connectionId) ServerDashboardTileStorage().remove(for: connectionId) + RoutineDebugOptionStore(defaults: { [defaults] in defaults }).remove(for: connectionId) MCPServerStore.shared.forgetConnection(connectionId) } purgeTrailingPaneKeys(connectionIds, defaults: defaults) diff --git a/apple/TablePro/Core/Storage/Preferences/PreferenceKeys.swift b/apple/TablePro/Core/Storage/Preferences/PreferenceKeys.swift index eac9c00c55..a6514424f8 100644 --- a/apple/TablePro/Core/Storage/Preferences/PreferenceKeys.swift +++ b/apple/TablePro/Core/Storage/Preferences/PreferenceKeys.swift @@ -13,8 +13,10 @@ enum PreferenceKeys { static let queryPlanRawFontSize = DefaultsKey("com.TablePro.queryPlan.rawFontSize") static let queryPlanBarMetric = DefaultsKey("com.TablePro.queryPlan.barMetric") static let lastBackupDirectory = DefaultsKey("com.TablePro.backup.lastDirectory") - /// The app version this Mac last showed the welcome window for. Device-local: it records what - /// has been shown here, not a preference, so it must not sync to another Mac that has not. + /// The newest build this Mac has run, for the Welcome window's What's New link. Device-local: it + /// records what has been shown here, not a preference, so it must not sync to another Mac. + static let lastSeenAppBuild = DefaultsKey("com.TablePro.welcome.lastSeenAppBuild") + /// What a build before `lastSeenAppBuild` recorded instead, read once to carry it over. static let lastSeenAppVersion = DefaultsKey("com.TablePro.welcome.lastSeenAppVersion") static let connectionListSortMode = DefaultsKey("com.TablePro.connectionList.sortMode") static let connectionListFavoritesOrder = DefaultsKey<[String]>("com.TablePro.connectionList.favoritesOrder") @@ -39,6 +41,7 @@ enum PreferenceKeys { queryPlanRawFontSize.name, queryPlanBarMetric.name, lastBackupDirectory.name, + lastSeenAppBuild.name, lastSeenAppVersion.name, receivesBetaUpdates.name, ] diff --git a/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift b/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift index 26d3ffb7e2..d6c229999e 100644 --- a/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift @@ -8,13 +8,21 @@ import TableProSQLGrammar // SQL is read by SQLCatalogEffectClassifier, shared with iOS; engines that do not speak SQL are decided here. enum CatalogChangeClassifier { - static func effect(ofStatements statements: [String], databaseType: DatabaseType) -> SQLCatalogEffect { + static func effect( + ofStatements statements: [String], + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> SQLCatalogEffect { statements.reduce(SQLCatalogEffect.none) { partial, statement in - partial.union(effect(of: statement, databaseType: databaseType)) + partial.union(effect(of: statement, databaseType: databaseType, grammar: grammar)) } } - static func effect(of sql: String, databaseType: DatabaseType) -> SQLCatalogEffect { + static func effect( + of sql: String, + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> SQLCatalogEffect { let trimmed = StatementBlank.trimming(SQLLeadingComments.stripping(sql)) guard !trimmed.isEmpty else { return .none } if databaseType == .redis { @@ -27,7 +35,7 @@ enum CatalogChangeClassifier { if let request = dynamoDBRequestEffect(trimmed, databaseType: databaseType) { return request } - return SQLCatalogEffectClassifier.effect(of: trimmed, grammar: databaseType.lexicalGrammar) + return SQLCatalogEffectClassifier.effect(of: trimmed, grammar: grammar ?? databaseType.lexicalGrammar) } // PartiQL, and an action the driver does not know, which goes out as PartiQL, take the SQL path. diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift index 262fa94ada..c179696233 100644 --- a/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift @@ -16,10 +16,6 @@ import TableProSQLGrammar /// spell out and never below a write. Dynamic SQL built from variables cannot be read, which leaves it where a `CALL` /// of a stored procedure is: a write whose effect the text does not show. extension QueryClassifier { - static func runsPLSQL(_ sql: String, databaseType: DatabaseType) -> Bool { - SQLProceduralBlock.runsPLSQL(sql, grammar: databaseType.lexicalGrammar) - } - static func proceduralBlockClassification( _ trimmed: String, grammar: SQLLexicalGrammar, diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 14cc6a3c49..0669a1d8ae 100644 --- a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -258,7 +258,10 @@ private extension QueryClassifier { options: [] ) - static let enginesWithSequencePseudoColumns: Set = [.oracle, .sapHana, .snowflake, .dameng, .mariadb] + // KingbaseES has it in Oracle mode only, and its type id cannot say which mode a server runs. + static let enginesWithSequencePseudoColumns: Set = [ + .oracle, .sapHana, .snowflake, .dameng, .mariadb, .kingbasees, + ] static func advancesSequence(_ body: String, databaseType: DatabaseType) -> Bool { let range = NSRange(body.startIndex..., in: body) @@ -300,6 +303,7 @@ private extension QueryClassifier { static let filesystemMarkers: [String] = [ "INTO OUTFILE", "INTO DUMPFILE", "TO PROGRAM", "FROM PROGRAM", "VACUUM INTO", "LOAD_FILE(", "PG_READ_FILE(", "PG_READ_BINARY_FILE(", "PG_LS_DIR(", + "SYS_READ_FILE(", "SYS_READ_BINARY_FILE(", "SYS_LS_DIR(", "LO_IMPORT(", "LO_EXPORT(", "PG_FILE_WRITE(", "LOAD_EXTENSION(", "READFILE(", "WRITEFILE(", "FN_GET_AUDIT_FILE(", "XP_CMDSHELL", "SP_OACREATE" ] diff --git a/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift b/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift index c465dee946..132c1c7e17 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift @@ -298,6 +298,16 @@ final class SQLFileParser: Sendable { flushWord(&ctx) } guard SqlBlockStructure.startsWord(nsBuffer, at: i, length: bufLen, grammar: ctx.grammar) else { return .none } + // `E'...'` is a literal, and the escape-string branch that reads it never runs once `E` starts a word. + if ctx.grammar.contains(.escapeStringPrefix), char == kCapitalE || char == kSmallE, + !SQLNonCodeSpan.isWordUnit(ctx.previousUnit) { + if i + 1 < bufLen { + if nsBuffer.character(at: i + 1) == kSingleQuote { return .none } + } else if !ctx.atEndOfInput { + ctx.previousUnitInWord = followsWord + return .needsMoreData + } + } if readsAlternativeQuotes, !SQLNonCodeSpan.isWordUnit(ctx.previousUnit), let quoteLength = alternativeQuotePrefixLength(nsBuffer, at: i, bufLen: bufLen) { if quoteLength > 0 { @@ -425,7 +435,7 @@ final class SQLFileParser: Sendable { appendRange(&ctx, from: i, to: i + prefixLength, in: nsBuffer) ctx.alternativeQuoteCloser = alternativeQuoteCloser(for: nsBuffer.character(at: i + prefixLength - 1)) ctx.state = .inAlternativeQuote - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) i += prefixLength return StepResult(advanced: true, deferred: false) case .none: @@ -479,6 +489,7 @@ final class SQLFileParser: Sendable { ctx.state = .inSingleQuotedString ctx.quoteChar = kSingleQuote ctx.backslashEscapesActive = true + ctx.boundaries?.observeQuotedToken(opener: char) i += 2 return StepResult(advanced: true, deferred: false) } @@ -488,7 +499,7 @@ final class SQLFileParser: Sendable { ctx.hasStatementContent, ctx.statementStartLine, ctx.currentLine) appendChar(char, to: ctx.currentStatement) ctx.state = .inBracketedIdentifier - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) return StepResult(advanced: false, deferred: false) } @@ -501,7 +512,7 @@ final class SQLFileParser: Sendable { let openerRange = NSRange(location: i, length: length) target.append(nsBuffer.substring(with: openerRange)) } - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) ctx.state = .inDollarQuote ctx.dollarTag = tag i += length @@ -525,7 +536,7 @@ final class SQLFileParser: Sendable { } if let advanced = processQuoteOpen(&ctx, char: char, nextChar: nextChar) { - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) if advanced { i += 2 } return StepResult(advanced: advanced, deferred: false) } @@ -708,7 +719,7 @@ final class SQLFileParser: Sendable { ctx.state = .inTripleQuotedString ctx.quoteChar = quote ctx.backslashEscapesActive = ctx.grammar.backslashEscapes(inQuote: quote) - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: quote) } /// Runs a triple-quoted literal to the three quotes that close it, holding back a quote or a backslash the buffer diff --git a/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift b/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift index 3ede0ba639..3d5fd13cc5 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift @@ -28,8 +28,21 @@ enum SQLLexicalResolver { /// The grammar a script is split with for execution on `connectionId`, with the driver's session facts applied. @MainActor static func executionGrammar(for databaseType: DatabaseType, connectionId: UUID) -> SQLLexicalGrammar { - let session = DatabaseManager.shared.driver(for: connectionId)?.sessionLexicalState - return readings(for: databaseType, session: session).execution + readings(for: databaseType, session: sessionState(for: connectionId)).execution + } + + /// The grammar for text bound to `scope`, read from the driver holding that database: a connection that keeps one + /// session per database can hold different session facts on each. + @MainActor + static func executionGrammar(for databaseType: DatabaseType, scope: DatabaseScope) -> SQLLexicalGrammar { + readings(for: databaseType, session: DatabaseManager.shared.sessionDriver(holding: scope)?.sessionLexicalState) + .execution + } + + /// What the connected driver read from its session, or `nil` while `connectionId` has none. + @MainActor + static func sessionState(for connectionId: UUID) -> PluginSessionLexicalState? { + DatabaseManager.shared.driver(for: connectionId)?.sessionLexicalState } private static func declaredGrammar(for databaseType: DatabaseType) -> SQLLexicalGrammar? { diff --git a/apple/TablePro/Core/Utilities/SQL/SQLQueryFingerprint.swift b/apple/TablePro/Core/Utilities/SQL/SQLQueryFingerprint.swift index 9982e24dbb..61e756f2ae 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLQueryFingerprint.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLQueryFingerprint.swift @@ -66,7 +66,7 @@ enum SQLQueryFingerprint { } switch token.type { - case .comment, .whitespace: + case .comment, .whitespace, .directive: continue case .number, .placeholder: @@ -189,7 +189,7 @@ enum SQLQueryFingerprint { return previousValue != "]" case .punctuation: return signPrecedingPunctuation.contains(previousValue) - case .identifier, .number, .string, .placeholder, .comment, .whitespace: + case .identifier, .number, .string, .placeholder, .comment, .whitespace, .directive: return false } } diff --git a/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index a60c1dcb85..70d1741469 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -233,7 +233,7 @@ internal struct SQLRowToStatementConverter { hex += String(format: "%02X", byte) } switch databaseType { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift, .cockroachdb, .pglite, .kingbasees: return "'\\x\(hex)'::bytea" case .mssql: return "0x\(hex)" diff --git a/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift b/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift index 560661b8e0..a70d3f00f8 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift @@ -28,9 +28,14 @@ internal struct SQLScriptText { private let grammar: SQLLexicalGrammar internal init(databaseType: DatabaseType) { + self.init(databaseType: databaseType, grammar: databaseType.lexicalGrammar) + } + + /// `grammar` is the session's execution grammar, which a server setting such as KingbaseES Oracle mode changes. + internal init(databaseType: DatabaseType, grammar: SQLLexicalGrammar) { self.databaseType = databaseType self.dialect = SqlDialect.from(databaseTypeId: databaseType.rawValue) - self.grammar = databaseType.lexicalGrammar + self.grammar = grammar } /// The statements `text` holds, each exactly as the driver receives it in one call, in order. diff --git a/apple/TablePro/Core/Utilities/UI/AlertHelper.swift b/apple/TablePro/Core/Utilities/UI/AlertHelper.swift index a0dc3210b0..9fe36ae79a 100644 --- a/apple/TablePro/Core/Utilities/UI/AlertHelper.swift +++ b/apple/TablePro/Core/Utilities/UI/AlertHelper.swift @@ -251,13 +251,19 @@ final class AlertHelper { work: .immediate { gate.deliver(.success(true)) } ) ) - let confirmed = try? await runHostedDecision( - title: title, - fittingWidth: 560, - gate: gate, - window: window, - rootView: sheet - ) + guard !Task.isCancelled else { return false } + // A caller that gives up takes the review down as a no, the way Escape would. + let confirmed = await withTaskCancellationHandler { + try? await runHostedDecision( + title: title, + fittingWidth: 560, + gate: gate, + window: window, + rootView: sheet + ) + } onCancel: { + Task { @MainActor in gate.cancel() } + } return confirmed ?? false } diff --git a/apple/TablePro/Core/Vim/VimCommandLineHandler.swift b/apple/TablePro/Core/Vim/VimCommandLineHandler.swift index 9f1ab06b3b..4d477dfcda 100644 --- a/apple/TablePro/Core/Vim/VimCommandLineHandler.swift +++ b/apple/TablePro/Core/Vim/VimCommandLineHandler.swift @@ -2,20 +2,34 @@ // VimCommandLineHandler.swift // TablePro // -// Handles Vim command-line commands (:w, :q, etc.) -// import Foundation -/// Handles Vim command-line commands struct VimCommandLineHandler { - /// Callback to execute the current query (:w) var onExecuteQuery: (() -> Void)? - - /// Callback to close the current tab (:q) var onCloseTab: (() -> Void)? + // 1-based position in the tab strip. + var onSelectTab: ((Int) -> Void)? + var onSelectAdjacentTab: ((Int) -> Void)? + var tabCount: (() -> Int)? + + // A count with no tab strip to measure it against stops here, past any strip's length. + private static let countLimit = 999_999 + + private enum TabCommand { + case next + case previous + } + + // Each spelling Vim accepts, from the shortest abbreviation to the full name. + private static let tabCommands: [String: TabCommand] = { + var names: [String: TabCommand] = [:] + for end in 4...7 { names[String("tabnext".prefix(end))] = .next } + for end in 4...11 { names[String("tabprevious".prefix(end))] = .previous } + for end in 4...7 { names[String("tabNext".prefix(end))] = .previous } + return names + }() - /// Process a command string (without the leading : or /) func handle(_ command: String) { let trimmed = command.trimmingCharacters(in: .whitespaces) @@ -25,7 +39,66 @@ struct VimCommandLineHandler { case "q": onCloseTab?() default: - break // Unknown commands are silently ignored + handleTabCommand(trimmed) + } + } + + private func handleTabCommand(_ command: String) { + let parts = command.split(separator: " ", omittingEmptySubsequences: true) + guard let name = parts.first, parts.count <= 2, + let tabCommand = Self.tabCommands[String(name)] else { return } + let argument = parts.count == 2 ? String(parts[1]) : nil + + switch tabCommand { + case .next: + guard let argument else { + onSelectAdjacentTab?(1) + return + } + if argument.hasPrefix("+") || argument.hasPrefix("-") { + guard let offset = relativeCount(argument.dropFirst()) else { return } + onSelectAdjacentTab?(argument.hasPrefix("-") ? -offset : offset) + } else if let number = absoluteCount(Substring(argument)) { + onSelectTab?(number) + } + case .previous: + guard let argument else { + onSelectAdjacentTab?(-1) + return + } + guard let count = relativeCount(Substring(argument)) else { return } + onSelectAdjacentTab?(-count) + } + } + + private var tabs: Int? { + tabCount.map { $0() }.flatMap { $0 > 0 ? $0 : nil } + } + + // Past the last tab is the last tab, as Vim's `goto_tabpage` reads it. + private func absoluteCount(_ text: Substring) -> Int? { + Self.count(text, cap: tabs ?? Self.countLimit, modulus: nil) + } + + // Moving by a multiple of the tab count lands where it started, so the move is reduced to less than one lap. + private func relativeCount(_ text: Substring) -> Int? { + Self.count(text, cap: Self.countLimit, modulus: tabs) + } + + // Read a digit at a time, so a count of any length neither overflows nor leaves the bound. + private static func count(_ text: Substring, cap: Int, modulus: Int?) -> Int? { + guard !text.isEmpty else { return nil } + var value = 0 + var isZero = true + for character in text { + guard ("0"..."9").contains(character), let digit = character.wholeNumberValue else { return nil } + isZero = isZero && digit == 0 + if let modulus { + value = (value * 10 + digit) % modulus + } else { + value = min(value * 10 + digit, cap) + } } + return isZero ? nil : value } } diff --git a/apple/TablePro/Core/Vim/VimEngine+NormalMode.swift b/apple/TablePro/Core/Vim/VimEngine+NormalMode.swift index 697417c5d2..52fd42aac5 100644 --- a/apple/TablePro/Core/Vim/VimEngine+NormalMode.swift +++ b/apple/TablePro/Core/Vim/VimEngine+NormalMode.swift @@ -570,6 +570,20 @@ extension VimEngine { case "J": joinLines(consumeCount(), withSpace: false, in: buffer) return true + case "t", "T": + let count = countPrefix + countPrefix = 0 + operatorCount = 0 + guard pendingOperator == nil else { + pendingOperator = nil + return true + } + if char == "t" { + onCommand?(count > 0 ? "tabnext \(count)" : "tabnext") + } else { + onCommand?("tabprevious \(max(count, 1))") + } + return true case "u": if pendingOperator == .lowercase { applyCaseToLine(.lowercase, count: consumeCount(), in: buffer) diff --git a/apple/TablePro/Core/Vim/VimEngine+Operators.swift b/apple/TablePro/Core/Vim/VimEngine+Operators.swift index ef2c795ddc..9c03d4dbbe 100644 --- a/apple/TablePro/Core/Vim/VimEngine+Operators.swift +++ b/apple/TablePro/Core/Vim/VimEngine+Operators.swift @@ -234,9 +234,7 @@ extension VimEngine { guard deleteCount > 0 else { return } let start = pos - deleteCount let range = NSRange(location: start, length: deleteCount) - register.text = buffer.string(in: range) - register.isLinewise = false - register.syncToPasteboard() + writeToActiveRegister(text: buffer.string(in: range), linewise: false, asDelete: true) buffer.replaceCharacters(in: range, with: "") buffer.setSelectedRange(NSRange(location: start, length: 0)) } @@ -251,9 +249,7 @@ extension VimEngine { let deleteCount = min(count, max(0, contentEnd - pos)) guard deleteCount > 0 else { setMode(.insert); return } let range = NSRange(location: pos, length: deleteCount) - register.text = buffer.string(in: range) - register.isLinewise = false - register.syncToPasteboard() + writeToActiveRegister(text: buffer.string(in: range), linewise: false, asDelete: true) buffer.replaceCharacters(in: range, with: "") buffer.setSelectedRange(NSRange(location: pos, length: 0)) setMode(.insert) diff --git a/apple/TablePro/Core/Vim/VimEngine+RegistersMarks.swift b/apple/TablePro/Core/Vim/VimEngine+RegistersMarks.swift index f0a50e5c70..6b72f1d6b7 100644 --- a/apple/TablePro/Core/Vim/VimEngine+RegistersMarks.swift +++ b/apple/TablePro/Core/Vim/VimEngine+RegistersMarks.swift @@ -7,6 +7,10 @@ import Foundation extension VimEngine { func writeToActiveRegister(text: String, linewise: Bool, asDelete: Bool) { + if selectedRegister == "_" { + selectedRegister = nil + return + } let entry = VimRegister(text: text, isLinewise: linewise) register = entry register.syncToPasteboard() @@ -18,7 +22,7 @@ extension VimEngine { } else { numberedRegisters[0] = entry } - if let name = selectedRegister, name != "_" { + if let name = selectedRegister { let isAppend = name.isUppercase let key: Character = isAppend ? Character(name.lowercased()) : name if isAppend, let existing = namedRegisters[key], !existing.text.isEmpty { diff --git a/apple/TablePro/Core/Vim/VimEngine+VisualMode.swift b/apple/TablePro/Core/Vim/VimEngine+VisualMode.swift index d7c1ebe5cb..55c7a9aaae 100644 --- a/apple/TablePro/Core/Vim/VimEngine+VisualMode.swift +++ b/apple/TablePro/Core/Vim/VimEngine+VisualMode.swift @@ -160,9 +160,7 @@ extension VimEngine { case "c": let sel = buffer.selectedRange() if sel.length > 0 { - register.text = buffer.string(in: sel) - register.isLinewise = isLinewise - register.syncToPasteboard() + writeToActiveRegister(text: buffer.string(in: sel), linewise: isLinewise, asDelete: true) if isLinewise { let trimmed = sel.length > 0 && sel.location + sel.length - 1 < buffer.length diff --git a/apple/TablePro/Core/Vim/VimRegister.swift b/apple/TablePro/Core/Vim/VimRegister.swift index 2483e922ac..4b14fe3759 100644 --- a/apple/TablePro/Core/Vim/VimRegister.swift +++ b/apple/TablePro/Core/Vim/VimRegister.swift @@ -2,23 +2,16 @@ // VimRegister.swift // TablePro // -// Vim register for storing yanked/deleted text -// -import AppKit +import Foundation -/// Vim register for yank/delete/paste operations struct VimRegister { - /// The stored text content var text: String = "" - - /// Whether the text was yanked/deleted linewise (entire lines) var isLinewise: Bool = false - /// Sync the register content to the system pasteboard + @MainActor func syncToPasteboard() { guard !text.isEmpty else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) + ClipboardService.shared.writeText(text) } } diff --git a/apple/TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift b/apple/TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift index 0017b21d0e..7df214ff3e 100644 --- a/apple/TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift +++ b/apple/TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift @@ -2,4 +2,6 @@ import AppKit internal extension NSLayoutConstraint.Priority { static let splitPaneHolding = NSLayoutConstraint.Priority(260) + // Below the other pane's `.defaultLow`, so this one takes the room when the split view resizes. + static let splitPaneFlexible = NSLayoutConstraint.Priority(240) } diff --git a/apple/TablePro/Extensions/NSWindow+FirstFrame.swift b/apple/TablePro/Extensions/NSWindow+FirstFrame.swift index 4facce23e8..2b78ba91d6 100644 --- a/apple/TablePro/Extensions/NSWindow+FirstFrame.swift +++ b/apple/TablePro/Extensions/NSWindow+FirstFrame.swift @@ -19,9 +19,14 @@ internal extension NSWindow { /// than never. That covers a launch nobody is looking at, which is exactly the case that must /// not stall. func afterNextFrame(_ body: @escaping @MainActor () -> Void) { + _ = observeNextFrame(body) + } + + /// Returns what stops the wait, for a caller that gives up on a frame that may never come. + private func observeNextFrame(_ body: @escaping @MainActor () -> Void) -> @MainActor () -> Void { guard screen != nil, let view = contentView else { body() - return + return {} } guard #available(macOS 14.0, *) else { /// `NSView.displayLink(target:selector:)` and `CADisplayLink` are both macOS 14. @@ -32,17 +37,63 @@ internal extension NSWindow { CATransaction.setCompletionBlock { DispatchQueue.main.async { MainActor.assumeIsolated(body) } } - return + return {} + } + let observer = FirstFrameObserver.observe(view, then: body) + return { observer.cancel() } + } + + /// Returns once the display has shown this window's next frame, or after `limit`: a hidden or + /// off-display view drives no display link, so the frame alone could never arrive. + func nextFrame(limit: Duration = .milliseconds(100)) async { + let gate = FrameGate() + await withCheckedContinuation { (continuation: CheckedContinuation) in + gate.continuation = continuation + gate.stopObserving = observeNextFrame { gate.open() } + Task { @MainActor in + try? await Task.sleep(for: limit) + gate.open() + } + } + } +} + +/// Work that must not share a run loop turn with the frame this turn builds. +@MainActor +internal enum CommittedTurn { + /// Runs `body` once `window` has shown its next frame, or after `nextFrame`'s limit; with no window, on the + /// next main-actor turn. A main-queue hop alone can run before the run loop waits, so the hang would still be + /// one: both pieces between two waits. A transaction's completion block is no end either: it waits for every + /// animation the turn adds, and a spinning progress indicator's never finishes. + static func after(in window: NSWindow?, _ body: @escaping @MainActor () -> Void) { + Task { @MainActor in + await window?.nextFrame() + body() } - FirstFrameObserver.observe(view, then: body) + } +} + +@MainActor +private final class FrameGate { + var continuation: CheckedContinuation? + var stopObserving: (@MainActor () -> Void)? + + // The limit can win, and a display link left registered would hold its observer for good. + func open() { + stopObserving?() + stopObserving = nil + continuation?.resume() + continuation = nil } } @available(macOS 14.0, *) @MainActor -private final class FirstFrameObserver: NSObject { +internal final class FirstFrameObserver: NSObject { private static var live: Set = [] + static var liveCount: Int { live.count } + private let body: @MainActor () -> Void private var link: CADisplayLink? @@ -50,19 +101,25 @@ private final class FirstFrameObserver: NSObject { self.body = body } - static func observe(_ view: NSView, then body: @escaping @MainActor () -> Void) { + static func observe(_ view: NSView, then body: @escaping @MainActor () -> Void) -> FirstFrameObserver { let observer = FirstFrameObserver(body: body) let link = view.displayLink(target: observer, selector: #selector(fire)) observer.link = link live.insert(observer) link.add(to: .main, forMode: .common) + return observer } - @objc - private func fire() { + func cancel() { link?.invalidate() link = nil Self.live.remove(self) + } + + @objc + private func fire() { + guard link != nil else { return } + cancel() body() } } diff --git a/apple/TablePro/Info.plist b/apple/TablePro/Info.plist index 9ac41c6a57..a6ec1fc9d6 100644 --- a/apple/TablePro/Info.plist +++ b/apple/TablePro/Info.plist @@ -581,6 +581,8 @@ postgresql postgres + kingbase + kingbase8 mysql mariadb tidb diff --git a/apple/TablePro/Models/Connection/ConnectionToolbarState.swift b/apple/TablePro/Models/Connection/ConnectionToolbarState.swift index 38ee3fb4a8..ed8460e957 100644 --- a/apple/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/apple/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -130,8 +130,6 @@ final class ConnectionToolbarState: ObservableObject { /// Whether there are pending data grid changes (for SQL preview button) @Published var hasDataPendingChanges: Bool = false - /// Whether the structure view has pending schema changes - @Published var hasStructureChanges: Bool = false // Bumped when a diagram's staged edits come or go; the pending state reads the diagram itself. @Published var diagramEditsRevision = 0 diff --git a/apple/TablePro/Models/Connection/DatabaseType.swift b/apple/TablePro/Models/Connection/DatabaseType.swift index 54359866a6..bce99781a3 100644 --- a/apple/TablePro/Models/Connection/DatabaseType.swift +++ b/apple/TablePro/Models/Connection/DatabaseType.swift @@ -26,6 +26,7 @@ extension DatabaseType { static let pglite = DatabaseType(rawValue: "PGlite") // Registry-distributed types (known plugins, downloadable separately) + static let kingbasees = DatabaseType(rawValue: "KingbaseES") static let mongodb = DatabaseType(rawValue: "MongoDB") static let redis = DatabaseType(rawValue: "Redis") static let mssql = DatabaseType(rawValue: "SQL Server") diff --git a/apple/TablePro/Models/ERDiagram/ERDiagramStagedOverlay.swift b/apple/TablePro/Models/ERDiagram/ERDiagramStagedOverlay.swift index 548af7f3af..18f7aa898c 100644 --- a/apple/TablePro/Models/ERDiagram/ERDiagramStagedOverlay.swift +++ b/apple/TablePro/Models/ERDiagram/ERDiagramStagedOverlay.swift @@ -148,7 +148,8 @@ extension ERDiagramStagedOverlay.TableEdits { case .deleteForeignKey(let foreignKey): foreignKeyEdits[foreignKey.id] = .dropped case .addIndex, .modifyIndex, .deleteIndex, .addCheckConstraint, .modifyCheckConstraint, - .deleteCheckConstraint, .modifyPrimaryKey, .modifyTableComment: + .deleteCheckConstraint, .modifyPrimaryKey, .modifyTableComment, .modifyTableCharacterSet, + .modifyAutoIncrement: continue } } diff --git a/apple/TablePro/Models/Export/ExportModels.swift b/apple/TablePro/Models/Export/ExportModels.swift index 0f6c42f8ee..288bdb1e30 100644 --- a/apple/TablePro/Models/Export/ExportModels.swift +++ b/apple/TablePro/Models/Export/ExportModels.swift @@ -292,6 +292,8 @@ struct ExportDatabaseItem: Identifiable { /// Set when the container's own checkbox was turned on before its unread kinds arrived, so /// they arrive selected like everything else the click reached. var selectsLoadedObjects = false + // Column values set before the unread kinds arrived, by column index; they arrive with them. + var arrivingOptionValues: [Int: Bool] = [:] init( id: UUID = UUID(), @@ -313,8 +315,12 @@ struct ExportDatabaseItem: Identifiable { /// An object that arrives already selected (a restored row) stays so. Any other is selected /// only when the container was ticked whole and nothing in it has been unticked since. - func mergingLoadedObjects(_ loaded: [ExportObjectItem]) -> ExportDatabaseItem { - let selectsArrivals = selectsLoadedObjects && objects.allSatisfy(\.isSelected) + func mergingLoadedObjects( + _ loaded: [ExportObjectItem], + writes: (ExportObjectItem) -> Bool = { _ in true } + ) -> ExportDatabaseItem { + // An object that writes nothing stays out of a container tick, so it does not count as unticked. + let selectsArrivals = selectsLoadedObjects && objects.filter(writes).allSatisfy(\.isSelected) let incoming = loaded.filter { unloadedKinds.contains($0.kind) } // The table listing reported these sequences as tables; one relation, so the sequence replaces it. let sequenceNames = Set(incoming.filter { $0.kind == .sequence }.map(\.name)) @@ -331,9 +337,32 @@ struct ExportDatabaseItem: Identifiable { merged.unloadedKinds = [] merged.isLoadingObjects = false merged.selectsLoadedObjects = false + merged.arrivingOptionValues = [:] return merged } + // Kept values go on after normalizing, which replaces an arrival's empty list with the defaults. + func mergingLoadedObjects(_ loaded: [ExportObjectItem], layout: ExportOptionLayout) -> ExportDatabaseItem? { + let merged = mergingLoadedObjects(loaded, writes: layout.writesSomething) + guard !merged.objects.isEmpty, let normalized = [merged].normalized(for: layout).first else { return nil } + let arrivals = Set(normalized.objects.map(\.id)).subtracting(objects.map(\.id)) + return normalized.applyingOptionValues(arrivingOptionValues, toObjects: arrivals, layout: layout) + } + + func applyingOptionValues( + _ values: [Int: Bool], + toObjects ids: Set, + layout: ExportOptionLayout + ) -> ExportDatabaseItem { + guard !values.isEmpty, !ids.isEmpty else { return self } + var updated = self + updated.objects = objects.map { object in + guard ids.contains(object.id) else { return object } + return object.settingOptions(values.filter { layout.supports(columnAt: $0.key, kind: object.kind) }) + } + return updated + } + func withID(_ id: UUID) -> ExportDatabaseItem { var copy = ExportDatabaseItem( id: id, @@ -344,6 +373,7 @@ struct ExportDatabaseItem: Identifiable { ) copy.isLoadingObjects = isLoadingObjects copy.selectsLoadedObjects = selectsLoadedObjects + copy.arrivingOptionValues = arrivingOptionValues return copy } @@ -377,7 +407,75 @@ struct ExportDatabaseItem: Identifiable { } } +enum ExportKindReconciliation: Equatable { + case unchanged + // The kinds left after narrowing, so a wider format later reads again. + case narrowed(loadedKinds: Set) + case reload + + static func next( + loadedKinds: Set, + wanted: Set + ) -> ExportKindReconciliation { + guard !loadedKinds.isEmpty else { return .unchanged } + guard wanted.isSubset(of: loadedKinds) else { return .reload } + return wanted == loadedKinds ? .unchanged : .narrowed(loadedKinds: wanted) + } +} + +// Resolved by the dialog so the tree never looks a plugin up itself. +struct ExportOptionLayout { + let columns: [PluginExportOptionColumn] + let defaultValues: [Bool] + // False for a structure-only format, which ignores a row scope. + let readsRows: Bool + let supports: (String, PluginExportObjectKind) -> Bool + + static var none: ExportOptionLayout { + ExportOptionLayout(columns: [], defaultValues: [], readsRows: true) { _, _ in true } + } + + func supports(columnAt index: Int, kind: PluginExportObjectKind) -> Bool { + columns.indices.contains(index) && supports(columns[index].id, kind) + } + + func writesSomething(_ object: ExportObjectItem) -> Bool { + columns.isEmpty || object.optionValues.contains(true) + } + + // Unread kinds count with the value they will arrive with. + func values( + ofColumn index: Int, + in objects: [ExportObjectItem], + unloadedKinds: Set = [], + arriving: [Int: Bool] = [:] + ) -> [Bool] { + guard columns.indices.contains(index) else { return [] } + let fallback = defaultValues.count == columns.count ? defaultValues[index] : columns[index].defaultValue + var values = objects + .filter { supports(columnAt: index, kind: $0.kind) } + .map { $0.optionValues[safe: index] ?? columns[index].defaultValue } + if unloadedKinds.contains(where: { supports(columnAt: index, kind: $0) }) { + values.append(arriving[index] ?? fallback) + } + return values + } +} + extension ExportObjectItem { + // Never selects. A selected object left with nothing on is deselected: it would export nothing. + func settingOptions(_ values: [Int: Bool]) -> ExportObjectItem { + guard values.keys.contains(where: optionValues.indices.contains) else { return self } + var updated = self + for (index, value) in values where updated.optionValues.indices.contains(index) { + updated.optionValues[index] = value + } + if !updated.optionValues.contains(true) { + updated.isSelected = false + } + return updated + } + func normalized(forOptionColumnCount optionColumnCount: Int, defaultOptionValues: [Bool]) -> ExportObjectItem { guard optionColumnCount > 0 else { return self } let fallback = defaultOptionValues.count == optionColumnCount @@ -451,6 +549,23 @@ extension [ExportDatabaseItem] { } } + // Masking keeps a routine from carrying a Data flag that would count it as exportable. + func normalized(for layout: ExportOptionLayout) -> [ExportDatabaseItem] { + normalizingOptionValues(optionColumnCount: layout.columns.count, defaultOptionValues: layout.defaultValues) + .maskingUnsupportedOptions(columns: layout.columns, supports: layout.supports) + } + + // A profile's option values belong to its format, so the tree is reset for that format first. + func applyingProfile( + _ profile: ExportProfile, + layout: ExportOptionLayout, + databaseType: DatabaseType + ) -> [ExportDatabaseItem] { + ExportProfileStorage.apply(profile, to: self) + .clearingRowScopes(unavailableOn: databaseType) + .normalized(for: layout) + } + func normalizingOptionValues(optionColumnCount: Int, defaultOptionValues: [Bool]) -> [ExportDatabaseItem] { map { database in var normalizedDatabase = database @@ -464,6 +579,7 @@ extension [ExportDatabaseItem] { func resettingOptionValues(to values: [Bool]) -> [ExportDatabaseItem] { map { database in var resetDatabase = database + resetDatabase.arrivingOptionValues = [:] resetDatabase.objects = database.objects.map { object in var resetObject = object resetObject.optionValues = values @@ -486,6 +602,24 @@ extension [ExportDatabaseItem] { } } + func settingOption( + _ index: Int, + to value: Bool, + objectIDs ids: Set, + arrivingIn databaseID: UUID? = nil + ) -> [ExportDatabaseItem] { + map { database in + var updated = database + updated.objects = database.objects.map { object in + ids.contains(object.id) ? object.settingOptions([index: value]) : object + } + if database.id == databaseID { + updated.arrivingOptionValues[index] = value + } + return updated + } + } + func clearingRowScopes(unavailableOn databaseType: DatabaseType) -> [ExportDatabaseItem] { map { database in var clearedDatabase = database diff --git a/apple/TablePro/Models/Query/QueryResult.swift b/apple/TablePro/Models/Query/QueryResult.swift index 0c11550945..4479269937 100644 --- a/apple/TablePro/Models/Query/QueryResult.swift +++ b/apple/TablePro/Models/Query/QueryResult.swift @@ -286,6 +286,9 @@ struct ColumnInfo: Identifiable, Hashable { /// says nothing about what the column holds. `PluginColumnInfo.classificationTypeName` says why. let classificationTypeName: String? + /// MariaDB's column-level CHECK clause, which a MODIFY drops unless it restates it. + let columnCheck: String? + /// What a classifier reads. Every display reads `dataType`, and these differ on PostgreSQL: /// `status` is shown and `ENUM` is classified, `posint` is shown and `INTEGER` is classified. var typeNameForClassification: String { classificationTypeName ?? dataType } @@ -309,7 +312,8 @@ struct ColumnInfo: Identifiable, Hashable { ddlDefault: String? = nil, ddlGenerationExpression: String? = nil, ddlCollation: String? = nil, - classificationTypeName: String? = nil + classificationTypeName: String? = nil, + columnCheck: String? = nil ) { self.name = name self.dataType = dataType @@ -330,6 +334,7 @@ struct ColumnInfo: Identifiable, Hashable { self.ddlGenerationExpression = ddlGenerationExpression self.ddlCollation = ddlCollation self.classificationTypeName = classificationTypeName + self.columnCheck = columnCheck } } diff --git a/apple/TablePro/Models/Query/QueryTab.swift b/apple/TablePro/Models/Query/QueryTab.swift index bdcbb9fc3d..072966f0b8 100644 --- a/apple/TablePro/Models/Query/QueryTab.swift +++ b/apple/TablePro/Models/Query/QueryTab.swift @@ -71,7 +71,6 @@ struct QueryTab: Identifiable, Equatable { var schemaVersion: Int var metadataVersion: Int var paginationVersion: Int - var loadEpoch: Int = 0 var pendingRestoredSort: [PersistedSortColumn]? // Held until the first load resolves the columns. @@ -155,7 +154,6 @@ struct QueryTab: Identifiable, Equatable { self.schemaVersion = 0 self.metadataVersion = 0 self.paginationVersion = 0 - self.loadEpoch = 0 self.pendingRestoredSort = nil self.restoredSortSource = .unset self.restoredPage = nil @@ -210,7 +208,6 @@ struct QueryTab: Identifiable, Equatable { self.schemaVersion = 0 self.metadataVersion = 0 self.paginationVersion = 0 - self.loadEpoch = 0 self.pendingRestoredSort = persisted.sortColumns // A file from before sortSource decodes to its old behaviour: saved columns were the user's, none meant undecided. self.restoredSortSource = persisted.sortSource @@ -387,6 +384,5 @@ struct QueryTab: Identifiable, Equatable { && lhs.isPreview == rhs.isPreview && lhs.isPinned == rhs.isPinned && lhs.hasUserInteraction == rhs.hasUserInteraction - && lhs.loadEpoch == rhs.loadEpoch } } diff --git a/apple/TablePro/Models/Query/QueryTabManager.swift b/apple/TablePro/Models/Query/QueryTabManager.swift index 4202f2df36..49b0da031f 100644 --- a/apple/TablePro/Models/Query/QueryTabManager.swift +++ b/apple/TablePro/Models/Query/QueryTabManager.swift @@ -192,7 +192,9 @@ final class QueryTabManager: ObservableObject { guard !strip.isEmpty else { return nil } let current = selectedTab.flatMap { tab in strip.firstIndex { $0.id == tab.id } } ?? 0 let count = strip.count - return strip[((current + offset) % count + count) % count].id + // Reduced before it is added, so no offset can overflow. + let step = offset % count + return strip[((current + step) % count + count) % count].id } // MARK: - Recency @@ -580,6 +582,23 @@ final class QueryTabManager: ObservableObject { return newTab.id } + // One tab per run: debugging the same routine again starts a second session rather than taking over a live one. + @discardableResult + func addRoutineDebugTab(id: UUID = UUID(), title: String, databaseName: String, schemaName: String?) -> UUID { + var newTab = QueryTab(id: id, title: title, tabType: .routineDebug) + newTab.tableContext.isEditable = false + newTab.tableContext.databaseName = databaseName + newTab.tableContext.schemaName = schemaName.flatMap { $0.isEmpty ? nil : $0 } + newTab.hasUserInteraction = true + tabs.append(newTab) + selectedTabId = newTab.id + return newTab.id + } + + static func routineDebugTitle(for name: String) -> String { + String(format: String(localized: "Debug %@"), name) + } + // Qualified by schema where tables are listed per schema, or two listings in one database would share a label. static func tableListingTitle(kindTitle: String, schemaName: String?) -> String { guard let schemaName, !schemaName.isEmpty else { return kindTitle } @@ -742,8 +761,11 @@ extension QueryTabManager { return visible } + // A debug tab names no entry: its paused target holds locks whichever database the sidebar shows, so it stays in + // sight everywhere while its run keeps the database it was started in. func containerName(of tab: QueryTab) -> String? { - WorkspaceAnchoring.containerName(of: tab, target: containerTarget) + guard !tab.tabType.isTransient else { return nil } + return WorkspaceAnchoring.containerName(of: tab, target: containerTarget) } // A tab naming no container (a server dashboard, a query tab on a browse-following engine) shows in every entry. @@ -810,7 +832,8 @@ extension QueryTabManager { switch type { case .query, .table, .createTable, .erDiagram, .objectSource, .tableListing: return true - case .serverDashboard, .usersRoles, .insights, .versionHistory: + // A paused debug target holds locks, so its tab stays in sight whichever entry the sidebar shows. + case .serverDashboard, .usersRoles, .insights, .versionHistory, .routineDebug: return false } } diff --git a/apple/TablePro/Models/Query/QueryTabState.swift b/apple/TablePro/Models/Query/QueryTabState.swift index 42fa43be7a..76f2957417 100644 --- a/apple/TablePro/Models/Query/QueryTabState.swift +++ b/apple/TablePro/Models/Query/QueryTabState.swift @@ -20,6 +20,14 @@ enum TabType: Equatable, Codable, Hashable { case versionHistory // Read-only grid of one database's or schema's tables, rebuilt from the catalog on each load, never from saved SQL. case tableListing + case routineDebug +} + +extension TabType { + // A live debug session cannot be restored, and the debugger is a module, so its tab never reaches disk or an outside client. + var isTransient: Bool { + self == .routineDebug + } } struct PersistedTab: Codable { diff --git a/apple/TablePro/Models/Query/ResultStatusModel.swift b/apple/TablePro/Models/Query/ResultStatusModel.swift index 6e9925fe0f..665075df18 100644 --- a/apple/TablePro/Models/Query/ResultStatusModel.swift +++ b/apple/TablePro/Models/Query/ResultStatusModel.swift @@ -48,9 +48,6 @@ struct ResultStatusControls: Equatable { /// First, Previous, Next, Last and the page number, which an engine that cannot skip rows has /// no use for. The rows-per-page menu stays, because it still sets how many leading rows load. var showsPageNavigation = false - /// The structure editor's add and remove pair, which is this bar's trailing cluster while the - /// structure editor is the content. - var showsStructureActions = false /// Only the data grid has cells to sum. JSON and Map report the row selection, not cells. var showsSelectionSummary = false } @@ -95,7 +92,6 @@ struct ResultStatusModel: Equatable { let isTable = snapshot.tabType == .table controls.showsModeSwitcher = snapshot.availableModes.count > 1 - controls.showsStructureActions = viewMode == .structure && snapshot.hasStructureActions controls.showsExecution = viewMode.reportsExecution /// A plan keeps the bar so it stays choosable and pinnable, and gives up everything the bar diff --git a/apple/TablePro/Models/Query/StatusBarSnapshot.swift b/apple/TablePro/Models/Query/StatusBarSnapshot.swift index 3202cbd7e7..d241272bc2 100644 --- a/apple/TablePro/Models/Query/StatusBarSnapshot.swift +++ b/apple/TablePro/Models/Query/StatusBarSnapshot.swift @@ -21,8 +21,6 @@ struct StatusBarSnapshot: Equatable { let hasTableName: Bool /// The modes this tab can switch between, which the bar's leading control offers. let availableModes: [ResultsViewMode] - /// Whether the structure editor has published an add/remove pair for this tab. - let hasStructureActions: Bool let pagination: PaginationState let statusMessage: String? let paginationCapability: PaginationCapability @@ -40,7 +38,6 @@ struct StatusBarSnapshot: Equatable { isValueFiltered: Bool = false, hasTableName: Bool, availableModes: [ResultsViewMode] = [], - hasStructureActions: Bool = false, isQueryPlan: Bool = false, pagination: PaginationState, statusMessage: String?, @@ -55,7 +52,6 @@ struct StatusBarSnapshot: Equatable { self.isValueFiltered = isValueFiltered self.hasTableName = hasTableName self.availableModes = availableModes - self.hasStructureActions = hasStructureActions self.isQueryPlan = isQueryPlan self.pagination = pagination self.statusMessage = statusMessage @@ -72,7 +68,6 @@ struct StatusBarSnapshot: Equatable { tableRows: TableRows?, displayRowCount: Int? = nil, isFetching: Bool = false, - hasStructureActions: Bool = false, isQueryPlan: Bool = false, paginationCapability: PaginationCapability = .offset, hasServerOutput: Bool = false, @@ -98,7 +93,6 @@ struct StatusBarSnapshot: Equatable { hasSpatialColumn: !(tab?.display.spatialColumns.isEmpty ?? true), hasServerOutput: hasServerOutput ), - hasStructureActions: hasStructureActions, isQueryPlan: isQueryPlan, pagination: pagination, // A run of several statements leaves each one's message on its own result, not on the tab. diff --git a/apple/TablePro/Models/Query/TabSession.swift b/apple/TablePro/Models/Query/TabSession.swift index 9038cd12f8..7d926a3d79 100644 --- a/apple/TablePro/Models/Query/TabSession.swift +++ b/apple/TablePro/Models/Query/TabSession.swift @@ -24,7 +24,6 @@ final class TabSession: ObservableObject, Identifiable { let id: UUID @Published var tableRows: TableRows - @Published var isEvicted: Bool /// Bumped by `TabSessionRegistry` on every mutation of `tableRows`. `TableRows` is a /// value type with no `Equatable` conformance, so views that derive expensive content @@ -48,15 +47,13 @@ final class TabSession: ObservableObject, Identifiable { var viewportStage: GridViewportStage? /// Whether the table changed after these rows, or the definition they were read with, were - /// fetched. Unlike eviction the rows stay, so the grid goes on showing them until the reload - /// commits over them, and a tab whose last result was empty can be marked too. Not published: - /// nothing draws it. + /// fetched. The rows stay, so the grid goes on showing them until the reload commits over them, + /// and a tab whose last result was empty can be marked too. Not published: nothing draws it. var freshness: TableFreshness init(id: UUID = UUID()) { self.id = id self.tableRows = TableRows() - self.isEvicted = false self.freshness = TableFreshness() self.dataRevision = 0 self.bufferEpoch = 0 diff --git a/apple/TablePro/Models/Query/TabSessionRegistry.swift b/apple/TablePro/Models/Query/TabSessionRegistry.swift index 6479ac81b0..6b2429bd89 100644 --- a/apple/TablePro/Models/Query/TabSessionRegistry.swift +++ b/apple/TablePro/Models/Query/TabSessionRegistry.swift @@ -40,29 +40,17 @@ final class TabSessionRegistry { func setTableRows(_ rows: TableRows, for tabId: UUID) { let session = ensureSession(for: tabId) session.tableRows = rows - session.isEvicted = false session.dataRevision &+= 1 session.bufferEpoch &+= 1 session.rowSetRevision &+= 1 } - /// A mutation of what the tab already holds, so it cannot resurrect a tab that holds nothing. - /// - /// Phase-2 metadata (foreign keys, defaults, enum values) lands at background priority long - /// after the load that asked for it, and it is keyed on the result rather than on the rows, so - /// it arrives on tabs that were evicted while it was in flight. Clearing `isEvicted` for a - /// mutation that leaves the buffer empty leaves a tab with no rows that `canAutoLoadTableTab` - /// reads as already loaded, and eviction cannot re-mark it because it has nothing left to lose: - /// the grid stays empty until an explicit refresh. @discardableResult func updateTableRows(for tabId: UUID, _ mutate: (inout TableRows) -> Delta) -> Delta { let session = ensureSession(for: tabId) var rows = session.tableRows let delta = mutate(&rows) session.tableRows = rows - if !rows.rows.isEmpty { - session.isEvicted = false - } session.dataRevision &+= 1 if delta.changesRowSet { session.rowSetRevision &+= 1 @@ -73,26 +61,6 @@ final class TabSessionRegistry { func removeTableRows(for tabId: UUID) { guard let session = sessions[tabId] else { return } session.tableRows = TableRows() - session.isEvicted = false - session.dataRevision &+= 1 - session.bufferEpoch &+= 1 - session.rowSetRevision &+= 1 - } - - func isEvicted(_ tabId: UUID) -> Bool { - sessions[tabId]?.isEvicted ?? false - } - - /// Drops a tab's rows and marks it evicted. Getting them back is the caller's job: SwiftUI's - /// `.task(id:)` keys on `QueryTab.loadEpoch`, so a caller that wants the lazy load to re-fire - /// bumps that itself. - /// - /// A tab with no rows is left alone, so eviction never marks a tab that has nothing to lose. - func evict(for tabId: UUID) { - guard let session = sessions[tabId] else { return } - guard !session.tableRows.rows.isEmpty else { return } - session.tableRows.discardRowsKeepingMetadata() - session.isEvicted = true session.dataRevision &+= 1 session.bufferEpoch &+= 1 session.rowSetRevision &+= 1 @@ -109,8 +77,7 @@ final class TabSessionRegistry { } /// Records that the tab's table changed. Nothing is discarded, and a tab holding no rows is - /// marked all the same: an empty collection that gains its first document is exactly the tab - /// eviction refuses. + /// marked all the same: an empty collection can gain its first document. func recordChange(_ change: TableFreshness.Change, for tabId: UUID) { ensureSession(for: tabId).freshness.record(change) } diff --git a/apple/TablePro/Models/Query/TableLoadTrigger.swift b/apple/TablePro/Models/Query/TableLoadTrigger.swift index 240a04661d..744f343fd2 100644 --- a/apple/TablePro/Models/Query/TableLoadTrigger.swift +++ b/apple/TablePro/Models/Query/TableLoadTrigger.swift @@ -9,3 +9,9 @@ internal enum TableLoadTrigger { case userInitiated case restore } + +// Keyed by tab: a window-wide trigger was consumed against whatever tab was selected when the connection came back. +internal struct ParkedTableLoad: Equatable { + let tabId: UUID + let trigger: TableLoadTrigger +} diff --git a/apple/TablePro/Models/Query/TableRows.swift b/apple/TablePro/Models/Query/TableRows.swift index 852751660b..b09235c46c 100644 --- a/apple/TablePro/Models/Query/TableRows.swift +++ b/apple/TablePro/Models/Query/TableRows.swift @@ -174,13 +174,6 @@ struct TableRows: Sendable { rowLocators[id] } - /// Releases the row payload while keeping the schema needed to render and reload the table. - mutating func discardRowsKeepingMetadata() { - rows = [] - indexByID = [:] - rowLocators = [:] - } - /// Writes a value into a cell, or with `isAbsent` takes the field out of the row. A value /// written into a missing field puts the field back. @discardableResult diff --git a/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift b/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift index 87daeaefb7..1f48338188 100644 --- a/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift +++ b/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift @@ -7,8 +7,8 @@ import Foundation /// a statement the server rejects. /// /// Curated in the app and keyed by database type id. It cannot be keyed by plugin: MySQL and MariaDB -/// share one and spell an expression default differently; PostgreSQL, Redshift, CockroachDB and PGlite -/// share one, and only two of them have `gen_random_uuid()`. +/// share one and spell an expression default differently; PostgreSQL, Redshift, CockroachDB, PGlite and +/// KingbaseES share one, and Redshift and KingbaseES have no `gen_random_uuid()`. /// /// What belongs here is the vocabulary, not the syntax. MySQL takes a `TEXT` or `JSON` default only in /// parentheses whatever the value is, so that parenthesising is the MySQL driver's job at the point it @@ -36,10 +36,11 @@ internal enum ColumnDefaultVocabulary { } /// Oracle treats a zero-length character value as null, so `DEFAULT ''` and `DEFAULT NULL` are the - /// same statement there and offering both would be a lie. Dameng follows Oracle. + /// same statement there and offering both would be a lie. Dameng follows Oracle, and so does KingbaseES + /// in Oracle mode, which its type id cannot tell from PostgreSQL mode. private static func offersEmptyString(_ databaseType: DatabaseType) -> Bool { switch databaseType { - case .oracle, .dameng: false + case .oracle, .dameng, .kingbasees: false default: true } } @@ -52,6 +53,8 @@ internal enum ColumnDefaultVocabulary { return ["CURRENT_TIMESTAMP", "uuid()", "curdate()"] case .postgresql, .pglite: return ["CURRENT_TIMESTAMP", "now()", "CURRENT_DATE", "gen_random_uuid()", "true", "false"] + case .kingbasees: + return ["CURRENT_TIMESTAMP", "now()", "CURRENT_DATE", "true", "false"] case .cockroachdb: return ["current_timestamp()", "now()", "gen_random_uuid()", "unique_rowid()", "true", "false"] case .redshift: diff --git a/apple/TablePro/Models/Schema/ColumnDefinition.swift b/apple/TablePro/Models/Schema/ColumnDefinition.swift index 15930c756d..682e1597da 100644 --- a/apple/TablePro/Models/Schema/ColumnDefinition.swift +++ b/apple/TablePro/Models/Schema/ColumnDefinition.swift @@ -24,6 +24,8 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable { var extra: String? var generationExpression: String? var generationKind: GenerationKind? + /// Not encoded: it names columns of the table it was read from. + var columnCheck: String? var isPrimaryKey: Bool @@ -83,6 +85,7 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable { extra: String?, generationExpression: String? = nil, generationKind: GenerationKind? = nil, + columnCheck: String? = nil, isPrimaryKey: Bool, ddlSpelling: String? = nil, ddlDefault: String? = nil, @@ -104,6 +107,7 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable { self.extra = extra self.generationExpression = generationExpression self.generationKind = generationKind + self.columnCheck = columnCheck self.isPrimaryKey = isPrimaryKey self.catalogType = ddlSpelling.map { CatalogSpelling(value: dataType, spelling: $0) } self.catalogDefault = Self.catalogSpelling(value: defaultValue, spelling: ddlDefault) @@ -198,6 +202,19 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable { defaultValue = nil } + /// One write for the pair, since MySQL refuses a collation from another character set. + mutating func setCharacterSet(_ name: String?, collation: String?) { + charset = name + self.collation = collation + } + + /// MySQL refuses `INT CHARACTER SET latin1` with ERROR 1064, so the pair goes in the same edit. + mutating func setDataType(_ dataType: String) { + self.dataType = dataType + guard !ColumnTextType.takesCharacterSet(dataType) else { return } + setCharacterSet(nil, collation: nil) + } + var hasName: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty } var hasDataType: Bool { !dataType.trimmingCharacters(in: .whitespaces).isEmpty } @@ -246,6 +263,7 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable { extra: columnInfo.extra, generationExpression: columnInfo.generationExpression, generationKind: columnInfo.generationKind, + columnCheck: columnInfo.columnCheck, isPrimaryKey: columnInfo.isPrimaryKey, ddlSpelling: columnInfo.ddlSpelling, ddlDefault: columnInfo.ddlDefault, @@ -271,7 +289,8 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable { ddlSpelling: ddlSpelling, ddlDefault: ddlDefault, ddlGenerationExpression: ddlGenerationExpression, - ddlCollation: ddlCollation + ddlCollation: ddlCollation, + columnCheck: columnCheck ) } @@ -294,7 +313,8 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable { ddlDefault: ddlDefault, ddlGenerationExpression: ddlGenerationExpression, ddlCollation: ddlCollation, - classificationTypeName: classificationTypeName + classificationTypeName: classificationTypeName, + columnCheck: columnCheck ) } diff --git a/apple/TablePro/Models/Schema/CreateTableDraftBuilder.swift b/apple/TablePro/Models/Schema/CreateTableDraftBuilder.swift index de4f7b03e9..783d9ab282 100644 --- a/apple/TablePro/Models/Schema/CreateTableDraftBuilder.swift +++ b/apple/TablePro/Models/Schema/CreateTableDraftBuilder.swift @@ -6,58 +6,46 @@ import Foundation import TableProPluginKit -/// What a Create Table draft comes to: the statement plan it will run, and every row that is not -/// ready to run. +// The statements a Create Table draft will run, and every row that is not ready to. struct CreateTablePlan { - /// Never carries indexes. An index is always its own statement, see `CreateTableDraftBuilder`. + // Never carries indexes or check constraints: each is its own statement after the CREATE. let definition: PluginCreateTableDefinition? let indexes: [PluginIndexDefinition] let issues: [SchemaDraftIssue] - /// Nil writes no comment. Set after the `CREATE TABLE`, as its own statement. + // Nil writes no comment. Set after the CREATE, as its own statement. var tableComment: String? + var checkConstraints: [PluginCheckConstraintDefinition] = [] + // Working-list rows of `indexes` and `checkConstraints`, since blank and incomplete rows are skipped. + var indexRows: [Int] = [] + var checkConstraintRows: [Int] = [] var isReadyToCreate: Bool { definition != nil && issues.isEmpty } + + func indexRow(at position: Int) -> Int { + indexRows.indices.contains(position) ? indexRows[position] : position + } + + func checkConstraintRow(at position: Int) -> Int { + checkConstraintRows.indices.contains(position) ? checkConstraintRows[position] : position + } } -/// Turns the Create Table editor's working rows into a statement plan. -/// -/// This exists because the view used to do it inline, in three filters that each demanded a -/// non-empty `name` and then dropped whatever failed: -/// -/// foreignKeys: workingForeignKeys.filter { !$0.name.isEmpty && !$0.columns.isEmpty && … } -/// -/// A new foreign key row's name is always empty and nothing in the grid fills it, so every foreign -/// key the user entered was deleted on the way to the driver, with no message and an SQL Preview -/// that agreed. No dialect requires the name, three drivers discard it, and the filter left out the -/// predicate that does matter: an empty referenced column list renders `REFERENCES "t" ()`, which -/// SQLite, PostgreSQL and DuckDB all reject. -/// -/// So the split is: this type answers whether a row is **complete**, the driver answers how to -/// **spell** it, and `ForeignKeyDialect` answers what the engine's grammar takes. Three row states, -/// and the middle one is the whole point: -/// -/// - **blank**: nothing typed. Ignored, because the editor seeds a blank column and `+` seeds blank -/// rows, and neither is something the user asked to create. -/// - **incomplete**: begun and unusable. An issue the user is shown. Never a silent drop. -/// - **complete**: emitted. +// Turns the working rows into a plan. This type says whether a row is complete, the driver how to spell it, and +// `ForeignKeyDialect` what the engine's grammar takes. A blank row is ignored (the editor seeds them), a begun one +// that cannot run is an issue, never a silent drop: requiring a foreign key's name once dropped every key (#2691). @MainActor enum CreateTableDraftBuilder { - /// Indexes are never folded into the `CREATE TABLE` statement, on any engine. - /// - /// PostgreSQL and DuckDB used to append their `CREATE INDEX` statements to the same returned - /// string, and that only ran because `PQexec` and `duckdb_query` take a whole batch. SQLite's - /// driver prepares with `sqlite3_prepare_v2(db, sql, -1, &stmt, nil)` and steps once, so a - /// second statement in the string is compiled by nobody: the index would vanish exactly the way - /// the foreign keys did. Every index therefore goes through `generateAddIndexSQL` as its own - /// statement, which is also what the existing-table path in `SchemaStatementGenerator` does. + // Indexes and checks are never folded into the CREATE: SQLite steps one statement per execute. static func plan( tableName: String, options: CreateTableOptions, columns: [EditableColumnDefinition], indexes: [EditableIndexDefinition], foreignKeys: [EditableForeignKeyDefinition], + checkConstraints: [EditableCheckConstraintDefinition] = [], dialect: ForeignKeyDialect, includesEngineOptions: Bool, + defaults: CreateTableOptionDefaults = CreateTableOptionDefaults(), suppliesItsOwnKey: Bool = false ) -> CreateTablePlan { var issues: [SchemaDraftIssue] = [] @@ -68,16 +56,17 @@ enum CreateTableDraftBuilder { let resolvedForeignKeys = resolveForeignKeys( foreignKeys, columnNames: columnNames, dialect: dialect, into: &issues ) + let resolvedChecks = resolveCheckConstraints(checkConstraints, into: &issues) let trimmedName = tableName.trimmingCharacters(in: .whitespaces) if trimmedName.isEmpty { issues.append(SchemaDraftIssue( - tab: .columns, row: nil, message: String(localized: "The table needs a name.") + section: .columns, row: nil, message: String(localized: "The table needs a name.") )) } if resolvedColumns.isEmpty, !suppliesItsOwnKey { issues.append(SchemaDraftIssue( - tab: .columns, row: nil, + section: .columns, row: nil, message: String(localized: "The table needs at least one column with a name and a type.") )) } @@ -87,32 +76,33 @@ enum CreateTableDraftBuilder { } let primaryKeyColumns = resolvedColumns.filter(\.isPrimaryKey).map(\.name) + let characterSet = includesEngineOptions ? defaults.characterSetClause(for: options) : nil let definition = PluginCreateTableDefinition( tableName: trimmedName, columns: resolvedColumns.map { $0.toPlugin() }, indexes: [], foreignKeys: resolvedForeignKeys.map { $0.toPlugin() }, primaryKeyColumns: primaryKeyColumns, - engine: includesEngineOptions ? options.engine : nil, - charset: includesEngineOptions ? options.charset : nil, - collation: includesEngineOptions ? options.collation : nil, + engine: includesEngineOptions ? defaults.engineClause(for: options) : nil, + charset: characterSet?.charset, + collation: characterSet?.collation, ifNotExists: options.ifNotExists ) return CreateTablePlan( definition: definition, - indexes: resolvedIndexes.map { $0.toPlugin() }, + indexes: resolvedIndexes.map { $0.definition.toPlugin() }, issues: issues, - tableComment: ObjectCommentDraft.normalized(options.comment) + tableComment: ObjectCommentDraft.normalized(options.comment), + checkConstraints: resolvedChecks.map { $0.definition.toPlugin() }, + indexRows: resolvedIndexes.map(\.row), + checkConstraintRows: resolvedChecks.map(\.row) ) } // MARK: - Columns - /// A column the user marked auto-increment without ticking Primary Key is promoted here rather - /// than in each driver. The old code passed the promotion through `primaryKeyColumns` only, and - /// SQLite reads `isPrimaryKey` alone, so on SQLite that column got neither a primary key nor - /// `AUTOINCREMENT`. Reconciling the two once leaves every driver reading one answer. + // An auto-increment column with no primary key ticked is promoted here, once: SQLite reads `isPrimaryKey` alone. private static func resolveColumns( _ columns: [EditableColumnDefinition], into issues: inout [SchemaDraftIssue] @@ -128,19 +118,19 @@ enum CreateTableDraftBuilder { } if name.isEmpty { issues.append(SchemaDraftIssue( - tab: .columns, row: row, message: String(localized: "This column needs a name.") + section: .columns, row: row, message: String(localized: "This column needs a name.") )) continue } if type.isEmpty { issues.append(SchemaDraftIssue( - tab: .columns, row: row, message: String(localized: "This column needs a type.") + section: .columns, row: row, message: String(localized: "This column needs a type.") )) continue } if !column.isNullable, column.hasNullDefault { issues.append(SchemaDraftIssue( - tab: .columns, row: row, + section: .columns, row: row, message: String(localized: "This column does not allow NULL, so its default cannot be NULL.") )) } @@ -161,7 +151,7 @@ enum CreateTableDraftBuilder { for position in duplicatePositions(of: resolved.map(\.name)) { issues.append(SchemaDraftIssue( - tab: .columns, row: sourceRows[position], + section: .columns, row: sourceRows[position], message: String( format: String(localized: "Another column is already named %@."), resolved[position].name ) @@ -173,15 +163,13 @@ enum CreateTableDraftBuilder { // MARK: - Indexes - /// An index does need a name, unlike a foreign key: `CREATE INDEX` has nowhere to put an - /// anonymous one. So the missing name is reported rather than required in silence. + // Unlike a foreign key, an index needs a name: CREATE INDEX has nowhere to put an anonymous one. private static func resolveIndexes( _ indexes: [EditableIndexDefinition], columnNames: Set, into issues: inout [SchemaDraftIssue] - ) -> [EditableIndexDefinition] { - var resolved: [EditableIndexDefinition] = [] - var sourceRows: [Int] = [] + ) -> [Resolved] { + var resolved: [Resolved] = [] for (row, index) in indexes.enumerated() { let name = index.name.trimmingCharacters(in: .whitespaces) @@ -191,14 +179,14 @@ enum CreateTableDraftBuilder { } if columns.isEmpty { issues.append(SchemaDraftIssue( - tab: .indexes, row: row, + section: .indexes, row: row, message: String(localized: "This index needs at least one column.") )) continue } if name.isEmpty { issues.append(SchemaDraftIssue( - tab: .indexes, row: row, message: String(localized: "This index needs a name.") + section: .indexes, row: row, message: String(localized: "This index needs a name.") )) continue } @@ -207,22 +195,22 @@ enum CreateTableDraftBuilder { normalized.columns = columns if let unknown = normalized.referencedColumnNames.first(where: { !columnNames.contains($0) }) { issues.append(SchemaDraftIssue( - tab: .indexes, row: row, + section: .indexes, row: row, message: String( format: String(localized: "The table has no column named %@."), unknown ) )) continue } - resolved.append(normalized) - sourceRows.append(row) + resolved.append(Resolved(definition: normalized, row: row)) } - for position in duplicatePositions(of: resolved.map(\.name)) { + for position in duplicatePositions(of: resolved.map(\.definition.name)) { issues.append(SchemaDraftIssue( - tab: .indexes, row: sourceRows[position], + section: .indexes, row: resolved[position].row, message: String( - format: String(localized: "Another index is already named %@."), resolved[position].name + format: String(localized: "Another index is already named %@."), + resolved[position].definition.name ) )) } @@ -252,7 +240,7 @@ enum CreateTableDraftBuilder { let names = resolved.map(\.name) for position in duplicatePositions(of: names) where !names[position].isEmpty { issues.append(SchemaDraftIssue( - tab: .foreignKeys, row: sourceRows[position], + section: .foreignKeys, row: sourceRows[position], message: String( format: String(localized: "Another foreign key is already named %@."), resolved[position].name @@ -263,9 +251,7 @@ enum CreateTableDraftBuilder { return resolved } - /// The constraint name is deliberately absent from every test here. It is optional in every - /// dialect TablePro speaks, three drivers discard it, and demanding it is what deleted the - /// user's foreign keys. + // No test here demands the name: it is optional in every dialect, and demanding it deleted the user's keys. private static func resolveForeignKey( _ foreignKey: EditableForeignKeyDefinition, row: Int, @@ -292,7 +278,7 @@ enum CreateTableDraftBuilder { } func report(_ message: String) { - issues.append(SchemaDraftIssue(tab: .foreignKeys, row: row, message: message)) + issues.append(SchemaDraftIssue(section: .foreignKeys, row: row, message: message)) } if normalized.columns.isEmpty { @@ -342,8 +328,61 @@ enum CreateTableDraftBuilder { return normalized } + // MARK: - Check constraints + + // Every driver writes a check as ADD CONSTRAINT CHECK (), so both are required. + private static func resolveCheckConstraints( + _ constraints: [EditableCheckConstraintDefinition], + into issues: inout [SchemaDraftIssue] + ) -> [Resolved] { + var resolved: [Resolved] = [] + + for (row, constraint) in constraints.enumerated() { + let name = constraint.name.trimmingCharacters(in: .whitespaces) + let expression = constraint.expression.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty, expression.isEmpty { + continue + } + if expression.isEmpty { + issues.append(SchemaDraftIssue( + section: .checkConstraints, row: row, + message: String(localized: "This check constraint needs an expression.") + )) + continue + } + if name.isEmpty { + issues.append(SchemaDraftIssue( + section: .checkConstraints, row: row, + message: String(localized: "This check constraint needs a name.") + )) + continue + } + var normalized = constraint + normalized.name = name + normalized.expression = expression + resolved.append(Resolved(definition: normalized, row: row)) + } + + for position in duplicatePositions(of: resolved.map(\.definition.name)) { + issues.append(SchemaDraftIssue( + section: .checkConstraints, row: resolved[position].row, + message: String( + format: String(localized: "Another check constraint is already named %@."), + resolved[position].definition.name + ) + )) + } + + return resolved + } + // MARK: - Helpers + private struct Resolved { + let definition: Definition + let row: Int + } + private static func trimmedOrNil(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespaces), !trimmed.isEmpty else { return nil } return trimmed @@ -353,12 +392,8 @@ enum CreateTableDraftBuilder { values.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } } - /// Every position after the first that repeats a name already seen, so the row the user is most - /// likely still editing is the one flagged. - /// - /// These are positions in the resolved list, not grid rows. A row skipped for being blank or - /// incomplete shortens the list, so the caller maps them back through its own `sourceRows` or - /// the message points at a different row than the duplicate. + // Positions in the resolved list after the first that repeat a name, so the row likely still being edited is + // flagged. The caller maps them back to working rows: skipped rows shorten the list. private static func duplicatePositions(of names: [String]) -> [Int] { var seen: Set = [] var duplicates: [Int] = [] diff --git a/apple/TablePro/Models/Schema/CreateTableOptions.swift b/apple/TablePro/Models/Schema/CreateTableOptions.swift index 1abcbbf284..db47acb110 100644 --- a/apple/TablePro/Models/Schema/CreateTableOptions.swift +++ b/apple/TablePro/Models/Schema/CreateTableOptions.swift @@ -4,42 +4,69 @@ // import Foundation +import TableProPluginKit +// Nil engine, charset and collation write no clause, so the table takes what the server and its database give it. struct CreateTableOptions: Hashable { - var engine: String = "InnoDB" - var charset: String = "utf8mb4" - var collation: String = "utf8mb4_unicode_ci" + var engine: String? + var charset: String? + var collation: String? var ifNotExists: Bool = false var comment: String = "" - static let engines = [ - "InnoDB", "MyISAM", "MEMORY", "CSV", "ARCHIVE", - "BLACKHOLE", "MERGE", "FEDERATED", "NDB" - ] - - static let charsets = [ - "utf8mb4", "utf8mb3", "utf8", "latin1", "ascii", - "binary", "utf16", "utf32", "cp1251", "big5", - "euckr", "gb2312", "gbk", "sjis" - ] - - static let collations: [String: [String]] = [ - "utf8mb4": [ - "utf8mb4_unicode_ci", "utf8mb4_general_ci", "utf8mb4_bin", - "utf8mb4_0900_ai_ci", "utf8mb4_unicode_520_ci" - ], - "utf8mb3": ["utf8mb3_unicode_ci", "utf8mb3_general_ci", "utf8mb3_bin"], - "utf8": ["utf8_unicode_ci", "utf8_general_ci", "utf8_bin"], - "latin1": ["latin1_swedish_ci", "latin1_general_ci", "latin1_bin"], - "ascii": ["ascii_general_ci", "ascii_bin"], - "binary": ["binary"], - "utf16": ["utf16_unicode_ci", "utf16_general_ci", "utf16_bin"], - "utf32": ["utf32_unicode_ci", "utf32_general_ci", "utf32_bin"], - "cp1251": ["cp1251_general_ci", "cp1251_ukrainian_ci", "cp1251_bin"], - "big5": ["big5_chinese_ci", "big5_bin"], - "euckr": ["euckr_korean_ci", "euckr_bin"], - "gb2312": ["gb2312_chinese_ci", "gb2312_bin"], - "gbk": ["gbk_chinese_ci", "gbk_bin"], - "sjis": ["sjis_japanese_ci", "sjis_bin"], - ] + // A character set without a collation takes its default, and one from the previous set is an error (1253). + func selectingCharacterSet(_ charset: String?, collation: String?) -> CreateTableOptions { + var next = self + next.charset = charset + next.collation = collation + return next + } + + // Starts from what a table in the target database takes anyway, which the builder then leaves unwritten. + func seeded(with defaults: CreateTableOptionDefaults) -> CreateTableOptions { + var next = self + if next.charset == nil { + next.charset = defaults.characterSet + next.collation = defaults.collation + } + if next.engine == nil { + next.engine = defaults.engine + } + return next + } +} + +// What a new table in the target database takes when its statement names nothing: the database's default pair and +// the server's default engine. +struct CreateTableOptionDefaults: Hashable { + var characterSet: String? + var collation: String? + var engine: String? + + // The pair is written whole or not at all: COLLATE alone moves the charset, and CHARACTER SET alone resets the + // collation to the charset's default. + func characterSetClause(for options: CreateTableOptions) -> (charset: String, collation: String?)? { + guard let charset = options.charset?.nilIfEmpty else { return nil } + let collation = options.collation?.nilIfEmpty + if Self.matches(charset, characterSet), Self.matches(collation, self.collation) { + return nil + } + return (charset, collation) + } + + func engineClause(for options: CreateTableOptions) -> String? { + guard let engine = options.engine?.nilIfEmpty, !Self.matches(engine, self.engine) else { return nil } + return engine + } + + private static func matches(_ lhs: String?, _ rhs: String?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case (let lhs?, let rhs?): + return lhs.caseInsensitiveCompare(rhs) == .orderedSame + default: + return false + } + } } diff --git a/apple/TablePro/Models/Schema/ForeignKeyDialect.swift b/apple/TablePro/Models/Schema/ForeignKeyDialect.swift index fe1f2d3fce..ace0be0dad 100644 --- a/apple/TablePro/Models/Schema/ForeignKeyDialect.swift +++ b/apple/TablePro/Models/Schema/ForeignKeyDialect.swift @@ -51,7 +51,7 @@ struct ForeignKeyDialect: Equatable, Sendable { allowsQualifiedReferencedTable: true, allowsOmittedReferencedColumns: false ) - case .postgresql, .pglite, .cockroachdb, .redshift: + case .postgresql, .pglite, .cockroachdb, .redshift, .kingbasees: return ForeignKeyDialect( deleteActions: everyAction, updateActions: everyAction, diff --git a/apple/TablePro/Models/Schema/IndexKeyDialect.swift b/apple/TablePro/Models/Schema/IndexKeyDialect.swift index 26bd85e841..232fbda1ea 100644 --- a/apple/TablePro/Models/Schema/IndexKeyDialect.swift +++ b/apple/TablePro/Models/Schema/IndexKeyDialect.swift @@ -17,7 +17,7 @@ struct IndexKeyDialect: Equatable, Sendable { return IndexKeyDialect(takesPrefixLengths: true, takesExpressions: true) case .mariadb, .tidb, .oceanbase: return IndexKeyDialect(takesPrefixLengths: true, takesExpressions: false) - case .postgresql, .pglite, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb: + case .postgresql, .pglite, .kingbasees, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb: return IndexKeyDialect(takesPrefixLengths: false, takesExpressions: true) default: return .columnsOnly diff --git a/apple/TablePro/Models/Schema/ReadIdentifiedDefinition.swift b/apple/TablePro/Models/Schema/ReadIdentifiedDefinition.swift index 4de39dde90..418824191d 100644 --- a/apple/TablePro/Models/Schema/ReadIdentifiedDefinition.swift +++ b/apple/TablePro/Models/Schema/ReadIdentifiedDefinition.swift @@ -57,7 +57,7 @@ internal extension SchemaChange { return .modifyCheckConstraint(old: old.withoutIdentity(), new: new.withoutIdentity()) case .deleteCheckConstraint(let constraint): return .deleteCheckConstraint(constraint.withoutIdentity()) - case .modifyPrimaryKey, .modifyTableComment: + case .modifyPrimaryKey, .modifyTableComment, .modifyTableCharacterSet, .modifyAutoIncrement: return self } } diff --git a/apple/TablePro/Models/Schema/SchemaChange.swift b/apple/TablePro/Models/Schema/SchemaChange.swift index 54d87c4918..e99da8c518 100644 --- a/apple/TablePro/Models/Schema/SchemaChange.swift +++ b/apple/TablePro/Models/Schema/SchemaChange.swift @@ -27,6 +27,11 @@ enum SchemaChange: Hashable, Equatable { /// Nil on either side means no comment. case modifyTableComment(old: String?, new: String?) + /// The default character set and collation, or with `convertsColumns` every text column too. + case modifyTableCharacterSet(old: TableOptions, new: TableOptions) + /// Old is nil when the table had no AUTO_INCREMENT column when it was read. + case modifyAutoIncrement(old: UInt64?, new: UInt64) + var isDelete: Bool { switch self { case .deleteColumn, .deleteIndex, .deleteForeignKey, .deleteCheckConstraint: @@ -45,26 +50,95 @@ enum SchemaChange: Hashable, Equatable { // Renaming is one statement that touches no rows. Only a changed expression drops and // re-adds the constraint, which re-checks the whole table. return old.expression != new.expression + case .modifyTableCharacterSet(_, let new): + // A convert rewrites every text column, can widen TEXT and loses what the new set cannot hold. + return new.convertsColumns default: return false } } + // A new character set converts the stored text, and what it cannot hold is lost. A table default is nil. + private static func changesCharacterSet(from old: String?, to new: String?) -> Bool { + switch (old, new) { + case (nil, nil): + return false + case let (old?, new?): + return old.caseInsensitiveCompare(new) != .orderedSame + default: + return true + } + } + var requiresDataMigration: Bool { switch self { case .modifyColumn(let old, let new): return old.dataType != new.dataType || (old.isNullable && !new.isNullable) + || Self.changesCharacterSet(from: old.charset, to: new.charset) case .deleteColumn, .modifyPrimaryKey: return true case .addCheckConstraint: return true case .modifyCheckConstraint(let old, let new): return old.expression != new.expression + case .modifyTableCharacterSet(_, let new): + return new.convertsColumns default: return false } } + // What an error or a review shows a person; `description` stays English for logs. + var localizedDescription: String { + switch self { + case .addColumn(let col): + return String(format: String(localized: "Add column “%@”"), col.name) + case .modifyColumn(let old, let new): + return old.name == new.name + ? String(format: String(localized: "Change column “%@”"), new.name) + : String(format: String(localized: "Change column “%@” to “%@”"), old.name, new.name) + case .deleteColumn(let col): + return String(format: String(localized: "Delete column “%@”"), col.name) + case .addIndex(let idx): + return String(format: String(localized: "Add index “%@”"), idx.name) + case .modifyIndex(let old, let new): + return old.name == new.name + ? String(format: String(localized: "Change index “%@”"), new.name) + : String(format: String(localized: "Change index “%@” to “%@”"), old.name, new.name) + case .deleteIndex(let idx): + return String(format: String(localized: "Delete index “%@”"), idx.name) + case .addForeignKey(let fk): + return String(format: String(localized: "Add foreign key “%@”"), fk.name) + case .modifyForeignKey(let old, let new): + return old.name == new.name + ? String(format: String(localized: "Change foreign key “%@”"), new.name) + : String(format: String(localized: "Change foreign key “%@” to “%@”"), old.name, new.name) + case .deleteForeignKey(let fk): + return String(format: String(localized: "Delete foreign key “%@”"), fk.name) + case .addCheckConstraint(let constraint): + return String(format: String(localized: "Add check constraint “%@”"), constraint.name) + case .modifyCheckConstraint(let old, let new): + return old.name == new.name + ? String(format: String(localized: "Change check constraint “%@”"), new.name) + : String(format: String(localized: "Change check constraint “%@” to “%@”"), old.name, new.name) + case .deleteCheckConstraint(let constraint): + return String(format: String(localized: "Delete check constraint “%@”"), constraint.name) + case .modifyPrimaryKey(_, let new): + return new.isEmpty + ? String(localized: "Remove the primary key") + : String(format: String(localized: "Set the primary key to %@"), new.joined(separator: ", ")) + case .modifyTableComment: + return String(localized: "Change the table comment") + case .modifyTableCharacterSet(_, let new): + let target = [new.characterSet, new.collation].compactMap { $0 }.joined(separator: " ") + return new.convertsColumns + ? String(format: String(localized: "Convert the table to %@"), target) + : String(format: String(localized: "Set the table's default character set to %@"), target) + case .modifyAutoIncrement(_, let new): + return String(format: String(localized: "Set the next AUTO_INCREMENT value to %@"), String(new)) + } + } + var description: String { switch self { case .addColumn(let col): @@ -95,6 +169,13 @@ enum SchemaChange: Hashable, Equatable { return "Change primary key from [\(old.joined(separator: ", "))] to [\(new.joined(separator: ", "))]" case .modifyTableComment: return "Change comment on table" + case .modifyTableCharacterSet(_, let new): + let target = [new.characterSet, new.collation].compactMap { $0 }.joined(separator: " ") + return new.convertsColumns + ? "Convert table to \(target)" + : "Change default character set of table to \(target)" + case .modifyAutoIncrement(_, let new): + return "Set AUTO_INCREMENT of table to \(new)" } } } @@ -106,4 +187,6 @@ enum SchemaChangeIdentifier: Hashable { case checkConstraint(UUID) case primaryKey case tableComment + case tableCharacterSet + case autoIncrement } diff --git a/apple/TablePro/Models/Schema/SchemaDraftIssue.swift b/apple/TablePro/Models/Schema/SchemaDraftIssue.swift index 66dc0be6ff..ec986d6188 100644 --- a/apple/TablePro/Models/Schema/SchemaDraftIssue.swift +++ b/apple/TablePro/Models/Schema/SchemaDraftIssue.swift @@ -5,30 +5,23 @@ import Foundation -/// A row the user began and did not finish, named where the user can act on it. -/// -/// The distinction this type exists to draw is between a row that is blank, which is the editor's -/// own placeholder and carries no intent, and a row that holds some of what a constraint needs and -/// not the rest. The first is ignored. The second used to be dropped from the generated statement -/// with no message at all, which is how a filled-in foreign key came to vanish between the grid and -/// the SQL Preview (#2691). +// A row the user began and did not finish. A blank row carries no intent and is ignored; a begun one is named +// here, never dropped from the statement in silence (#2691). struct SchemaDraftIssue: Equatable, Identifiable, Sendable { - let tab: StructureTab - /// Zero-based position in the tab's grid, or nil for an issue about the draft as a whole. + let section: StructureSection + // Zero-based position in the section's working list, or nil for an issue about the draft as a whole. let row: Int? let message: String - var id: String { "\(tab.rawValue)-\(row.map(String.init) ?? "-")-\(message)" } + var id: String { "\(section.rawValue)-\(row.map(String.init) ?? "-")-\(message)" } - /// The message with its row and tab in front, for a reader who is looking at another tab. - /// - /// Rows are numbered from one here because that is how the grid's row gutter numbers them. + // Numbered from one, the way the grid's row gutter counts. var qualifiedMessage: String { guard let row else { - return String(format: String(localized: "%1$@: %2$@"), tab.displayName, message) + return String(format: String(localized: "%1$@: %2$@"), section.displayName, message) } return String( - format: String(localized: "%1$@ row %2$lld: %3$@"), tab.displayName, Int64(row + 1), message + format: String(localized: "%1$@ row %2$lld: %3$@"), section.displayName, Int64(row + 1), message ) } } diff --git a/apple/TablePro/Models/Schema/SchemaEntityOperations.swift b/apple/TablePro/Models/Schema/SchemaEntityOperations.swift index ef8919da49..36415b38b5 100644 --- a/apple/TablePro/Models/Schema/SchemaEntityOperations.swift +++ b/apple/TablePro/Models/Schema/SchemaEntityOperations.swift @@ -2,17 +2,10 @@ // SchemaEntityOperations.swift // TablePro // -// Describes one structure-editor entity kind to the generic staging path. -// import Foundation -/// What distinguishes one structure entity from another when it is staged. -/// -/// Columns, indexes, foreign keys and check constraints are staged by an identical sequence: -/// register an undo, record a pending change under the entity's identifier, then write the -/// working array. Only the arrays, the enum cases and the undo action names differ, so those -/// are supplied here and the sequence itself lives once in `StructureChangeManager`. +// One structure entity kind, for the staging sequence `StructureChangeManager` runs once for all four. struct SchemaEntityOperations where Entity.ID == UUID { let working: ReferenceWritableKeyPath let current: KeyPath @@ -26,4 +19,6 @@ struct SchemaEntityOperations where Entity.ID let addActionName: String let editActionName: String let deleteActionName: String + // Whether the working copy needs a statement; a column's key flag is the table's key change, not the column's. + var needsStatement: (_ loaded: Entity, _ working: Entity) -> Bool = { $0 != $1 } } diff --git a/apple/TablePro/Models/Schema/StructureTab.swift b/apple/TablePro/Models/Schema/StructureSection.swift similarity index 52% rename from apple/TablePro/Models/Schema/StructureTab.swift rename to apple/TablePro/Models/Schema/StructureSection.swift index 53aba1a111..1591be0043 100644 --- a/apple/TablePro/Models/Schema/StructureTab.swift +++ b/apple/TablePro/Models/Schema/StructureSection.swift @@ -1,28 +1,36 @@ // -// StructureTab.swift +// StructureSection.swift // TablePro // -// Tab selection for structure view -// import Foundation -/// Tab selection for structure view -enum StructureTab: String, CaseIterable, Hashable { +// The kinds of definition a table's structure stages edits for. Read-only panes are StructurePane. +enum StructureSection: String, CaseIterable, Hashable { case columns case indexes case foreignKeys case checkConstraints - case triggers - case ddl - case parts var displayName: String { switch self { case .columns: String(localized: "Columns") case .indexes: String(localized: "Indexes") case .foreignKeys: String(localized: "Foreign Keys") - case .checkConstraints: String(localized: "Constraints") + case .checkConstraints: String(localized: "Check Constraints") + } + } +} + +enum StructurePane: String, CaseIterable, Hashable { + case definition + case triggers + case ddl + case parts + + var displayName: String { + switch self { + case .definition: String(localized: "Definition") case .triggers: String(localized: "Triggers") case .ddl: "DDL" case .parts: "Parts" diff --git a/apple/TablePro/Models/Schema/TableOptions.swift b/apple/TablePro/Models/Schema/TableOptions.swift new file mode 100644 index 0000000000..626192dafa --- /dev/null +++ b/apple/TablePro/Models/Schema/TableOptions.swift @@ -0,0 +1,142 @@ +// +// TableOptions.swift +// TablePro +// + +import Foundation + +struct TableOptions: Hashable { + var characterSet: String? + var collation: String? + /// Also rewrites every text column, not only the default later columns take. A read never sets it. + var convertsColumns = false + /// The value the next row takes, nil when the table has no AUTO_INCREMENT column. + var autoIncrement: UInt64? + + /// Without the counter, so a counter edit never changes the staged character set change. + var characterSetOnly: TableOptions { + TableOptions(characterSet: characterSet, collation: collation, convertsColumns: convertsColumns) + } + + func adoptingCharacterSet(of written: TableOptions) -> TableOptions { + var copy = self + copy.characterSet = written.characterSet + copy.collation = written.collation + copy.convertsColumns = false + return copy + } +} + +/// A nil `original` means the read failed or the engine has none, so nothing stages. +struct TableOptionsDraft: Equatable { + let original: TableOptions? + var current: TableOptions? + /// False from a save that wrote the counter until a read confirms it, since the server may have raised it. + let isAutoIncrementConfirmed: Bool + + init(original: TableOptions?, isAutoIncrementConfirmed: Bool = true) { + var loaded = original + loaded?.convertsColumns = false + self.original = loaded + self.current = loaded + self.isAutoIncrementConfirmed = isAutoIncrementConfirmed + } + + var hasChanges: Bool { + characterSetChange != nil || autoIncrementChange != nil + } + + /// A convert to the table's own character set is still a change: it rewrites every column that differs. + var characterSetChange: SchemaChange? { + guard let original, let current else { return nil } + guard current.convertsColumns + || current.characterSet != original.characterSet + || current.collation != original.collation else { return nil } + return .modifyTableCharacterSet(old: original.characterSetOnly, new: current.characterSetOnly) + } + + var autoIncrementChange: SchemaChange? { + guard let original, let value = current?.autoIncrement, value != original.autoIncrement else { return nil } + return .modifyAutoIncrement(old: original.autoIncrement, new: value) + } +} + +struct TableCharacterSet: Hashable, Identifiable { + let name: String + let defaultCollation: String + let collations: [String] + let description: String? + + var id: String { name } +} + +/// What the server offers any table. Server-wide, so one read serves every table on the connection. +struct TableOptionsCatalog: Hashable { + let characterSets: [TableCharacterSet] + let engines: [String] + let defaultEngine: String? + + func characterSet(named name: String) -> TableCharacterSet? { + characterSets.first { $0.name.caseInsensitiveCompare(name) == .orderedSame } + } + + func collations(for characterSet: String) -> [String] { + self.characterSet(named: characterSet)?.collations ?? [] + } + + func defaultCollation(for characterSet: String) -> String? { + self.characterSet(named: characterSet)?.defaultCollation + } + + func characterSet(owning collation: String) -> TableCharacterSet? { + characterSets.first { candidate in + candidate.collations.contains { $0.caseInsensitiveCompare(collation) == .orderedSame } + } + } +} + +enum ColumnTextType { + /// National spellings are left out: MySQL 8.4 refuses `NVARCHAR(10) CHARACTER SET latin1` with ERROR 1064. + static func takesCharacterSet(_ dataType: String) -> Bool { + var words = baseWords(of: dataType) + if words.count > 1, words.last == "BINARY" { + words.removeLast() + } + return textSpellings.contains(words.joined(separator: " ")) + } + + private static func baseWords(of dataType: String) -> [String] { + let base = dataType.prefix { $0 != "(" } + return base.uppercased().split(whereSeparator: \.isWhitespace).map(String.init) + } + + private static let textSpellings: Set = [ + "CHAR", "CHARACTER", "VARCHAR", "CHARACTER VARYING", "CHAR VARYING", + "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT", "LONG", "LONG VARCHAR", + "ENUM", "SET" + ] +} + +enum AutoIncrementRange { + static func maximum(forColumnType dataType: String) -> UInt64? { + maximum(forColumnType: dataType, unsigned: false) + } + + /// `unsigned` is the column's own flag, for a type read without the word. + static func maximum(forColumnType dataType: String, unsigned: Bool) -> UInt64? { + let words = dataType.uppercased().split { $0.isWhitespace || $0 == "(" || $0 == ")" }.map(String.init) + guard let base = words.first else { return nil } + if base == "SERIAL" { return UInt64.max } + guard let bits = integerBits[base] else { return nil } + let isUnsigned = unsigned || words.contains("UNSIGNED") + return isUnsigned ? UInt64.max >> (64 - bits) : UInt64.max >> (65 - bits) + } + + private static let integerBits: [String: Int] = [ + "TINYINT": 8, "INT1": 8, "BOOL": 8, "BOOLEAN": 8, + "SMALLINT": 16, "INT2": 16, + "MEDIUMINT": 24, "INT3": 24, "MIDDLEINT": 24, + "INT": 32, "INTEGER": 32, "INT4": 32, + "BIGINT": 64, "INT8": 64 + ] +} diff --git a/apple/TablePro/Models/Settings/SQLFormatterSettings.swift b/apple/TablePro/Models/Settings/SQLFormatterSettings.swift index 9fa00e6e35..60470caeb8 100644 --- a/apple/TablePro/Models/Settings/SQLFormatterSettings.swift +++ b/apple/TablePro/Models/Settings/SQLFormatterSettings.swift @@ -45,6 +45,22 @@ struct SQLFormatterSettings: Codable, Equatable { return options } + // The keys 0.83 stored and synced, so a Mac on either build reads the other's settings. A rules file uses + // sql-formatter's names instead (`SQLFormatterRulesFile`). + enum RuleKeys: String, CodingKey, CaseIterable { + case indentStyle + case indentWidth + case keywordCase + case functionCase + case commaPosition + case lineWidth + case joinCondition + case alignAliases + case clauseItems + case logicalOperatorNewline + case dataTypeCase + } + private enum FollowKeys: String, CodingKey { case indentFollowsTabWidth case keywordCaseFollowsEditor @@ -52,7 +68,7 @@ struct SQLFormatterSettings: Codable, Equatable { // Lenient, like every synced settings blob: a value this build cannot read costs only that field. init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: SQLFormatterOptions.CodingKeys.self) + let container = try decoder.container(keyedBy: RuleKeys.self) let follow = try decoder.container(keyedBy: FollowKeys.self) let fallback = SQLFormatterOptions.default var rules = SQLFormatterOptions() @@ -72,6 +88,11 @@ struct SQLFormatterSettings: Codable, Equatable { ) rules.joinCondition = container.decodeSetting(.joinCondition, default: fallback.joinCondition) rules.alignAliases = container.decodeSetting(.alignAliases, default: fallback.alignAliases) + rules.clauseItems = container.decodeSetting(.clauseItems, default: fallback.clauseItems) + rules.logicalOperatorNewline = container.decodeSetting( + .logicalOperatorNewline, default: fallback.logicalOperatorNewline + ) + rules.dataTypeCase = container.decodeSetting(.dataTypeCase, default: fallback.dataTypeCase) self.rules = rules let defaults = Self.default indentFollowsTabWidth = follow.decodeSetting(.indentFollowsTabWidth, default: defaults.indentFollowsTabWidth) @@ -81,7 +102,18 @@ struct SQLFormatterSettings: Codable, Equatable { } func encode(to encoder: Encoder) throws { - try rules.encode(to: encoder) + var container = encoder.container(keyedBy: RuleKeys.self) + try container.encode(rules.indentStyle, forKey: .indentStyle) + try container.encode(rules.indentWidth, forKey: .indentWidth) + try container.encode(rules.keywordCase, forKey: .keywordCase) + try container.encode(rules.functionCase, forKey: .functionCase) + try container.encode(rules.commaPosition, forKey: .commaPosition) + try container.encode(rules.lineWidth, forKey: .lineWidth) + try container.encode(rules.joinCondition, forKey: .joinCondition) + try container.encode(rules.alignAliases, forKey: .alignAliases) + try container.encode(rules.clauseItems, forKey: .clauseItems) + try container.encode(rules.logicalOperatorNewline, forKey: .logicalOperatorNewline) + try container.encode(rules.dataTypeCase, forKey: .dataTypeCase) var follow = encoder.container(keyedBy: FollowKeys.self) try follow.encode(indentFollowsTabWidth, forKey: .indentFollowsTabWidth) try follow.encode(keywordCaseFollowsEditor, forKey: .keywordCaseFollowsEditor) diff --git a/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift b/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift index 018b3f3f6e..5d6741568a 100644 --- a/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift +++ b/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift @@ -32,6 +32,12 @@ internal enum SidebarObjectListPresentation: Equatable { /// Reaching for the state enum directly instead is what left the tree with a spinner on no gate /// at all, flashing it on every engine that groups by schema while the two flat shapes beside /// it held theirs back. + /// What the list stands on while a loaded catalog waits for its turn: the load still reads as in flight. + internal static func presentedState(_ state: SchemaState, showsLoaded: Bool) -> SchemaState { + if case .loaded = state, !showsLoaded { return .loading } + return state + } + internal static func resolveDeferringEmptyStates( state: SchemaState, hasOutlastedGrace: Bool = true diff --git a/apple/TablePro/Models/UI/DataGridConfiguration.swift b/apple/TablePro/Models/UI/DataGridConfiguration.swift index e6313ae64d..3f05e7c2b8 100644 --- a/apple/TablePro/Models/UI/DataGridConfiguration.swift +++ b/apple/TablePro/Models/UI/DataGridConfiguration.swift @@ -45,4 +45,10 @@ struct DataGridConfiguration: Equatable { // VoiceOver's name for the grid ("Active Sessions"); nil reads "Data grid". var accessibilityLabel: String? + + // As tall as its rows, inside a page that scrolls vertically for it; the grid scrolls only sideways. + var fitsRows = false + + // Off where an empty cell means "none set", as a structure list's Default or Comment does, not an empty string. + var marksEmptyText = true } diff --git a/apple/TablePro/Models/UI/FieldEditorKind.swift b/apple/TablePro/Models/UI/FieldEditorKind.swift index 5de538e35e..4386adc89b 100644 --- a/apple/TablePro/Models/UI/FieldEditorKind.swift +++ b/apple/TablePro/Models/UI/FieldEditorKind.swift @@ -21,11 +21,6 @@ internal enum FieldEditorKind: Equatable { /// a one-line value or a JSON document, which are two different editors over the same list. case arrayElements(element: ArrayElementEditor, values: [String]) case vector - case typePicker - /// A value with suggestions the user may ignore: the menu writes correct SQL, typing writes - /// your own. `enumPicker` cannot serve, because its list is the whole set of legal values. - case valuePicker(options: [GridMenuOption]) - case schemaText case multiLine case singleLine } diff --git a/apple/TablePro/Models/UI/GridSelectionOwner.swift b/apple/TablePro/Models/UI/GridSelectionOwner.swift index b20f998971..9a212ceb91 100644 --- a/apple/TablePro/Models/UI/GridSelectionOwner.swift +++ b/apple/TablePro/Models/UI/GridSelectionOwner.swift @@ -33,7 +33,7 @@ internal enum GridSelectionOwner: Equatable { // Map selects through the same model: a clicked shape selects its row. return .dataGrid case .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource, .versionHistory, - .tableListing: + .tableListing, .routineDebug: return .none } } diff --git a/apple/TablePro/Models/UI/InspectorFieldLayout.swift b/apple/TablePro/Models/UI/InspectorFieldLayout.swift deleted file mode 100644 index 5119772e49..0000000000 --- a/apple/TablePro/Models/UI/InspectorFieldLayout.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// InspectorFieldLayout.swift -// TablePro -// - -import Foundation - -/// How a field arranges its label against its editor. -/// -/// The arrangement follows whose labels these are, not how big the editor is. A data row's label is -/// a column name, which is user data of no bounded length: measured against a realistic 400-column -/// set, the widest renders at 259.8pt, wider than the whole pane at its 270pt minimum. No label lane -/// can hold that, and a lane wide enough to try takes the width the value needs, so at the minimum a -/// side-by-side data row truncates the name *and* the value. A schema row's label comes from a -/// closed, authored vocabulary (`StructureColumnField.displayName` plus the index, foreign-key and -/// check-constraint headers), whose widest member is "Ref Columns" at 66.4pt, so the lane Apple's own -/// inspectors use is both correct and achievable there. -/// -/// Nothing here can be derived at runtime from the rows themselves. Measured: a `PreferenceKey` -/// max-reduce inside a `List` sees only the realized rows, 39pt against a true widest of 296pt, so a -/// content-derived lane would change width as the user scrolls. A custom `AlignmentID` does not -/// resolve across a `List`'s rows at all (145pt of spread; the same guide in one `VStack` gives 0). -/// `Form(.columns)` and `Grid` do align, and both forfeit the laziness `InspectorFieldListView` -/// depends on. -/// -/// Both switches are exhaustive on purpose rather than `default`-armed. A new editor kind has to be -/// classified for each provenance instead of inheriting whichever arm was written first; two -/// `default`-armed switches over `FieldEditorKind` are what let `.typePicker` escape the value-font -/// domain and render a structure row's Name and Type in two different fonts. -internal enum InspectorFieldLayout: Equatable { - /// Label in a fixed trailing-aligned lane, editor beside it, one line high. - case inline - /// Label and type above, editor spanning the full width below. - case stacked - - internal static func resolve(for kind: FieldEditorKind, isSchemaField: Bool) -> InspectorFieldLayout { - isSchemaField ? schemaLayout(for: kind) : dataLayout(for: kind) - } - - /// A data field always stacks, whatever it holds. Choosing per row from the value's own length - /// would give the pane two label lanes and two value lanes alternating down the list, drop the - /// type badge from exactly the rows too narrow to carry it, and reflow a row from one shape to - /// the other while the user is typing into it. - private static func dataLayout(for kind: FieldEditorKind) -> InspectorFieldLayout { - switch kind { - case .singleLine, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .schemaText, .typePicker, - .valuePicker, .multiLine, .json, .phpSerialized, .binaryValue, .blobHex, .image, .geometry: - return .stacked - } - } - - /// A schema field's label is short and authored, so the lane holds it and the row stays one line - /// high. The editors that need the pane's width still take it. - private static func schemaLayout(for kind: FieldEditorKind) -> InspectorFieldLayout { - switch kind { - case .singleLine, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .schemaText, .typePicker, - .valuePicker: - return .inline - case .multiLine, .json, .phpSerialized, .binaryValue, .blobHex, .image, .geometry: - return .stacked - } - } -} diff --git a/apple/TablePro/Models/UI/InspectorRowSource.swift b/apple/TablePro/Models/UI/InspectorRowSource.swift deleted file mode 100644 index e819603517..0000000000 --- a/apple/TablePro/Models/UI/InspectorRowSource.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// InspectorRowSource.swift -// TablePro -// -// Lets the grid that owns the current selection supply the inspector's -// fields and receive its edits, instead of the inspector assuming every -// selection indexes the data tab's rows. -// - -import Foundation - -internal struct InspectorRowField: Equatable { - let name: String - let value: String? - let editor: FieldEditorKind - let isModified: Bool - let isEditable: Bool - - init(name: String, value: String?, editor: FieldEditorKind, isModified: Bool = false, isEditable: Bool = true) { - self.name = name - self.value = value - self.editor = editor - self.isModified = isModified - self.isEditable = isEditable - } -} - -internal struct InspectorRow: Equatable { - let fields: [InspectorRowField] - let isEditable: Bool -} - -@MainActor -internal protocol InspectorRowSource: AnyObject { - func inspectorRow(atDisplayRow displayRow: Int) -> InspectorRow? - func commitInspectorField(displayRow: Int, fieldIndex: Int, value: String?) -} diff --git a/apple/TablePro/Models/UI/InspectorSubject.swift b/apple/TablePro/Models/UI/InspectorSubject.swift index cb1df8b970..71403965d7 100644 --- a/apple/TablePro/Models/UI/InspectorSubject.swift +++ b/apple/TablePro/Models/UI/InspectorSubject.swift @@ -5,22 +5,13 @@ import Foundation -/// What the inspector is currently inspecting, and the two lines it draws for it above its content. -/// -/// The pane had no subject at all before: it multiplexed three unrelated tabs, so there was nothing -/// one title could name and the header carried a picker instead. Naming the subject is what lets -/// the inspector say which row of which table is on screen, which is the first thing a reader of an -/// inspector needs and the thing the old pane never showed. -/// -/// A schema grid is a first-class case rather than an afterthought. The structure and create-table -/// editors feed the same pane through `InspectorRowSource`, and their selection is a column -/// definition, not a row of data: it has no position in a result and no row identity, so a subject -/// that assumed one would render "Row 0 of 0" over a perfectly good column. +// What the inspector is inspecting, as the two lines it draws above its content. A structure definition has no +// position in a result, so it is named by what it is and whose it is, never "Row 0 of 0". internal enum InspectorSubject: Equatable { case empty case tableRow(table: String, position: RowPosition?) case multipleRows(table: String, count: Int) - case columnDefinition(column: String, table: String?) + case definition(name: String, section: StructureSection, table: String?) case tableOnly(table: String) internal struct RowPosition: Equatable { @@ -33,20 +24,18 @@ internal enum InspectorSubject: Equatable { } } - /// The bold first line: the thing being inspected. internal var title: String? { switch self { case .empty: return nil case .tableRow(let table, _), .multipleRows(let table, _), .tableOnly(let table): return table - case .columnDefinition(let column, _): - return column + case .definition(let name, let section, _): + return name.isEmpty ? Self.newDefinitionTitle(section) : name } } - /// The secondary second line: where the thing sits. Nil where there is nothing useful to add, - /// so the header collapses to one line rather than drawing an empty one. + // Nil where there is nothing to add, so the header collapses to one line. internal var subtitle: String? { switch self { case .empty: @@ -60,11 +49,38 @@ internal enum InspectorSubject: Equatable { ) case .multipleRows(_, let count): return String(format: String(localized: "%d rows selected"), count) - case .columnDefinition(_, let table): - guard let table else { return String(localized: "Column") } - return String(format: String(localized: "Column of %@"), table) + case .definition(_, let section, let table): + guard let table else { return Self.kindName(section) } + return Self.kindName(section, of: table) case .tableOnly: return String(localized: "Table") } } + + private static func newDefinitionTitle(_ section: StructureSection) -> String { + switch section { + case .columns: String(localized: "New Column") + case .indexes: String(localized: "New Index") + case .foreignKeys: String(localized: "New Foreign Key") + case .checkConstraints: String(localized: "New Check Constraint") + } + } + + private static func kindName(_ section: StructureSection) -> String { + switch section { + case .columns: String(localized: "Column") + case .indexes: String(localized: "Index") + case .foreignKeys: String(localized: "Foreign Key") + case .checkConstraints: String(localized: "Check Constraint") + } + } + + private static func kindName(_ section: StructureSection, of table: String) -> String { + switch section { + case .columns: String(format: String(localized: "Column of %@"), table) + case .indexes: String(format: String(localized: "Index of %@"), table) + case .foreignKeys: String(format: String(localized: "Foreign Key of %@"), table) + case .checkConstraints: String(format: String(localized: "Check Constraint of %@"), table) + } + } } diff --git a/apple/TablePro/Models/UI/KeyboardShortcutModels.swift b/apple/TablePro/Models/UI/KeyboardShortcutModels.swift index b707d86815..79b0e75513 100644 --- a/apple/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/apple/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -40,6 +40,7 @@ enum ShortcutCategory: String, Codable, CaseIterable, Identifiable { case dataGrid case navigation case connections + case debug case app var id: String { rawValue } @@ -50,6 +51,7 @@ enum ShortcutCategory: String, Codable, CaseIterable, Identifiable { case .dataGrid: return String(localized: "Data Grid") case .navigation: return String(localized: "Navigation") case .connections: return String(localized: "Connections") + case .debug: return String(localized: "Debug") case .app: return String(localized: "App") } } @@ -181,6 +183,13 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case showPreviousWorkspace case showNextWorkspace + // Debug, listed only while the Routine Debugger module is on + case debugContinue + case debugStepOver + case debugStepInto + case debugStepOut + case toggleBreakpoint + var id: String { rawValue } var category: ShortcutCategory { @@ -215,9 +224,28 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .showPreviousWindowTab, .showNextWindowTab, .toggleWorkspaceRail, .showPreviousWorkspace, .showNextWorkspace: return .navigation + case .debugContinue, .debugStepOver, .debugStepInto, .debugStepOut, .toggleBreakpoint: + return .debug } } + var module: AppModule? { + Self.routineDebuggerActions.contains(self) ? .routineDebugger : nil + } + + private static let routineDebuggerActions: Set = [ + .debugContinue, .debugStepOver, .debugStepInto, .debugStepOut, .toggleBreakpoint, + ] + + // An action of a module that is off has no row, no menu item and no say in a conflict. + func isAvailable(enabledModules: Set) -> Bool { + module.map { enabledModules.contains($0) } ?? true + } + + static func settingsRows(in category: ShortcutCategory, enabledModules: Set) -> [ShortcutAction] { + allCases.filter { $0.category == category && $0.isAvailable(enabledModules: enabledModules) } + } + /// A command that dispatches to whichever surface holds focus must be `.global`, or the /// conflict resolver treats its combo as free in the other context and AppKit blanks one of /// the two menu items that end up claiming it. The three find commands all route that way. @@ -375,6 +403,11 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .aiReviewQuery: return String(localized: "Review with AI") case .aiExplainQuery: return String(localized: "Explain with AI") case .aiOptimizeQuery: return String(localized: "Optimize with AI") + case .debugContinue: return String(localized: "Continue") + case .debugStepOver: return String(localized: "Step Over") + case .debugStepInto: return String(localized: "Step Into") + case .debugStepOut: return String(localized: "Step Out") + case .toggleBreakpoint: return String(localized: "Toggle Breakpoint") } } } @@ -526,6 +559,9 @@ struct KeyboardSettings: Codable, Equatable { continue } guard override == key, other.context.overlaps(action.context) else { continue } + if let module = other.module, !AppModule.storedEnabledModules().contains(module) { + continue + } return other } return nil @@ -538,8 +574,16 @@ struct KeyboardSettings: Codable, Equatable { /// Find a conflicting action for the given combo within an overlapping /// context, excluding the specified action. func findConflict(for key: BoundKey, excluding action: ShortcutAction) -> ShortcutAction? { + findConflict(for: key, excluding: action, enabledModules: AppModule.storedEnabledModules()) + } + + func findConflict( + for key: BoundKey, + excluding action: ShortcutAction, + enabledModules: Set + ) -> ShortcutAction? { guard !key.isCleared else { return nil } - for other in ShortcutAction.allCases where other != action { + for other in ShortcutAction.allCases where other != action && other.isAvailable(enabledModules: enabledModules) { guard action.context.overlaps(other.context), let otherKey = shortcut(for: other), !otherKey.isCleared, otherKey == key else { continue } @@ -715,7 +759,13 @@ struct KeyboardSettings: Codable, Equatable { .switchToLeastRecentTab: .special(.tab, shift: true, control: true), .toggleWorkspaceRail: .character("0", command: true, option: true), .showPreviousWorkspace: .special(.upArrow, command: true, control: true), - .showNextWorkspace: .special(.downArrow, command: true, control: true) + .showNextWorkspace: .special(.downArrow, command: true, control: true), + + // Xcode's keys. Toggle Breakpoint ships unbound: current macOS lists Command-Backslash as a system hotkey, which the recorder refuses. + .debugContinue: .character("y", command: true, control: true), + .debugStepOver: .special(.f6), + .debugStepInto: .special(.f7), + .debugStepOut: .special(.f8) ] } diff --git a/apple/TablePro/Models/UI/MultiRowEditState.swift b/apple/TablePro/Models/UI/MultiRowEditState.swift index 294d697f48..b4c2c95517 100644 --- a/apple/TablePro/Models/UI/MultiRowEditState.swift +++ b/apple/TablePro/Models/UI/MultiRowEditState.swift @@ -21,15 +21,9 @@ struct FieldEditState: Identifiable { var isPrimaryKey: Bool = false var isForeignKey: Bool = false - // Set when the owning grid dictates the editor instead of the column type. - var editor: FieldEditorKind? - // Resolved once: it costs a JSONSerialization and a PHP-serialized parse, and view bodies re-parsed it on every hover. var resolvedEditor: FieldEditorKind? - // A schema field has no data type, so it offers no type badge and no NULL or DEFAULT state. - var isSchemaField: Bool = false - // Shown without an editor: refusing the edit further down left a pending value here that Save never writes. var isServerOwned: Bool = false @@ -46,7 +40,7 @@ struct FieldEditState: Identifiable { var declaredType: String? - // Recorded by the grid owning a schema row, or the change manager for rows that disagree, which no pending value can hold. + // Recorded by the change manager for rows that disagree, which no pending value can hold. var hasCommittedEdit: Bool = false var originalValue: String? @@ -124,8 +118,6 @@ final class MultiRowEditState: ObservableObject { // The rows an edit is staged against, captured when the selection was configured, never a display position. @Published private(set) var rowIDs: [RowID] = [] - // The schema row the fields came from, for a grid whose rows have no identity. - private var schemaDisplayRow: Int? @Published private(set) var allRows: [[String?]] = [] // Per row of allRows: the columns it has no field for. @@ -177,9 +169,8 @@ final class MultiRowEditState: ObservableObject { cellWriter: CellWriterSupport = .none ) { let columnsChanged = self.columns != columns - let selectionChanged = self.rowIDs != rowIDs || schemaDisplayRow != nil + let selectionChanged = self.rowIDs != rowIDs - schemaDisplayRow = nil self.rowIDs = rowIDs self.allRows = allRows self.absentCells = absentCells @@ -332,7 +323,7 @@ final class MultiRowEditState: ObservableObject { let reveals = AppSettingsManager.shared.dataGrid.showInvisibleCharacters guard reveals != revealsInvisibleCharacters else { return } revealsInvisibleCharacters = reveals - for index in fields.indices where !fields[index].isSchemaField && fields[index].editor == nil { + for index in fields.indices { let field = fields[index] let resolved = FieldEditorResolver.resolve( for: field.columnTypeEnum, @@ -348,54 +339,6 @@ final class MultiRowEditState: ObservableObject { } } - // Field ids survive a reconfigure of the same row so a commit does not rebuild the editors and drop focus. - func configure(schemaFields: [InspectorRowField], displayRow: Int) { - let names = schemaFields.map(\.name) - let reusedIds = schemaDisplayRow == displayRow && columns == names ? fields.map(\.id) : [] - - schemaDisplayRow = displayRow - rowIDs = [] - absentCells = [] - unloadedCells = [] - expressionCells = [] - databaseType = nil - cellWriter = .none - rowsAreInserted = false - columns = names - columnTypes = Array(repeating: .text(rawType: nil), count: names.count) - allRows = [schemaFields.map(\.value)] - - fields = schemaFields.enumerated().map { index, field in - var state = FieldEditState( - columnIndex: index, - columnName: field.name, - columnTypeEnum: .text(rawType: nil), - isLongText: false, - isJson: false, - editor: field.editor, - isSchemaField: true, - hasCommittedEdit: field.isModified, - originalValue: field.value, - hasMultipleValues: false, - pendingValue: nil, - isPendingNull: false, - isPendingDefault: false - ) - if index < reusedIds.count { - state.id = reusedIds[index] - } - if field.editor == nil { - state.resolvedEditor = FieldEditorResolver.resolve( - for: .text(rawType: nil), - isLongText: false, - originalValue: field.value - ) - } - state.isServerOwned = !field.isEditable - return state - } - } - // Read from the store, not a copy an earlier render captured; a field's value binding answers with this. func currentText(at index: Int) -> String { guard fields.indices.contains(index) else { return "" } @@ -408,7 +351,7 @@ final class MultiRowEditState: ObservableObject { } func setValueContext(for field: FieldEditState, isWritable: Bool) -> SetValueContext? { - guard let databaseType, !field.isSchemaField else { return nil } + guard let databaseType else { return nil } return SetValueContext( columnType: field.columnTypeEnum, isNullable: field.isNullable, @@ -647,7 +590,6 @@ final class MultiRowEditState: ObservableObject { onFieldReverted = nil onFieldRemoved = nil onDetachedFieldChanged = nil - schemaDisplayRow = nil rowIDs = [] allRows = [] absentCells = [] diff --git a/apple/TablePro/Models/UI/PendingChangeKind.swift b/apple/TablePro/Models/UI/PendingChangeKind.swift index b065c3cda9..93e2626fc7 100644 --- a/apple/TablePro/Models/UI/PendingChangeKind.swift +++ b/apple/TablePro/Models/UI/PendingChangeKind.swift @@ -5,17 +5,8 @@ import Foundation -/// What the window's Save command would commit, which is also whether it can commit at all. -/// -/// One value rather than five booleans read in five places. `updateToolbarPendingState()` folded -/// four of the five sources into `hasPendingChanges` and never read the fifth, so a Users & Roles -/// tab with staged principals left both the toolbar's commit button and Cmd+S dim while -/// `saveChanges()` already carried the branch that would have applied them. -/// -/// The tab decides the kind, because two kinds can be staged at once and only one of them is the -/// one the user is looking at. It decides nothing the user reads: the commit control's label is -/// `ToolbarContextResolver.commitVerb(for:)`, from the tab kind alone, because this value comes and -/// goes with every edit and a label that followed it moved the titlebar while the user typed. +// What the window's Save would commit, and whether it can commit at all. The tab decides the kind, since two kinds +// can be staged at once; the commit label comes from the tab kind alone, so it does not move while the user types. internal enum PendingChangeKind: Equatable, Hashable, Sendable { case data case structure @@ -23,7 +14,7 @@ internal enum PendingChangeKind: Equatable, Hashable, Sendable { case principals case file - /// Nil when nothing is staged for this tab, which is what leaves the commit control dim. + // Nil when nothing is staged for this tab, which leaves the commit control dim. internal static func resolve( tabType: TabType?, hasDataChanges: Bool, @@ -41,12 +32,12 @@ internal enum PendingChangeKind: Equatable, Hashable, Sendable { } switch tabType { case .createTable: - /// A definition that is not yet committable is not a pending change: the tab's own - /// validity gate is what `hasCreateTablePending` already answers. + // A definition that is not yet committable is not a pending change. return hasCreateTablePending ? .createTable : nil case .usersRoles: return hasPrincipalChanges ? .principals : nil - case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing: + case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing, + .routineDebug: return contentKind( hasDataChanges: hasDataChanges, hasStructureChanges: hasStructureChanges, @@ -55,8 +46,24 @@ internal enum PendingChangeKind: Equatable, Hashable, Sendable { } } - /// Structure outranks data, and data outranks a dirty file, because a structure edit rewrites - /// the table the staged rows are going into and has to be named first. + // Narrower than the commit: a dirty file and staged principals have no SQL to show, and a structure session + // previews only from its own Structure view, so staged ALTERs behind the Data view leave Preview with nothing. + internal static func offersPreview( + of kind: PendingChangeKind?, + hasDataChanges: Bool, + previewsSchemaChanges: Bool + ) -> Bool { + switch kind { + case .data, .createTable: + return true + case .structure: + return hasDataChanges || previewsSchemaChanges + case .principals, .file, nil: + return false + } + } + + // A structure edit rewrites the table the staged rows go into, so it is named before data, and data before a file. private static func contentKind( hasDataChanges: Bool, hasStructureChanges: Bool, diff --git a/apple/TablePro/Models/UI/RowInspectorContext.swift b/apple/TablePro/Models/UI/RowInspectorContext.swift index 7a1eb23bd1..8ebe1f73d2 100644 --- a/apple/TablePro/Models/UI/RowInspectorContext.swift +++ b/apple/TablePro/Models/UI/RowInspectorContext.swift @@ -5,41 +5,25 @@ import Foundation -/// Everything the inspector draws, and nothing else. -/// -/// This used to be one `InspectorContext` carrying the row, the JSON snapshot and a ten-row text -/// summary of the grid for the assistant, so a selection change rebuilt the assistant's context and -/// an assistant change rebuilt the row's. Splitting them along the surfaces that read them is what -/// lets each be `Equatable`: the old struct held an untyped `[(column:value:type:)]` tuple array, -/// which no equality could be written for, so every rebuild was a change whether or not anything -/// had moved. -/// -/// The fields themselves are not here. They live in `MultiRowEditState`, which both the data grid -/// and the schema grid already configure, and duplicating them into a second array is what made the -/// old tuple path dead computation: it ran the display-format and blob-format pipeline over every -/// cell on every tick and the view read only whether it was nil. +// Everything the inspector draws. The assistant's context is separate, so a selection change never rebuilds it. +// The row's fields are not here: they live in `MultiRowEditState`. internal struct RowInspectorContext: Equatable { internal let subject: InspectorSubject - /// Whether a row is selected at all, which the mode choice turns on. Separate from the subject - /// because a subject exists for a table with no row selected. + // Separate from the subject: a subject exists for a table with no row selected. internal let hasRow: Bool internal let isEditable: Bool internal let isRowDeleted: Bool - /// Present only on a tab that actually has a table. The old context handed over the - /// coordinator's one metadata slot unconditionally, latest-wins and cleared only on teardown, so - /// a query tab, a dashboard or an ER diagram showed the statistics of whichever table had been - /// opened last, including after its tab was closed. + // Only on a tab that has a table, so a query tab never shows the statistics of the last table opened. internal let tableMetadata: TableMetadata? - /// The same row the fields mode shows, as raw cell values, because the JSON mode decides from - /// the column's own type whether a value prints quoted. + // Raw cell values, because the JSON rendering quotes a value by its column's own type. internal let jsonRow: JSONRowSnapshot? - /// The table a structure row's type picker offers user-defined types for. Nil outside one. - internal let userDefinedTypeScope: DatabaseScope? + // Set while a Structure view or a Create Table tab is on screen; the inspector then edits its definitions. + internal var structure: StructureInspectorSource? internal static let empty = RowInspectorContext( subject: .empty, @@ -48,14 +32,25 @@ internal struct RowInspectorContext: Equatable { isRowDeleted: false, tableMetadata: nil, jsonRow: nil, - userDefinedTypeScope: nil + structure: nil ) } -/// What the assistant needs to know about the window it is docked in. -/// -/// Kept apart from the row context so that moving the selection does not rebuild it, and so that a -/// window whose assistant was never revealed never pays for the grid summary at all. +// The structure editor on screen, compared by identity. The inspector observes the editor itself, so a selection +// or an edit redraws it without a new context. +internal enum StructureInspectorSource: Equatable { + case table(StructureEditingSession) + case createTable(CreateTableDraft) + + internal static func == (lhs: StructureInspectorSource, rhs: StructureInspectorSource) -> Bool { + switch (lhs, rhs) { + case (.table(let left), .table(let right)): left === right + case (.createTable(let left), .createTable(let right)): left === right + default: false + } + } +} + internal struct AssistantContext: Equatable { internal let currentQuery: String? internal let queryResults: String? diff --git a/apple/TablePro/Models/UI/ToolbarContext.swift b/apple/TablePro/Models/UI/ToolbarContext.swift index b4900b9818..f4267409e4 100644 --- a/apple/TablePro/Models/UI/ToolbarContext.swift +++ b/apple/TablePro/Models/UI/ToolbarContext.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit /// Everything the connection window's toolbar is allowed to know about what the window is showing. /// @@ -55,6 +56,12 @@ internal struct ToolbarContext: Equatable { /// A type's tab queues changes the commit control saves; every other object tab only reads. internal let stagesObjectChanges: Bool + // Whether the Routine Debugger module puts its controls in the toolbar at all. + internal let showsDebugItems: Bool + // What the selected debug tab can do now; empty and false everywhere else. + internal let debugCommands: Set + internal let canStopDebugging: Bool + /// The subset of the context that may move an item in or out of the titlebar, or change what /// one says. /// @@ -75,6 +82,7 @@ internal struct ToolbarContext: Equatable { internal let supportsServerDashboard: Bool internal let isAIEnabled: Bool internal var stagesObjectChanges = false + internal var showsDebugItems = false } internal var visibilityKey: VisibilityKey { @@ -87,7 +95,8 @@ internal struct ToolbarContext: Equatable { supportsImport: supportsImport, supportsServerDashboard: supportsServerDashboard, isAIEnabled: isAIEnabled, - stagesObjectChanges: stagesObjectChanges + stagesObjectChanges: stagesObjectChanges, + showsDebugItems: showsDebugItems ) } @@ -111,7 +120,10 @@ internal struct ToolbarContext: Equatable { supportsImport: Bool = false, supportsServerDashboard: Bool = false, isAIEnabled: Bool = false, - stagesObjectChanges: Bool = false + stagesObjectChanges: Bool = false, + showsDebugItems: Bool = false, + debugCommands: Set = [], + canStopDebugging: Bool = false ) { self.tabKind = tabKind self.resultsMode = resultsMode @@ -133,6 +145,9 @@ internal struct ToolbarContext: Equatable { self.supportsServerDashboard = supportsServerDashboard self.isAIEnabled = isAIEnabled self.stagesObjectChanges = stagesObjectChanges + self.showsDebugItems = showsDebugItems + self.debugCommands = debugCommands + self.canStopDebugging = canStopDebugging } /// The whole context over a key the caller already computed, so the eight slow-moving facts @@ -149,7 +164,9 @@ internal struct ToolbarContext: Equatable { canAddRow: Bool, canRestorePreviousValues: Bool, canNavigateBack: Bool, - canNavigateForward: Bool + canNavigateForward: Bool, + debugCommands: Set = [], + canStopDebugging: Bool = false ) { self.init( tabKind: key.tabKind, @@ -171,7 +188,10 @@ internal struct ToolbarContext: Equatable { supportsImport: key.supportsImport, supportsServerDashboard: key.supportsServerDashboard, isAIEnabled: key.isAIEnabled, - stagesObjectChanges: key.stagesObjectChanges + stagesObjectChanges: key.stagesObjectChanges, + showsDebugItems: key.showsDebugItems, + debugCommands: debugCommands, + canStopDebugging: canStopDebugging ) } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index adfb8a9735..f9c6cadf13 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -194,6 +194,10 @@ }, "Add one row to a table on a saved connection. Provide the row as a JSON object or a CSV row." : { + }, + "Add plugin_debugger to shared_preload_libraries and restart the server." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Add rows to ${table}" : { @@ -451,6 +455,9 @@ }, "No foreign key links %@ to the tables before it, so it is cross joined." : { + }, + "No Foreign Keys" : { + }, "No row holds the values it was loaded with. Another client may have changed or deleted it." : { @@ -472,6 +479,10 @@ }, "Only Left" : { + }, + "Only PL/pgSQL functions and procedures can be debugged." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Only Right" : { @@ -637,6 +648,22 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "TablePro can't pass any value of type %2$@ to %1$@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro can't read %@. Check that this account can see its source." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro can't read this unit's source." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro can't turn text into a %2$@ value for %1$@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "TablePro signs RSA keys with rsa-sha2-256 or rsa-sha2-512, not with ssh-rsa, which uses SHA-1." : { "extractionState" : "manual", "generatesSymbol" : false @@ -675,6 +702,9 @@ }, "The changes were saved, but the type could not be read back. Reload before editing it again." : { + }, + "The changes you have not saved will be lost." : { + }, "The client certificate and key could not be written to a temporary file." : { "extractionState" : "manual", @@ -973,6 +1003,9 @@ } } } + }, + "-- Error generating SQL: %@" : { + }, "-- Once the rows are in" : { "localizations" : { @@ -1825,6 +1858,10 @@ } } }, + "“%@” isn't a port or a range of ports, such as 4000-4999." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "“%@” now holds the current result of its query." : { "localizations" : { "fr" : { @@ -3172,6 +3209,9 @@ } } } + }, + "%@ (default)" : { + }, "%@ (entry point %@)" : { "localizations" : { @@ -3203,6 +3243,10 @@ } } }, + "%@ (specification)" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "%@ (You)" : { "localizations" : { "fr" : { @@ -4004,6 +4048,9 @@ } } } + }, + "%@ DDL" : { + }, "%@ does not accept this column change." : { "localizations" : { @@ -4287,6 +4334,14 @@ }, "%@ has no column to create." : { + }, + "%@ has no default value." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "%@ has no public procedure or function to start from." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "%@ holds a document or an array, and this text is not valid JSON." : { "extractionState" : "manual", @@ -4878,6 +4933,10 @@ } } }, + "%@ isn't compiled for debugging." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "%@ lines have unreadable characters" : { "localizations" : { "fr" : { @@ -4927,6 +4986,9 @@ } } } + }, + "%@ May Still Be Running" : { + }, "%@ more, in the script beside this" : { "localizations" : { @@ -5781,6 +5843,12 @@ } } } + }, + "%@ runs in a compatibility mode with column types of its own, so structures can be compared but no script is generated." : { + + }, + "%@ runs in a compatibility mode with column types of its own. Copy to a target of the same type in the same mode." : { + }, "%@ s" : { "extractionState" : "stale", @@ -9331,9 +9399,15 @@ } } } + }, + "%d changes to this table's structure will be lost." : { + }, "%d characters" : { + }, + "%d Check Constraints Selected" : { + }, "%d columns" : { "localizations" : { @@ -9374,6 +9448,9 @@ } } } + }, + "%d Columns Selected" : { + }, "%d connected" : { "localizations" : { @@ -9820,6 +9897,9 @@ } } } + }, + "%d Foreign Keys Selected" : { + }, "%d included" : { "localizations" : { @@ -9860,6 +9940,9 @@ } } } + }, + "%d Indexes Selected" : { + }, "%d item(s) affected" : { "extractionState" : "manual", @@ -15946,6 +16029,10 @@ } } }, + "A connection came from %@, which isn't the database server's address, so TablePro closed it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "A connection can use only one connection method at a time. Enabled: %@." : { "localizations" : { "fr" : { @@ -16117,6 +16204,14 @@ } } }, + "A DBA can grant it with GRANT DEBUG CONNECT SESSION TO %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "A DBA can grant it with GRANT DEBUG ON %1$@ TO %2$@, or grant DEBUG ANY PROCEDURE." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "a destructive statement" : { "localizations" : { "fr" : { @@ -17899,6 +17994,13 @@ } } } + }, + "A user with ALTER SYSTEM can find its session in V$SESSION and end it with ALTER SYSTEM KILL SESSION." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "A value at or below the highest existing ID becomes that ID plus one." : { + }, "A value could not be written as JSON." : { "localizations" : { @@ -19898,6 +20000,9 @@ } } } + }, + "Add check constraint “%@”" : { + }, "Add Column" : { "localizations" : { @@ -19938,6 +20043,9 @@ } } } + }, + "Add column “%@”" : { + }, "Add Column…" : { "localizations" : { @@ -20421,6 +20529,9 @@ } } } + }, + "Add foreign key “%@”" : { + }, "Add Global Index" : { "extractionState" : "manual", @@ -20515,6 +20626,9 @@ } } } + }, + "Add index “%@”" : { + }, "Add indexes to improve query performance on frequently searched columns" : { "localizations" : { @@ -21233,6 +21347,10 @@ } } }, + "Address the database server can reach this Mac at" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Adds a narrow strip on the window's leading edge listing every connection and database you have open, so one click switches to it." : { "localizations" : { "fr" : { @@ -25321,6 +25439,9 @@ }, "And %lld more" : { + }, + "AND and OR:" : { + }, "And one that will run after this session ends:\n\n%@" : { @@ -25438,6 +25559,9 @@ } } } + }, + "Another check constraint is already named %@." : { + }, "Another column is already named %@." : { "localizations" : { @@ -26516,6 +26640,9 @@ } } } + }, + "Applies to columns added later. Existing columns keep their character set." : { + }, "Applies to grid cells, the inspector, cell popovers and the row diff." : { @@ -28148,6 +28275,10 @@ } } }, + "Ask a superuser to run CREATE EXTENSION pldbgapi in this database." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Ask about step %d" : { "localizations" : { "fr" : { @@ -29960,6 +30091,8 @@ } }, "Automatic" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -31635,6 +31768,12 @@ "Bell" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Below the keyword" : { + + }, + "Beside the keyword" : { + }, "Betas get new features first. Turning this off keeps the beta you have until a newer release ships." : { @@ -32280,6 +32419,14 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "Breakpoint, line %d" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Breakpoints" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Briefcase" : { "extractionState" : "manual", "generatesSymbol" : false @@ -33396,6 +33543,9 @@ "Cake" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Call Stack" : { + }, "Calling" : { "localizations" : { @@ -33556,6 +33706,12 @@ } } } + }, + "Can't Debug %@" : { + + }, + "Can't List Routines" : { + }, "Can't List Tables" : { "localizations" : { @@ -35636,6 +35792,32 @@ } } }, + "Change check constraint “%@”" : { + + }, + "Change check constraint “%@” to “%@”" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change check constraint “%1$@” to “%2$@”" + } + } + } + }, + "Change column “%@”" : { + + }, + "Change column “%@” to “%@”" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change column “%1$@” to “%2$@”" + } + } + } + }, "Change File" : { "extractionState" : "stale", "localizations" : { @@ -35717,6 +35899,19 @@ } } }, + "Change foreign key “%@”" : { + + }, + "Change foreign key “%@” to “%@”" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change foreign key “%1$@” to “%2$@”" + } + } + } + }, "Change Host to a name the certificate covers." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -35729,6 +35924,19 @@ } } }, + "Change index “%@”" : { + + }, + "Change index “%@” to “%@”" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change index “%1$@” to “%2$@”" + } + } + } + }, "Change Not Saved" : { "localizations" : { "fr" : { @@ -35978,6 +36186,9 @@ } } } + }, + "Change the table comment" : { + }, "Change this in Settings > Integrations." : { "extractionState" : "stale", @@ -36344,6 +36555,14 @@ } } }, + "Changes the procedure makes are committed when it commits or finishes. Stop rolls back what it hasn't committed." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Changes the routine makes are committed when it finishes. Stop rolls them back." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Changes Unavailable" : { }, @@ -37055,6 +37274,9 @@ } } } + }, + "Check Constraint" : { + }, "Check constraint must have a name and an expression" : { "localizations" : { @@ -37065,6 +37287,12 @@ } } } + }, + "Check Constraint of %@" : { + + }, + "Check Constraints" : { + }, "Check constraints need %@ or later." : { "extractionState" : "manual", @@ -37725,6 +37953,9 @@ } } } + }, + "Choose a character set first" : { + }, "Choose a connection" : { "localizations" : { @@ -38609,6 +38840,10 @@ } } }, + "Choose DBMS_DEBUG as the protocol." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Choose Destination…" : { "localizations" : { "fr" : { @@ -38769,6 +39004,10 @@ } } }, + "Choose other Debugger Ports, or DBMS_DEBUG as the protocol." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Choose port automatically" : { "localizations" : { "fr" : { @@ -39391,6 +39630,9 @@ } } } + }, + "Clause items:" : { + }, "Clear" : { "localizations" : { @@ -43287,6 +43529,10 @@ } } }, + "Collection" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Collection %@ no longer exists." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -43311,6 +43557,14 @@ } } }, + "Collection of %d elements" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Collection of 1 element" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "collections" : { "localizations" : { "fr" : { @@ -46136,6 +46390,10 @@ } } }, + "Compile for Debug" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Complete Sign In" : { "localizations" : { "fr" : { @@ -49932,6 +50190,9 @@ } } } + }, + "Convert existing columns" : { + }, "Convert line break to space" : { "extractionState" : "stale", @@ -50055,6 +50316,12 @@ } } } + }, + "Convert the table to %@" : { + + }, + "Converting the table's character set rewrites every text column and loses characters the new one cannot hold." : { + }, "Coordination & Config" : { "localizations" : { @@ -52472,6 +52739,9 @@ } } } + }, + "Could not build the column reorder for this table. If this engine's driver was installed before column reorder shipped, update it in Settings > Extensions." : { + }, "Could not build the column reorder for this table. If this engine's driver was installed before column reorder shipped, update it in Settings > Plugins." : { "localizations" : { @@ -52525,6 +52795,9 @@ }, "Could not build the query that matches rows" : { + }, + "Could not build the table rebuild for this change. If this engine's driver was installed before foreign key editing shipped, update it in Settings > Extensions." : { + }, "Could not build the table rebuild for this change. If this engine's driver was installed before foreign key editing shipped, update it in Settings > Plugins." : { "localizations" : { @@ -54555,6 +54828,9 @@ }, "Couldn't Duplicate the Theme" : { + }, + "Couldn't Export Formatter Rules" : { + }, "Couldn't Export the Theme" : { @@ -54742,6 +55018,12 @@ } } } + }, + "Couldn't read the character sets" : { + + }, + "Couldn't Read the DDL" : { + }, "Couldn't read the referenced table" : { "localizations" : { @@ -54752,6 +55034,18 @@ } } } + }, + "Couldn't read the saved value." : { + + }, + "Couldn't read the server's character sets." : { + + }, + "Couldn't read the table options" : { + + }, + "Couldn't Read the Triggers" : { + }, "Couldn't Refresh Materialized View" : { "localizations" : { @@ -55859,6 +56153,9 @@ } } } + }, + "Create Table cannot add a check constraint on this database." : { + }, "Create Table cannot add an index on this database." : { "localizations" : { @@ -57710,6 +58007,8 @@ } }, "Data" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -58180,6 +58479,9 @@ }, "Data type" : { + }, + "Data type case:" : { + }, "data type negotiation" : { "extractionState" : "manual", @@ -60259,6 +60561,10 @@ } } }, + "DBMS_DEBUG_JDWP needs a Debugger Host for the database to connect back to." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Deactivate" : { "localizations" : { "fr" : { @@ -60459,6 +60765,60 @@ } } }, + "Debug" : { + + }, + "Debug %@" : { + + }, + "Debug it from a routine that calls it with arguments only this overload takes." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debug it from a routine that calls it with that value." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debug Routine" : { + + }, + "Debug Routine…" : { + + }, + "Debug Session Ended" : { + + }, + "Debug…" : { + + }, + "Debugger Host" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugger Ports" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugging %@ needs the DEBUG privilege on it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugging needs the DEBUG CONNECT SESSION privilege." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugging through DBMS_DEBUG instead." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugging through DBMS_DEBUG_JDWP. The database connected to TablePro at %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugging through DBMS_DEBUG." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Decimal" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -60630,6 +60990,9 @@ } } } + }, + "Decrease Font Size" : { + }, "Decrypt" : { "localizations" : { @@ -61910,6 +62273,9 @@ } } } + }, + "Delete check constraint “%@”" : { + }, "Delete Column" : { "localizations" : { @@ -61950,6 +62316,9 @@ } } } + }, + "Delete column “%@”" : { + }, "Delete Columns" : { "localizations" : { @@ -62223,6 +62592,9 @@ } } } + }, + "Delete foreign key “%@”" : { + }, "Delete Group" : { "localizations" : { @@ -62313,6 +62685,9 @@ } } } + }, + "Delete index “%@”" : { + }, "Delete is off by default, so a first run cannot remove a row from the target." : { "localizations" : { @@ -69819,6 +70194,9 @@ }, "Edit Settings" : { + }, + "Edit Table Options" : { + }, "Edit Sequence" : { @@ -71793,6 +72171,9 @@ }, "Engines that match function names by exact case, such as ClickHouse, keep the case you typed." : { + }, + "Engines that match type names by exact case, such as ClickHouse, keep the case you typed." : { + }, "Enter a _key for the new row. Bigtable has no generated keys." : { "extractionState" : "manual", @@ -71983,6 +72364,9 @@ } } } + }, + "Enter a whole number from 1 to %@." : { + }, "Enter an absolute path, as it appears on the SSH server." : { "localizations" : { @@ -84191,6 +84575,9 @@ } } } + }, + "Foreign Key" : { + }, "Foreign key %@ matches %@ on the target but the names differ." : { "localizations" : { @@ -84238,6 +84625,10 @@ } } }, + "Foreign key enforcement could not be put back while a transaction is open." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Foreign key must have at least one column, a referenced table, and a referenced column" : { "localizations" : { "fr" : { @@ -84247,6 +84638,9 @@ } } } + }, + "Foreign Key of %@" : { + }, "Foreign key points at a column that does not exist: %@" : { "localizations" : { @@ -86407,6 +86801,14 @@ } } }, + "Give %1$@ a value. Left at its default, the call also matches another overload of %2$@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Give %1$@ values. Left at their defaults, the call also matches another overload of %2$@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Give its full path in the connection. Apps launched from the Dock do not inherit your shell's PATH." : { "localizations" : { "fr" : { @@ -90562,6 +90964,10 @@ } } }, + "If the server reaches this Mac through NAT or a load balancer, choose DBMS_DEBUG as the protocol." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "If the server stopped answering at the network encryption step, set Network Encryption to Rejected on the Advanced tab and connect again." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -93281,6 +93687,9 @@ } } } + }, + "Increase Font Size" : { + }, "Increment" : { @@ -93496,6 +93905,9 @@ } } } + }, + "Index of %@" : { + }, "Index proposal on %@" : { "localizations" : { @@ -93672,6 +94084,8 @@ } }, "Indexes" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -95142,6 +95556,10 @@ }, "Install the %@ plugin?" : { + }, + "Install the pldebugger package for this PostgreSQL version on the server, then run CREATE EXTENSION pldbgapi in this database." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Installed" : { "localizations" : { @@ -97066,6 +97484,10 @@ } } }, + "Its server session may still be running and holding its locks." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Its structure could not be read." : { "localizations" : { "fr" : { @@ -97078,6 +97500,10 @@ }, "Its uncommitted changes are discarded." : { + }, + "Its uncommitted work rolls back when it ends." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Its WHERE clause is not supported here, so it covers every row." : { "localizations" : { @@ -97089,6 +97515,10 @@ } } }, + "JDWP error %d." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Join condition:" : { }, @@ -98976,6 +99406,10 @@ }, "This sequence's settings could not be read, so they cannot be changed here." : { + }, + "This server can't show the default value of %@, so enter a value for it." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "keyword: %@" : { "localizations" : { @@ -99177,6 +99611,10 @@ } } }, + "KingbaseES in %@ mode is not supported. Connect to a server in PostgreSQL or Oracle mode." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "kubectl port-forward" : { "localizations" : { "fr" : { @@ -101983,6 +102421,10 @@ } } }, + "Line %1$d of %2$@ has no code to stop at." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Line %1$d: %2$@" : { "localizations" : { "fr" : { @@ -102046,6 +102488,9 @@ } } } + }, + "Line %d" : { + }, "Line break" : { "extractionState" : "manual", @@ -106417,6 +106862,9 @@ } } } + }, + "Marked for deletion. Undo or Undo Delete brings it back." : { + }, "Match" : { "localizations" : { @@ -113081,6 +113529,12 @@ } } } + }, + "New Check Constraint" : { + + }, + "New Column" : { + }, "New Connection" : { "localizations" : { @@ -113464,6 +113918,9 @@ } } } + }, + "New Foreign Key" : { + }, "New Group" : { "localizations" : { @@ -113559,6 +114016,9 @@ } } } + }, + "New Index" : { + }, "New Jump Host" : { "localizations" : { @@ -114302,6 +114762,15 @@ "Newspaper" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Next Auto Increment" : { + + }, + "Next Auto Increment must be between 1 and %@" : { + + }, + "Next Auto Increment needs a column with AUTO_INCREMENT" : { + }, "Next cursor: %@. The scan has not finished." : { "extractionState" : "manual", @@ -114951,6 +115420,9 @@ }, "No build of this plugin works with this version of TablePro." : { + }, + "No Call Stack" : { + }, "No cells changed." : { "localizations" : { @@ -115161,6 +115633,9 @@ } } } + }, + "No Columns" : { + }, "No Columns Defined" : { "localizations" : { @@ -116144,6 +116619,9 @@ } } } + }, + "No DDL Available" : { + }, "No DDL found for %@." : { "extractionState" : "manual", @@ -116157,6 +116635,10 @@ } } }, + "No debugger host is set, so the database can't connect back to TablePro. Debugging through DBMS_DEBUG." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "No default" : { "localizations" : { "fr" : { @@ -116757,6 +117239,9 @@ } } } + }, + "No Indexes" : { + }, "No Indexes Defined" : { "localizations" : { @@ -117889,6 +118374,9 @@ } } } + }, + "No Output" : { + }, "No parts found" : { "localizations" : { @@ -118012,6 +118500,10 @@ } } }, + "No port in the Debugger Ports range is free on this Mac." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "No primary key. Choose key columns to compare this table." : { "localizations" : { "fr" : { @@ -120369,6 +120861,9 @@ } } } + }, + "No Variables" : { + }, "No Vault address. Enter one in the credential profile, or set VAULT_ADDR in TablePro's environment." : { @@ -122017,6 +122512,10 @@ } } }, + "Not readable" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Not readable with your permissions" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -122889,6 +123388,9 @@ } } } + }, + "Nothing to Debug" : { + }, "Nothing to Draw" : { "localizations" : { @@ -125739,6 +126241,10 @@ } } }, + "Only the owner of %@ or a superuser can debug it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Only use the bootstrap address" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -128799,6 +129305,10 @@ } } }, + "Or choose DBMS_DEBUG as the protocol, which needs no ACL entry." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Oracle" : { "extractionState" : "stale", "localizations" : { @@ -128844,6 +129354,10 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "Oracle can't tell the overloads apart without it (PLS-00307), and TablePro can't read the default to pass it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Oracle returned no definition for view '%@'." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -128994,6 +129508,10 @@ } } }, + "Other sessions using the package lose its state, and their next call to it fails once with ORA-04068." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Other work ran on this connection before you confirmed. Check the transaction and try again." : { }, @@ -131044,6 +131562,10 @@ } } }, + "Pass NULL or leave it at its default." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Passphrase" : { "localizations" : { "fr" : { @@ -132096,6 +132618,20 @@ } } }, + "Paused at line %d" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Paused at line %d in %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Paused at line %1$d in %2$@" + } + } + } + }, "Paw Print" : { "extractionState" : "manual", "generatesSymbol" : false @@ -134468,6 +135004,9 @@ } } } + }, + "PostgreSQL and Oracle compatible SQL" : { + }, "PostgreSQL custom archive" : { "localizations" : { @@ -135028,6 +135567,9 @@ } } } + }, + "Preferred tries TLS first, falls back to plain." : { + }, "Preferred tries TLS first, falls back to plain. Matches psql and DataGrip defaults. Required by AWS RDS, Cloud SQL, Heroku, Supabase, Neon." : { "localizations" : { @@ -136961,6 +137503,8 @@ } }, "Procedure" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -137929,6 +138473,10 @@ } } }, + "Protocol" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "protocol negotiation" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -142424,6 +142972,12 @@ }, "Reading the files…" : { + }, + "Reading the saved value…" : { + + }, + "Reading the server's character sets…" : { + }, "Reading the source…" : { "localizations" : { @@ -144222,6 +144776,9 @@ } } } + }, + "Refreshing reads the table structure again and discards the changes you have not saved." : { + }, "Refreshing will discard all unsaved changes." : { "localizations" : { @@ -145239,6 +145796,9 @@ } } } + }, + "Remove All Breakpoints" : { + }, "Remove All Filters" : { "localizations" : { @@ -146310,6 +146870,14 @@ } } }, + "Remove SET client_min_messages from the routine to debug it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Remove SET transaction_timeout from the routine to debug it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Remove tag %@" : { "comment" : "A label for a button that removes a tag from a list of selected tags. The argument is the name of the tag to be removed.", "isCommentAutoGenerated" : true, @@ -146403,6 +146971,9 @@ } } } + }, + "Remove the primary key" : { + }, "Remove the second entry." : { "extractionState" : "manual", @@ -150826,6 +151397,9 @@ } } } + }, + "Rewrites every text column to this character set. TEXT can grow to MEDIUMTEXT, and the table is copied while it runs." : { + }, "right curly bracket" : { "extractionState" : "manual", @@ -151315,6 +151889,9 @@ } } } + }, + "Routine Debugger" : { + }, "Routine name" : { "localizations" : { @@ -151366,6 +151943,10 @@ } } }, + "Routines with polymorphic or record parameters can't be debugged." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Routines, sorted by qualified name" : { "localizations" : { "fr" : { @@ -152404,6 +152985,9 @@ } } } + }, + "Rows in %1$@ break the foreign key %2$@." : { + }, "Rows in %@ changed after they were compared. Compare again before generating the script." : { "localizations" : { @@ -153990,6 +154574,9 @@ } } } + }, + "Running…" : { + }, "Runs a command you write. It must open the local port itself." : { "localizations" : { @@ -154315,6 +154902,9 @@ } } } + }, + "Safe Mode is read-only for this connection." : { + }, "Safe mode level" : { "localizations" : { @@ -155069,6 +155659,9 @@ } } } + }, + "Save column and primary key changes before converting existing columns." : { + }, "Save Comparison…" : { "localizations" : { @@ -159335,6 +159928,9 @@ }, "Select a column" : { + }, + "Select a definition to edit it" : { + }, "Select a dump file this engine's own tool wrote." : { "localizations" : { @@ -160069,6 +160665,9 @@ } } } + }, + "Select one to edit it" : { + }, "Select Plugin" : { "localizations" : { @@ -161408,6 +162007,14 @@ } } }, + "Server process %d may still be running and holding its locks." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Server process %d may still be waiting for a debugger and holding its locks. To end it, run SELECT pldbg_attach_to_port(%d) and pass the number it returns to pldbg_abort_target." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Server response: %@" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -161968,6 +162575,14 @@ } } }, + "Session SID %1$d, SERIAL# %2$d. %3$@ A user with ALTER SYSTEM can end it now with %4$@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Session SID %d. %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Session state" : { "localizations" : { "fr" : { @@ -162311,6 +162926,14 @@ } } }, + "Set Debugger Host to an address the database server can reach this Mac at, or choose DBMS_DEBUG as the protocol." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Set Debugger Host to an address the server can reach, or choose DBMS_DEBUG as the protocol." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Set DEFAULT" : { "localizations" : { "fr" : { @@ -162528,6 +163151,15 @@ } } } + }, + "Set the next AUTO_INCREMENT value to %@" : { + + }, + "Set the primary key to %@" : { + + }, + "Set the table's default character set to %@" : { + }, "Set to NULL" : { "localizations" : { @@ -173063,6 +173695,18 @@ } } } + }, + "Step Into" : { + + }, + "Step Out" : { + + }, + "Step Over" : { + + }, + "Step through PL/pgSQL and PL/SQL routines with breakpoints." : { + }, "Stop" : { "extractionState" : "manual", @@ -173315,6 +173959,9 @@ } } } + }, + "Stop Debugging" : { + }, "Stop Export?" : { "localizations" : { @@ -173645,6 +174292,10 @@ }, "Stopped." : { + }, + "Stopped. The routine's changes were rolled back." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Stopping…" : { "extractionState" : "stale", @@ -173973,6 +174624,8 @@ } }, "Structure" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -177240,6 +177893,9 @@ } } } + }, + "Table Default" : { + }, "Table Description" : { "extractionState" : "manual", @@ -178562,6 +179218,10 @@ } } }, + "TablePro compiles it again with debug information (PLSQL_OPTIMIZE_LEVEL=1) and keeps its other settings. Compiling commits." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "TablePro connects to the first broker over a tunnel. Partitions led by other brokers cannot be read." : { "localizations" : { "fr" : { @@ -178767,6 +179427,10 @@ }, "TablePro couldn't check which rows in other tables reference these rows." : { + }, + "TablePro couldn't confirm that the routine ended. It may still be running on the server." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "TablePro couldn't reach the plugin registry to update this plugin. Check your connection and reopen TablePro." : { "localizations" : { @@ -182829,6 +183493,10 @@ }, "The branch and its upstream branch each have commits the other lacks. Finish this pull in a Git client." : { + }, + "The breakpoint on line %1$d of %2$@ moved to line %3$d, the next line with code." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "The broker answered a coordinator lookup with no coordinator." : { "extractionState" : "manual", @@ -183243,6 +183911,9 @@ } } } + }, + "The change to this table's structure will be lost." : { + }, "The changes were edited while the save was checking other tables. Save again to include them." : { @@ -183330,6 +184001,10 @@ } } }, + "The CHECK on column %@ names column %@, which this save renames. Save the rename on its own first." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The cache must be at least 1." : { }, @@ -183963,6 +184638,10 @@ } } }, + "The connection from the database didn't complete the debugger handshake." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The connection has no current DuckDB catalog to read metadata from" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -184322,6 +185001,9 @@ } } } + }, + "The connection was closed or reconnected while the debugger was starting. Start it again." : { + }, "The connection was closed." : { "localizations" : { @@ -184741,6 +185423,18 @@ } } }, + "The database answered the debugger with data TablePro can't read." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The database couldn't reach TablePro at %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The database didn't connect to TablePro at %@ in time." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "the database file" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -184815,6 +185509,10 @@ } } }, + "The database isn't allowed to connect to TablePro at %@ (ORA-24247). A DBA can allow it with DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE and the jdwp privilege." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The database name is longer than the 128 bytes FreeTDS takes." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -184953,6 +185651,44 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "The database's debugger refused the request." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The database's debugger sent data TablePro can't read, so the session ended." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The database's debugger stopped answering, so the session ended." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The debug session has ended." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The debug session's connection closed." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The debugger couldn't finish this step." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The debugger couldn't start." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The debugger didn't confirm the stop. The routine may still be running on the server." : { + + }, + "The debugger stopped for a reason TablePro doesn't know." : { + + }, + "The debugger's session was interrupted while it waited, so the routine was stopped and its uncommitted changes were rolled back. A cancel or the server's transaction_timeout can cause this." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The declared length is kept for reference but is not enforced." : { "localizations" : { "fr" : { @@ -186074,6 +186810,9 @@ } } } + }, + "The installed %1$@ does not support %2$@. Update it in Settings > Extensions." : { + }, "The installed %1$@ does not support %2$@. Update it in Settings > Plugins." : { @@ -187594,6 +188333,14 @@ } } }, + "The PL/pgSQL debugger can't step out of a routine." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The PL/pgSQL debugger isn't installed in “%@”." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The plan setting could not be turned off again, so the connection was closed. Reconnect to go on." : { "extractionState" : "manual", "generatesSymbol" : false @@ -188327,6 +189074,9 @@ } } } + }, + "The query is larger than %@, too large to format." : { + }, "The query names its own output format, %@. Remove its FORMAT clause to read it as rows." : { "extractionState" : "manual", @@ -188646,6 +189396,29 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "The routine couldn't be stopped on the server." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The routine finished before the stop reached it." : { + + }, + "The routine is still running on the server. DBMS_DEBUG can't interrupt it between breakpoints." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The routine isn't paused." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The routine raised an error. Its changes were rolled back." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The routine was stopped, and its uncommitted changes were rolled back." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The row filter on %@ was not a single expression, so every row was exported." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -189418,6 +190191,10 @@ }, "The server computes this column, so it is left out." : { + }, + "The server couldn't start the debugger." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "The server did not accept the client certificate. %@" : { "extractionState" : "manual", @@ -191040,6 +191817,9 @@ } } } + }, + "The table is being read again after the save." : { + }, "The table is no longer reachable." : { "localizations" : { @@ -193187,6 +193967,10 @@ } } }, + "This call matches more than one overload of %@, so Oracle can't run it (PLS-00307)." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This change needs %@ to be recreated, and it cannot be recreated from the definition the database stored for it. Virtual tables, such as full-text search tables, are the usual reason." : { "localizations" : { "fr" : { @@ -193206,6 +193990,12 @@ } } } + }, + "This check constraint needs a name." : { + + }, + "This check constraint needs an expression." : { + }, "This client does not have access to that connection." : { "localizations" : { @@ -193381,6 +194171,13 @@ } } }, + "This connection can't open the separate sessions the debugger needs." : { + + }, + "This connection can't run the PL/pgSQL debugger." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This connection does not support user and role management." : { "localizations" : { "fr" : { @@ -193953,6 +194750,9 @@ } } } + }, + "This connection's driver can't debug routines." : { + }, "This connection's driver does not list its tables." : { "localizations" : { @@ -194364,6 +195164,10 @@ } } }, + "This database doesn't provide DBMS_DEBUG_JDWP." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This database has an open transaction. Closing it rolls the transaction back and discards its uncommitted changes." : { "localizations" : { "fr" : { @@ -197547,6 +198351,17 @@ } } } + }, + "This routine sets client_min_messages to %@, which hides the notice the debugger starts with." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "This routine sets transaction_timeout to %@, which would end it while it waits at a breakpoint." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "This routine takes no arguments." : { + }, "This row does not name a stored document." : { "extractionState" : "manual", @@ -197880,6 +198695,10 @@ } } }, + "This server has no PL/pgSQL debugger." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This server has no users or roles you can manage." : { "localizations" : { "fr" : { @@ -198234,6 +199053,9 @@ } } } + }, + "This table has no options to change." : { + }, "This table has no primary key or unique index to match rows on." : { @@ -200089,6 +200911,9 @@ } } } + }, + "Toggle Breakpoint" : { + }, "Toggle Comment" : { "localizations" : { @@ -201744,6 +202569,8 @@ "generatesSymbol" : false }, "Trigger" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -202609,6 +203436,9 @@ }, "Try again and answer the prompt in TablePro." : { + }, + "Try again, or install the plugin from Settings > Extensions." : { + }, "Try again, or install the plugin from Settings > Plugins." : { "localizations" : { @@ -205868,6 +206698,9 @@ } } } + }, + "Update or reinstall the plugin in Settings > Extensions." : { + }, "Update or reinstall the plugin in Settings > Plugins." : { "localizations" : { @@ -206047,6 +206880,12 @@ } } } + }, + "Update the database driver in Settings > Extensions to run a batch more than once with GO." : { + + }, + "Update the database driver in Settings > Extensions to run several statements in one call." : { + }, "Update the database driver in Settings > Plugins to run a batch more than once with GO." : { "localizations" : { @@ -209100,6 +209939,9 @@ } } } + }, + "Variables" : { + }, "Variant id" : { "localizations" : { @@ -211441,6 +212283,9 @@ } } } + }, + "Where AND and OR go when a WHERE or HAVING condition breaks across lines." : { + }, "WHERE clause" : { "localizations" : { @@ -211481,6 +212326,9 @@ } } } + }, + "Where the items of a SELECT list, SET list, WITH list, or WHERE or HAVING condition start once it breaks across lines. A single item stays beside its keyword." : { + }, "Whether a session is open" : { "localizations" : { diff --git a/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift b/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift index 9c3f184a7e..64cb3d6702 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift @@ -248,7 +248,7 @@ extension AIChatViewModel { else { return [] } return AISchemaContext.referencedTables( in: AISchemaContext.carriedQuery(query), - grammar: connection.type.lexicalGrammar + grammar: SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id) ) } diff --git a/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift b/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift index dc065991a3..0fbec7e3a0 100644 --- a/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -121,6 +121,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { private var activeCrossConnectionQueryLoadId = UUID() private var loadedCrossConnectionVersion: CrossConnectionCatalogVersion? private var loadedCrossConnectionQueryVersion: CrossConnectionQueryVersion? + // The retained Open Quickly panel keeps showing this model until its next presentation. + private var isReleased = false @Published private(set) var groups: [Group] = [] @Published private(set) var isLoading = true @@ -252,6 +254,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { openTables: Set = [], browseSchema: String? = nil ) async { + // A task that starts after the panel already closed would set up the observers again. + guard !isReleased, !Task.isCancelled else { return } isLoading = true let loadId = UUID() @@ -520,7 +524,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { /// task that drives this, and the refresh it just ran has already moved the version, so /// the panel refreshes that connection forever. func loadCrossConnectionItems() async { - guard scope.usesCrossConnectionCatalog else { return } + guard !isReleased, scope.usesCrossConnectionCatalog else { return } guard loadedCrossConnectionVersion != crossConnectionCatalogVersion else { return } let loadId = UUID() @@ -554,7 +558,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { } func loadCrossConnectionQueryItems() async { - guard scope.usesCrossConnectionQueries else { return } + guard !isReleased, scope.usesCrossConnectionQueries else { return } let version = crossConnectionQueryVersion guard loadedCrossConnectionQueryVersion != version else { return } @@ -716,6 +720,36 @@ internal final class QuickSwitcherViewModel: ObservableObject { return covered } + /// Run when the panel closes. Loads still in flight see the bumped ids and drop what they bring. + func releaseResults() { + isReleased = true + activeLoadId = UUID() + activeCrossConnectionLoadId = UUID() + activeCrossConnectionQueryLoadId = UUID() + listingRequestId = nil + tableSourceObservations = [] + tableSource = nil + tableInputs = nil + pendingTableBuild?.task.cancel() + pendingTableBuild = nil + filterTask?.cancel() + filterTask = nil + baseItems = nil + tableItems = [] + tableItemKeys = [] + allItems = [] + crossConnectionItems = [] + crossConnectionQueryItems = [] + groups = [] + selectedItemId = nil + isFiltering = false + isLoadingBrowseCatalog = false + isListingInFlight = false + isSharedListingLoading = false + isLoadingCrossConnections = false + isLoadingCrossConnectionQueries = false + } + func canOpenStructure(_ item: QuickSwitcherItem) -> Bool { guard let target = item.target else { return true } return target.connectionId == connectionId @@ -762,6 +796,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { /// build off the main actor so neither can stall the panel while it is being typed into. private func scheduleFilter(debounced: Bool) { filterTask?.cancel() + guard !isReleased else { return } let query = searchText.trimmingCharacters(in: .whitespaces) let items = scopedItems() let scope = scope diff --git a/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index f334fa115a..f417403b01 100644 --- a/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -85,8 +85,8 @@ struct AIChatWalkthroughBlockView: View { Spacer() if walkthrough.hasDiff { Picker(String(localized: "Diff layout"), selection: diffStyleBinding) { - Text("Unified").tag(SqlWalkthroughDiffStyle.unified) Text("Split").tag(SqlWalkthroughDiffStyle.split) + Text("Unified").tag(SqlWalkthroughDiffStyle.unified) } .pickerStyle(.segmented) .labelsHidden() diff --git a/apple/TablePro/Views/Backup/BackupScopeModel.swift b/apple/TablePro/Views/Backup/BackupScopeModel.swift index 70a1915b26..f7688e3f4f 100644 --- a/apple/TablePro/Views/Backup/BackupScopeModel.swift +++ b/apple/TablePro/Views/Backup/BackupScopeModel.swift @@ -115,33 +115,89 @@ final class BackupScopeModel: ObservableObject { rows = Self.togglingObject(id, in: database, rows: rows) } + enum Row: Equatable { + case database(String) + case object(String, database: String) + } + + func toggle(_ selection: [Row]) { + rows = Self.toggling(selection, in: rows) + } + + // One direction for every row, and an object under a selected database is left to it: toggling both flipped it back. + static func toggling(_ selection: [Row], in rows: [DatabaseRow]) -> [DatabaseRow] { + let databases = Set(selection.compactMap { row -> String? in + guard case .database(let name) = row else { return nil } + return name + }) + let targets = selection.filter { row in + guard case .object(_, let database) = row else { return true } + return !databases.contains(database) + } + let turningOn = targets.contains { target in + switch target { + case .database(let name): + return rows.first { $0.name == name }.map { !$0.isSelected || $0.isMixed } ?? false + case .object(let id, let database): + return rows.first { $0.name == database }?.objects.first { $0.id == id }.map { !$0.isSelected } ?? false + } + } + return targets.reduce(rows) { rows, target in + switch target { + case .database(let name): + return settingDatabase(name, selected: turningOn, in: rows) + case .object(let id, let database): + return settingObject(id, in: database, selected: turningOn, rows: rows) + } + } + } + /// Ticking a mixed database means "all of it", which is the state a user reaches by unticking /// a few tables and then changing their mind. static func togglingDatabase( _ database: String, in rows: [DatabaseRow] + ) -> [DatabaseRow] { + guard let row = rows.first(where: { $0.name == database }) else { return rows } + return settingDatabase(database, selected: !row.isSelected || row.isMixed, in: rows) + } + + static func settingDatabase( + _ database: String, + selected: Bool, + in rows: [DatabaseRow] ) -> [DatabaseRow] { var rows = rows guard let index = rows.firstIndex(where: { $0.name == database }) else { return rows } - let turningOn = !rows[index].isSelected || rows[index].isMixed - rows[index].isSelected = turningOn + rows[index].isSelected = selected for objectIndex in rows[index].objects.indices { - rows[index].objects[objectIndex].isSelected = turningOn + rows[index].objects[objectIndex].isSelected = selected } return rows } + static func togglingObject( + _ id: String, + in database: String, + rows: [DatabaseRow] + ) -> [DatabaseRow] { + guard let object = rows.first(where: { $0.name == database })?.objects.first(where: { $0.id == id }) + else { return rows } + return settingObject(id, in: database, selected: !object.isSelected, rows: rows) + } + /// Unticking the last object is a decision to leave the database out, not a decision to back it /// up empty. Ticking one again brings the database back carrying only that object. - static func togglingObject( + static func settingObject( _ id: String, in database: String, + selected: Bool, rows: [DatabaseRow] ) -> [DatabaseRow] { var rows = rows guard let index = rows.firstIndex(where: { $0.name == database }) else { return rows } guard let objectIndex = rows[index].objects.firstIndex(where: { $0.id == id }) else { return rows } - rows[index].objects[objectIndex].isSelected.toggle() + rows[index].objects[objectIndex].isSelected = selected rows[index].isSelected = rows[index].objects.contains(where: \.isSelected) return rows } diff --git a/apple/TablePro/Views/Backup/BackupScopeTreeView.swift b/apple/TablePro/Views/Backup/BackupScopeTreeView.swift index aee2d5036e..9625427c75 100644 --- a/apple/TablePro/Views/Backup/BackupScopeTreeView.swift +++ b/apple/TablePro/Views/Backup/BackupScopeTreeView.swift @@ -25,6 +25,8 @@ internal struct BackupScopeTreeView: NSViewRepresentable { let outlineView = CheckboxOutlineView() outlineView.headerView = nil outlineView.style = .plain + // The default style left the column 14 pt wider than the clip view, so a swipe slid the rows sideways. + outlineView.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle outlineView.rowSizeStyle = .custom outlineView.rowHeight = 24 outlineView.allowsMultipleSelection = true @@ -335,16 +337,14 @@ internal final class BackupScopeTreeCoordinator: NSObject, NSOutlineViewDataSour internal func toggleSelectedRows() { guard let outlineView else { return } - for index in outlineView.selectedRowIndexes { - guard let node = outlineView.item(atRow: index) as? BackupScopeNode else { continue } + let selection = outlineView.selectedRowIndexes.compactMap { index -> BackupScopeModel.Row? in + guard let node = outlineView.item(atRow: index) as? BackupScopeNode else { return nil } switch node.kind { - case .database(let name): - model.toggleDatabase(name) - case .object(let database, let id): - model.toggleObject(id, in: database) - case .placeholder: - continue + case .database(let name): return .database(name) + case .object(let database, let id): return .object(id, database: database) + case .placeholder: return nil } } + model.toggle(selection) } } diff --git a/apple/TablePro/Views/Compare/CompareDetailView.swift b/apple/TablePro/Views/Compare/CompareDetailView.swift index 2357e9fb06..4b8d88cb90 100644 --- a/apple/TablePro/Views/Compare/CompareDetailView.swift +++ b/apple/TablePro/Views/Compare/CompareDetailView.swift @@ -29,16 +29,15 @@ internal struct CompareDetailView: View { } private var paneSelector: some View { - Picker(String(localized: "Detail"), selection: $session.detailPane) { + PaneTabBar( + title: String(localized: "Detail"), + selection: $session.detailPane, + identifier: "compare-detail-tabs" + ) { ForEach(CompareDetailPane.allCases, id: \.self) { pane in Text(pane.title).tag(pane) } } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - .padding(.vertical, 8) - .frame(maxWidth: .infinity) } @ViewBuilder diff --git a/apple/TablePro/Views/Compare/CompareSyncWindowController.swift b/apple/TablePro/Views/Compare/CompareSyncWindowController.swift index 26e514086e..bd19dc5070 100644 --- a/apple/TablePro/Views/Compare/CompareSyncWindowController.swift +++ b/apple/TablePro/Views/Compare/CompareSyncWindowController.swift @@ -153,7 +153,11 @@ internal final class CompareSyncWindowController: NSWindowController, private func installToolbar(on window: NSWindow) { let toolbar = NSToolbar(identifier: "com.TablePro.CompareSyncToolbar") toolbar.delegate = self - toolbar.displayMode = .iconAndLabel + /// Icon only, the default every other TablePro window and the system apps ship. The source + /// and target items still read as words, since an item's title draws in this mode, and a + /// user who wants labels sets Icon and Text from Customize Toolbar, which is autosaved and + /// restored over this default. + toolbar.displayMode = .iconOnly toolbar.allowsUserCustomization = true toolbar.autosavesConfiguration = true window.toolbar = toolbar diff --git a/apple/TablePro/Views/Components/CheckboxOutlineView.swift b/apple/TablePro/Views/Components/CheckboxOutlineView.swift index 2fea752bed..62c298fe58 100644 --- a/apple/TablePro/Views/Components/CheckboxOutlineView.swift +++ b/apple/TablePro/Views/Components/CheckboxOutlineView.swift @@ -53,3 +53,47 @@ internal final class HostingTableCellView: NSTableCellView { hosting.rootView = content } } + +internal final class CheckboxTableCellView: NSTableCellView { + private let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private var modelState: NSControl.StateValue = .off + private var action: (() -> Void)? + + internal init(identifier: NSUserInterfaceItemIdentifier) { + super.init(frame: .zero) + self.identifier = identifier + checkbox.target = self + checkbox.action = #selector(clicked(_:)) + checkbox.translatesAutoresizingMaskIntoConstraints = false + addSubview(checkbox) + NSLayoutConstraint.activate([ + checkbox.centerXAnchor.constraint(equalTo: centerXAnchor), + checkbox.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + @available(*, unavailable) + internal required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + internal func configure( + state: NSControl.StateValue, + isEnabled: Bool, + accessibilityLabel: String, + action: @escaping () -> Void + ) { + modelState = state + self.action = action + checkbox.allowsMixedState = state == .mixed + checkbox.state = state + checkbox.isEnabled = isEnabled + checkbox.setAccessibilityLabel(accessibilityLabel) + } + + // AppKit already moved the box to its next state; the action decides the real one. + @objc private func clicked(_ sender: NSButton) { + sender.state = modelState + action?() + } +} diff --git a/apple/TablePro/Views/Components/DiagramZoomToolbar.swift b/apple/TablePro/Views/Components/DiagramZoomToolbar.swift index 1c74411f82..bbbe412086 100644 --- a/apple/TablePro/Views/Components/DiagramZoomToolbar.swift +++ b/apple/TablePro/Views/Components/DiagramZoomToolbar.swift @@ -59,11 +59,26 @@ struct DiagramZoomToolbar: View { .buttonStyle(.borderless) .padding(.horizontal, 10) .padding(.vertical, 6) - .themeMaterial(.toolbar, .thinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 0.5)) + .modifier(DiagramZoomSurface()) } private var zoomPercentage: String { "\(Int((viewport.magnification * 100).rounded()))%" } } + +/// Controls floating over content take Liquid Glass from macOS 26, as the Open Quickly panel and +/// the chat's jump button already do. Glass draws its own edge and adapts to Reduce Transparency +/// and Increase Contrast itself, so the hairline and the solid fallback belong to the material +/// only. +private struct DiagramZoomSurface: ViewModifier { + func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content.glassEffect(.regular, in: Capsule()) + } else { + content + .themeMaterial(.toolbar, .thinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 0.5)) + } + } +} diff --git a/apple/TablePro/Views/Components/ViewSwitcherStyle.swift b/apple/TablePro/Views/Components/ViewSwitcherStyle.swift new file mode 100644 index 0000000000..fc1aa5cc8f --- /dev/null +++ b/apple/TablePro/Views/Components/ViewSwitcherStyle.swift @@ -0,0 +1,65 @@ +// +// ViewSwitcherStyle.swift +// TablePro +// + +import SwiftUI + +/// Measured on macOS 27, small `.tabs` draws a flat track with a raised neutral thumb, the look Apple +/// gives tab navigation, where small `.segmented` fills the thumb with the accent and reads as a value. +/// +/// To accessibility it is then a tab group of tab buttons rather than a radio group of segments. +private struct ViewSwitcherStyle: ViewModifier { + let controlSize: ControlSize + + func body(content: Content) -> some View { + switcher(content) + .controlSize(controlSize) + } + + @ViewBuilder + private func switcher(_ content: Content) -> some View { + #if SDK_MACOS_27 + if #available(macOS 27.0, *) { + content.pickerStyle(.tabs) + } else { + content.pickerStyle(.segmented) + } + #else + content.pickerStyle(.segmented) + #endif + } +} + +internal extension View { + /// For a picker that switches what a pane shows: small in a pane bar, status bar, viewer strip or + /// inspector, regular in a `PaneTabBar`. A picker that sets a value stays `.segmented`. + func viewSwitcherStyle(_ controlSize: ControlSize = .small) -> some View { + modifier(ViewSwitcherStyle(controlSize: controlSize)) + } +} + +/// The tab row a main-window pane switches its views with, centred over content that runs edge to +/// edge. A bordered tab view boxes and insets the grids these panes hold; the tabs role is the +/// control macOS 27 gives tab navigation, and before it the segmented control is. +internal struct PaneTabBar: View { + let title: String + @Binding var selection: Selection + let identifier: String + @ViewBuilder let tabs: () -> Tabs + + /// Spacers rather than a fixed size, so a pane narrower than the tabs compresses them instead of + /// pushing the outer ones past its edges. + var body: some View { + HStack(spacing: 0) { + Spacer(minLength: 0) + Picker(title, selection: $selection, content: tabs) + .labelsHidden() + .viewSwitcherStyle(.regular) + .accessibilityIdentifier(identifier) + Spacer(minLength: 0) + } + .padding(.horizontal) + .padding(.vertical, 8) + } +} diff --git a/apple/TablePro/Views/DataFiles/DataFileContentView.swift b/apple/TablePro/Views/DataFiles/DataFileContentView.swift index fe2a2dd979..e662a59e91 100644 --- a/apple/TablePro/Views/DataFiles/DataFileContentView.swift +++ b/apple/TablePro/Views/DataFiles/DataFileContentView.swift @@ -99,14 +99,15 @@ struct DataFileContentView: View { } struct DataFileSheetTabs: View { - static let segmentedSheetLimit = 8 + /// The HIG's limit for tabs, past which it asks for a pop-up menu instead. + static let segmentedSheetLimit = 6 @ObservedObject var controller: DataFileController var body: some View { HStack { if controller.sheets.count <= Self.segmentedSheetLimit { - picker.pickerStyle(.segmented) + picker.viewSwitcherStyle() } else { picker.pickerStyle(.menu) } @@ -130,5 +131,6 @@ struct DataFileSheetTabs: View { .labelsHidden() .fixedSize() .accessibilityLabel(String(localized: "Sheet")) + .accessibilityIdentifier("data-file-sheet-picker") } } diff --git a/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift b/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift index f7d8bc992e..20ae8e73fa 100644 --- a/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift +++ b/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift @@ -31,7 +31,6 @@ struct DataFileRowDetailsView: View { editState: editState, isEditable: controller.isEditable && !controller.isBusy, databaseType: Self.databaseType, - userDefinedTypeScope: nil, offersDatabaseValues: false ) } diff --git a/apple/TablePro/Views/DataFiles/DataFileWindowController.swift b/apple/TablePro/Views/DataFiles/DataFileWindowController.swift index d558fda84b..945b1c42ef 100644 --- a/apple/TablePro/Views/DataFiles/DataFileWindowController.swift +++ b/apple/TablePro/Views/DataFiles/DataFileWindowController.swift @@ -12,7 +12,15 @@ extension NSToolbarItem.Identifier { static let dataFileColumns = NSToolbarItem.Identifier("com.TablePro.dataFile.columns") static let dataFileFilters = NSToolbarItem.Identifier("com.TablePro.dataFile.filters") static let dataFileSearch = NSToolbarItem.Identifier("com.TablePro.dataFile.search") - static let dataFileInspector = NSToolbarItem.Identifier("com.TablePro.dataFile.inspector") + /// AppKit's own inspector toggle from macOS 14, which draws the system glyph and sends + /// `toggleInspector(_:)` down the responder chain to the split controller. macOS 13 has no such + /// item, so it keeps an app-owned one. + static let dataFileInspector: NSToolbarItem.Identifier = { + if #available(macOS 14.0, *) { + return .toggleInspector + } + return NSToolbarItem.Identifier("com.TablePro.dataFile.inspector") + }() } @MainActor @@ -110,6 +118,7 @@ final class DataFileWindowController: NSWindowController, NSWindowDelegate, NSTo return makeItem(itemIdentifier, label: String(localized: "Filters"), symbol: ToolbarSymbols.filter(), action: #selector(DataFileSplitViewController.toggleFilterBar(_:))) case .dataFileInspector: + guard #unavailable(macOS 14.0) else { return nil } return makeItem(itemIdentifier, label: String(localized: "Inspector"), symbol: "sidebar.trailing", action: #selector(DataFileSplitViewController.toggleInspector(_:))) case .dataFileColumns: @@ -132,9 +141,18 @@ final class DataFileWindowController: NSWindowController, NSWindowDelegate, NSTo } } + /// The inspector toggle follows the order the main window uses: after the tracking separator + /// and a flexible space, so it sits over the inspector and stays at the trailing edge whether + /// the pane is open or not. func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [.dataFileAddRow, .dataFileDeleteRows, .dataFileColumns, .flexibleSpace, .dataFileFilters, - .dataFileSearch, .dataFileInspector] + var items: [NSToolbarItem.Identifier] = [ + .dataFileAddRow, .dataFileDeleteRows, .dataFileColumns, .flexibleSpace, .dataFileFilters, .dataFileSearch, + ] + if #available(macOS 14.0, *) { + items.append(contentsOf: [.inspectorTrackingSeparator, .flexibleSpace]) + } + items.append(.dataFileInspector) + return items } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridCellContent.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellContent.swift index be6494909c..b32be5c107 100644 --- a/apple/TablePro/Views/DataGrid/Cells/DataGridCellContent.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellContent.swift @@ -23,7 +23,8 @@ struct DataGridCellContent { static func placeholder( for rawValue: PluginCellValue, isAbsent: Bool = false, - unloadedByteCount: Int? = nil + unloadedByteCount: Int? = nil, + marksEmptyText: Bool = true ) -> DataGridCellPlaceholder? { if let unloadedByteCount { return .unloaded(byteCount: unloadedByteCount) } guard !isAbsent else { return .absent } @@ -32,7 +33,7 @@ struct DataGridCellContent { return .null case .text(let value): if value == PluginCellValue.defaultMarkerText { return .defaultMarker } - return value.isEmpty ? .empty : nil + return value.isEmpty && marksEmptyText ? .empty : nil case .bytes: return nil } diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridCellPresenter.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellPresenter.swift index 4db4c80598..8f7745aac0 100644 --- a/apple/TablePro/Views/DataGrid/Cells/DataGridCellPresenter.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellPresenter.swift @@ -16,6 +16,7 @@ final class DataGridCellPresenter { let findMatch: GridFindState.Match? let isEditable: Bool let checkboxColumns: Set + let marksEmptyText: Bool let palette: DataGridCellPalette let scope: ThemeScope let databaseType: DatabaseType? @@ -55,6 +56,7 @@ final class DataGridCellPresenter { findMatch: model.find.currentMatch, isEditable: model.configuration.isEditable, checkboxColumns: model.configuration.checkboxColumns, + marksEmptyText: model.configuration.marksEmptyText, palette: registry.palette, scope: model.themeScope, databaseType: model.configuration.traits.databaseType, @@ -248,7 +250,8 @@ final class DataGridCellPresenter { placeholder: DataGridCellContent.placeholder( for: rawValue, isAbsent: row.isAbsent(dataIndex), - unloadedByteCount: row.unloadedByteCount(dataIndex) + unloadedByteCount: row.unloadedByteCount(dataIndex), + marksEmptyText: pass.marksEmptyText ) ), state: DataGridCellState( @@ -294,7 +297,8 @@ final class DataGridCellPresenter { switch DataGridCellContent.placeholder( for: rawValue, isAbsent: row.isAbsent(dataIndex), - unloadedByteCount: row.unloadedByteCount(dataIndex) + unloadedByteCount: row.unloadedByteCount(dataIndex), + marksEmptyText: model.configuration.marksEmptyText ) { case .null: return String(localized: "NULL") case .empty: return String(localized: "Empty") diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridMetrics.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridMetrics.swift index 05091ede89..54adc3e2e6 100644 --- a/apple/TablePro/Views/DataGrid/Cells/DataGridMetrics.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridMetrics.swift @@ -21,4 +21,14 @@ enum DataGridMetrics { static func rowHeight(_ setting: DataGridRowHeight, font: NSFont) -> CGFloat { max(CGFloat(setting.rawValue), (font.ascender - font.descender + font.leading).rounded(.up)) } + + // An overlay scroller floats over the last row, so only a legacy one adds height. + static func fittingHeight( + headerHeight: CGFloat, + rowCount: Int, + rowPitch: CGFloat, + horizontalScrollerHeight: CGFloat + ) -> CGFloat { + headerHeight + CGFloat(max(rowCount, 0)) * rowPitch + horizontalScrollerHeight + } } diff --git a/apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift b/apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift index 71e665d3b0..c74cde2a24 100644 --- a/apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift +++ b/apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift @@ -156,19 +156,32 @@ extension DataGridViewController { return cell } - // A row prepared off screen was answered with no view and is never asked again, so revealed rows reload. + // A row prepared off screen was answered with no view and is never asked again, so an on-screen row without + // its stand-in reloads. Asked of the rows themselves: a client's read prepares rows the grid never showed. func remountAccessibilityCells(force: Bool = false) { guard DataGridAccessibility.isActive, !isEditingCell else { return } let table = main.tableView let visible = table.rows(in: table.visibleRect) - guard visible.length > 0, force || !NSEqualRanges(visible, accessibilityMountedRows) else { return } let upperBound = min(visible.location + visible.length, table.numberOfRows) guard visible.location >= 0, upperBound > visible.location, table.numberOfColumns > 0 else { return } - accessibilityMountedRows = visible - table.reloadData( - forRowIndexes: IndexSet(integersIn: visible.location.. Void)? // The frozen pane hands every wheel event to the main pane, so one momentum engine moves both. weak var wheelTarget: NSScrollView? + private var wheelRouting = DataGridWheelRouting() + + // The page the grid sits in scrolls vertically for it, so the main pane keeps only its sideways scroll. + var fitsRows = false { + didSet { + guard fitsRows != oldValue, paneKind == .main else { return } + hasVerticalScroller = !fitsRows + verticalScrollElasticity = fitsRows ? .none : .automatic + } + } init(kind: DataGridPane.Kind) { paneKind = kind @@ -43,11 +53,23 @@ final class DataGridScrollView: NSScrollView { } override func scrollWheel(with event: NSEvent) { - guard let wheelTarget else { - super.scrollWheel(with: event) + if let wheelTarget { + wheelTarget.scrollWheel(with: event) return } - wheelTarget.scrollWheel(with: event) + if fitsRows, let page = superview?.enclosingScrollView { + let axis = wheelRouting.axis( + phase: event.phase, + momentumPhase: event.momentumPhase, + deltaX: event.scrollingDeltaX, + deltaY: event.scrollingDeltaY + ) + if axis == .vertical { + page.scrollWheel(with: event) + return + } + } + super.scrollWheel(with: event) } // The frozen pane repeats cells the main table already publishes, so VoiceOver sees one table. @@ -61,3 +83,32 @@ final class DataGridScrollView: NSScrollView { return scroller.frame.height } } + +// One axis per trackpad gesture, momentum included, so a swipe never splits between the grid and its page. +// A wheel with no phases is a mouse, and each notch decides alone. +struct DataGridWheelRouting { + enum Axis { + case vertical + case horizontal + } + + private var gestureAxis: Axis? + + mutating func axis(phase: NSEvent.Phase, momentumPhase: NSEvent.Phase, deltaX: CGFloat, deltaY: CGFloat) -> Axis { + if phase.isEmpty, momentumPhase.isEmpty { + return Self.axis(deltaX: deltaX, deltaY: deltaY) ?? .vertical + } + if phase.contains(.began) || phase.contains(.mayBegin) { + gestureAxis = Self.axis(deltaX: deltaX, deltaY: deltaY) + } else if gestureAxis == nil, !phase.isEmpty { + // A gesture can begin with no movement; its first move decides. + gestureAxis = Self.axis(deltaX: deltaX, deltaY: deltaY) + } + return gestureAxis ?? .vertical + } + + static func axis(deltaX: CGFloat, deltaY: CGFloat) -> Axis? { + guard deltaX != 0 || deltaY != 0 else { return nil } + return abs(deltaY) >= abs(deltaX) ? .vertical : .horizontal + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridTableView.swift b/apple/TablePro/Views/DataGrid/DataGridTableView.swift index d1edf45a3e..22028b7938 100644 --- a/apple/TablePro/Views/DataGrid/DataGridTableView.swift +++ b/apple/TablePro/Views/DataGrid/DataGridTableView.swift @@ -51,7 +51,22 @@ final class DataGridTableView: NSTableView { // A revealed column must never park under the frozen pane, so the rect widens leftward by its width. override func scrollToVisible(_ rect: NSRect) -> Bool { guard paneKind == .main, let controller else { return super.scrollToVisible(rect) } - return super.scrollToVisible(controller.revealRect(for: rect, in: self)) + let widened = controller.revealRect(for: rect, in: self) + guard controller.model.configuration.fitsRows else { return super.scrollToVisible(widened) } + // Only sideways. AppKit calls this itself while the pane resizes, with rects spanning the header, so the page + // moves only for a reveal the grid starts (`scrollRowToVisible`, `reveal(row:displayColumn:)`). + let visible = visibleRect + return super.scrollToVisible(NSRect(x: widened.minX, y: visible.midY, width: widened.width, height: 1)) + } + + // Up, Down and type-select reveal through here; a table that fits its rows has nothing to scroll, so the page moves. + override func scrollRowToVisible(_ row: Int) { + guard paneKind == .main, let controller, controller.model.configuration.fitsRows else { + super.scrollRowToVisible(row) + return + } + guard row >= 0, row < numberOfRows else { return } + controller.revealOnPage(rect(ofRow: row)) } // MARK: - Keyboard @@ -243,17 +258,23 @@ final class DataGridTableView: NSTableView { override func accessibilityChildren() -> [Any]? { guard paneKind == .main else { return [] } - DataGridAccessibility.markActive() + noteAccessibilityRead() return super.accessibilityChildren() } override func accessibilityCell(forColumn column: Int, row: Int) -> Any? { guard paneKind == .main else { return nil } - DataGridAccessibility.markActive() + noteAccessibilityRead() return view(atColumn: column, row: row, makeIfNecessary: false) as? DataGridAccessibilityCellView ?? super.accessibilityCell(forColumn: column, row: row) } + // A read prepares every row it walks, off screen ones without a stand-in, and some reach the screen with no scroll. + private func noteAccessibilityRead() { + DataGridAccessibility.markActive() + controller?.scheduleAccessibilityRemount() + } + override func accessibilitySelectedCells() -> [Any]? { guard paneKind == .main else { return [] } return controller?.accessibilitySelectedCells() ?? super.accessibilitySelectedCells() diff --git a/apple/TablePro/Views/DataGrid/DataGridView.swift b/apple/TablePro/Views/DataGrid/DataGridView.swift index 1ed325eb32..8f66e7123c 100644 --- a/apple/TablePro/Views/DataGrid/DataGridView.swift +++ b/apple/TablePro/Views/DataGrid/DataGridView.swift @@ -16,7 +16,12 @@ struct DataGridView: View { var body: some View { VStack(spacing: 0) { DataGridFindBarSlot(model: model, find: model.find) - DataGridRepresentable(model: model) + if model.configuration.fitsRows { + DataGridRepresentable(model: model) + .frame(height: model.fittingHeight) + } else { + DataGridRepresentable(model: model) + } } } } diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift index ab5f2b8bd8..ec17de9e21 100644 --- a/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift @@ -41,6 +41,7 @@ extension DataGridViewController { applyRowNumberWidth() syncSortIndicators() redrawVisibleRows() + updateFittingHeight() } // MARK: - Structural changes @@ -53,13 +54,13 @@ extension DataGridViewController { for pane in panes { pane.tableView.reloadData() } - accessibilityMountedRows = NSRange(location: 0, length: 0) drawnSelection = model.selection // A view-based reloadData() drops the selection without telling anyone (probe P2), so it is written back. mirrorSelection() updateHighlightedColumns(model.selection) syncSortIndicators() startBackgroundPrewarm() + updateFittingHeight() } private func columnsDidChange() { @@ -88,6 +89,7 @@ extension DataGridViewController { } applyRowNumberWidth() startBackgroundPrewarm() + updateFittingHeight() } private func removeRows(_ indexes: IndexSet) { @@ -102,6 +104,7 @@ extension DataGridViewController { pane.tableView.removeRows(at: indexes, withAnimation: []) } applyRowNumberWidth() + updateFittingHeight() } private func updateRows(_ rows: IndexSet, columns displayColumns: IndexSet?) { @@ -169,6 +172,31 @@ extension DataGridViewController { pane.tableView.tile() } } + updateFittingHeight() + } + + // The table's own header, rows and legacy scroller: what a fitsRows grid needs to show every row. + var measuredFittingHeight: CGFloat { + let table = main.tableView + return DataGridMetrics.fittingHeight( + headerHeight: table.headerView?.frame.height ?? 0, + rowCount: table.numberOfRows, + rowPitch: table.rowHeight + table.intercellSpacing.height, + horizontalScrollerHeight: main.scrollView.legacyHorizontalScrollerHeight + ) + } + + // Published on the next turn: this runs inside SwiftUI updates and layout passes, which refuse a model change. + func updateFittingHeight() { + guard model.configuration.fitsRows, !isFittingHeightScheduled, + measuredFittingHeight != model.fittingHeight else { return } + isFittingHeightScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.isFittingHeightScheduled = false + guard self.model.mountedView === self, self.model.configuration.fitsRows else { return } + self.model.setFittingHeight(self.measuredFittingHeight) + } } // The clip view keeps its offset in points, so a new row height would otherwise bring other rows into view. diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Selection.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Selection.swift index 7728930d6a..f3fb692a25 100644 --- a/apple/TablePro/Views/DataGrid/DataGridViewController+Selection.swift +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Selection.swift @@ -180,10 +180,13 @@ extension DataGridViewController { func reveal(row: Int, displayColumn: Int?) { let table = main.tableView guard row >= 0, row < table.numberOfRows else { return } - table.scrollRowToVisible(row) - if let displayColumn { - revealColumn(displayColumn) + revealingInGrid { + table.scrollRowToVisible(row) + if let displayColumn { + revealColumn(displayColumn) + } } + revealOnPage(table.rect(ofRow: row)) } // Horizontal only; a frozen column is on screen at every offset. @@ -193,7 +196,46 @@ extension DataGridViewController { let index = main.tableIndex(forDisplayPosition: displayColumn, columns: model.columns) else { return } let columnRect = table.rect(ofColumn: index) let visible = table.visibleRect - _ = table.scrollToVisible(NSRect(x: columnRect.minX, y: visible.midY, width: columnRect.width, height: 1)) + revealingInGrid { + _ = table.scrollToVisible(NSRect(x: columnRect.minX, y: visible.midY, width: columnRect.width, height: 1)) + } + } + + private func revealingInGrid(_ body: () -> Void) { + let wasRevealing = isRevealingInGrid + isRevealingInGrid = true + body() + isRevealingInGrid = wasRevealing + } + + // A grid sized to its rows shows them all, so a row it reveals is brought into view by the page it sits in. + // Kept until the grid has grown to a row it just took, since the page lays out after this turn. + // A reveal the grid cannot show within two turns is dropped, so a later layout never scrolls the page on its own. + func revealOnPage(_ rectInTable: NSRect) { + guard model.configuration.fitsRows, !isRevealingInGrid else { return } + pendingPageReveal = rectInTable + performPageReveal() + guard pendingPageReveal != nil else { return } + DispatchQueue.main.async { [weak self] in + DispatchQueue.main.async { [weak self] in + self?.performPageReveal() + self?.pendingPageReveal = nil + } + } + } + + func performPageReveal() { + guard let rect = pendingPageReveal else { return } + let target = container.convert(rect, from: main.tableView) + let bounds = container.bounds + guard target.maxY <= bounds.maxY + 1 else { return } + pendingPageReveal = nil + let visible = container.visibleRect + guard target.minY < visible.minY || target.maxY > visible.maxY else { return } + // Kept within the grid's width: the grid already moved its columns, so the page moves only vertically. + let minX = min(max(target.minX, bounds.minX), bounds.maxX) + let maxX = max(min(target.maxX, bounds.maxX), minX) + _ = container.scrollToVisible(NSRect(x: minX, y: target.minY, width: maxX - minX, height: target.height)) } func revealActiveCell() { @@ -299,6 +341,9 @@ extension DataGridViewController { while let event = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) { if event.type == .leftMouseUp { break } _ = main.tableView.autoscroll(with: event) + if model.configuration.fitsRows { + _ = container.autoscroll(with: event) + } guard let cell = dragCell(at: event.locationInWindow, rowsOnly: drag.mode == .rows) else { continue } if drag.mode == .rows ? cell.row != start.row : cell != start { moved = true diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController.swift b/apple/TablePro/Views/DataGrid/DataGridViewController.swift index 3776585b3e..dd7176f7af 100644 --- a/apple/TablePro/Views/DataGrid/DataGridViewController.swift +++ b/apple/TablePro/Views/DataGrid/DataGridViewController.swift @@ -29,13 +29,16 @@ final class DataGridViewController: NSViewController, DataGridMountedView { var knownWidths: [DataGridColumnID: CGFloat] = [:] var knownWidthsSawRows = false var savedWidthIDs: Set = [] - var accessibilityMountedRows = NSRange(location: 0, length: 0) + var isAccessibilityRemountScheduled = false var needsViewportRestore = false var pendingFocus = false var lastDataGridSettings = AppSettingsManager.shared.dataGrid var prewarmTask: Task? var prewarmResumeTask: Task? var reorderToolTipOwner: NSString? + var isFittingHeightScheduled = false + var pendingPageReveal: NSRect? + var isRevealingInGrid = false private var observers: [NSObjectProtocol] = [] private var cancellables: Set = [] private var themeCancellable: AnyCancellable? @@ -109,6 +112,7 @@ final class DataGridViewController: NSViewController, DataGridMountedView { knownWidths = [:] knownWidthsSawRows = false savedWidthIDs = [] + pendingPageReveal = nil mount() } @@ -124,6 +128,7 @@ final class DataGridViewController: NSViewController, DataGridMountedView { prewarmTask = nil prewarmResumeTask?.cancel() prewarmResumeTask = nil + pendingPageReveal = nil observers.forEach(NotificationCenter.default.removeObserver) observers.removeAll() cancellables.removeAll() @@ -153,6 +158,12 @@ final class DataGridViewController: NSViewController, DataGridMountedView { needsViewportRestore = false applyStagedViewport() } + updateFittingHeight() + scheduleAccessibilityRemount() + if pendingPageReveal != nil { + // The page scrolls once its own layout is done, so its document already holds the grid's new height. + DispatchQueue.main.async { [weak self] in self?.performPageReveal() } + } } // MARK: - DataGridMountedView @@ -297,6 +308,8 @@ final class DataGridViewController: NSViewController, DataGridMountedView { applyRowReorder(configuration.rowReorder, to: table) } main.tableView.setAccessibilityLabel(configuration.accessibilityLabel ?? String(localized: "Data grid")) + main.scrollView.fitsRows = configuration.fitsRows + container.followsPageScroll = configuration.fitsRows applyRowHeight() } diff --git a/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift b/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift index 20175e5be0..cf80d17f2f 100644 --- a/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift +++ b/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift @@ -18,10 +18,12 @@ import TableProSQLGrammar enum FoldProviderResolver { /// The provider for a connection, which knows both the language and the SQL dialect. static func provider(for databaseType: DatabaseType) -> LineFoldProvider? { - provider( - for: PluginManager.shared.editorLanguage(for: databaseType), - grammar: databaseType.lexicalGrammar - ) + provider(for: databaseType, grammar: databaseType.lexicalGrammar) + } + + /// The same, reading the grammar the connection's session lexes with. + static func provider(for databaseType: DatabaseType, grammar: SQLLexicalGrammar) -> LineFoldProvider? { + provider(for: PluginManager.shared.editorLanguage(for: databaseType), grammar: grammar) } /// The provider for a listing whose language is known but whose dialect is not, such as a fenced code block. diff --git a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift index cc1e7add05..ac96f70436 100644 --- a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -3,6 +3,7 @@ import SwiftUI import TableProAutocomplete import TableProEditorKit import TableProPluginKit +import TableProSQLGrammar import TableProTextEngine @MainActor @@ -41,6 +42,7 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { let databaseType: DatabaseType? let profileRevision: String? let connectionId: UUID? + let grammar: SQLLexicalGrammar? } private var service: QueryCompletionService @@ -60,13 +62,15 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, profile: QueryCompletionProfile? = nil, - connectionId: UUID? = nil + connectionId: UUID? = nil, + grammar: SQLLexicalGrammar? = nil ) { let requested = Configuration( schemaProvider: schemaProvider.map(ObjectIdentifier.init), databaseType: databaseType, profileRevision: profile?.revision, - connectionId: connectionId + connectionId: connectionId, + grammar: grammar ) guard requested != configuration else { return } configuration = requested @@ -74,7 +78,8 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { schemaProvider: schemaProvider, databaseType: databaseType, profile: profile, - connectionId: connectionId + connectionId: connectionId, + grammar: grammar ) service.updateFavoriteKeywords(favoriteKeywords) session = nil diff --git a/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift b/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift index a8bc35f755..094e3d4ce7 100644 --- a/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift +++ b/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift @@ -9,6 +9,7 @@ import AppKit import os import TableProEditorKit +import TableProSQLGrammar import TableProTextEngine @MainActor @@ -32,8 +33,8 @@ final class QueryDiagnosticsController { self.clock = clock } - func configure(databaseType: DatabaseType?) { - producer = QueryDiagnosticsFactory.make(for: databaseType) + func configure(databaseType: DatabaseType?, grammar: SQLLexicalGrammar? = nil) { + producer = QueryDiagnosticsFactory.make(for: databaseType, grammar: grammar) diagnostics = [] } diff --git a/apple/TablePro/Views/Editor/SQLEditorController.swift b/apple/TablePro/Views/Editor/SQLEditorController.swift index 5afcebd0d1..1b218c27bc 100644 --- a/apple/TablePro/Views/Editor/SQLEditorController.swift +++ b/apple/TablePro/Views/Editor/SQLEditorController.swift @@ -9,6 +9,7 @@ import SwiftUI import TableProEditorKit import TableProGrammars import TableProPluginKit +import TableProSQLGrammar import TableProTextEngine /// What a query tab hands the SQL editor on every update. @@ -31,6 +32,9 @@ struct SQLEditorInput { var onFoldRangesChanged: (([Range]) -> Void)? var onCloseTab: (() -> Void)? var onExecuteQuery: (() -> Void)? + var onSelectTab: ((Int) -> Void)? + var onSelectAdjacentTab: ((Int) -> Void)? + var tabCount: (() -> Int)? var onRunStatement: ((String, Int) -> Bool)? /// A tab runs one thing at a time, so the gutter's run controls go dim for the length of a query. var isExecuting = false @@ -60,6 +64,11 @@ final class SQLEditorController { private var pendingCaret: [CursorPosition]? private var cancellables: Set = [] private var isTornDown = false + // One instance, because `SourceEditor` keeps the provider it was created with and a new grammar has to reach it. + private lazy var foldProvider: LineFoldProvider? = FoldProviderResolver.provider( + for: input.databaseType ?? .mysql, + grammar: coordinator.lexicalGrammar + ) var textViewController: TextViewController { guard let host else { preconditionFailure("SQLEditorController used before init finished") } @@ -81,6 +90,7 @@ final class SQLEditorController { observeEditorSettings() observeVimMode() + observeSessionChanges() configureCompletion() setupFavoritesObserver() subscribeToCompletionRevision() @@ -133,6 +143,9 @@ final class SQLEditorController { private func applyInput(_ input: SQLEditorInput) { coordinator.onCloseTab = input.onCloseTab coordinator.onExecuteQuery = input.onExecuteQuery + coordinator.onSelectTab = input.onSelectTab + coordinator.onSelectAdjacentTab = input.onSelectAdjacentTab + coordinator.tabCount = input.tabCount coordinator.onRunStatement = input.onRunStatement coordinator.setStatementRunControlsEnabled(!input.isExecuting) coordinator.setStatementHighlightEnabled(AppSettingsManager.shared.editor.highlightCurrentStatement) @@ -154,6 +167,36 @@ final class SQLEditorController { if let restoredFoldRanges = input.restoredFoldRanges { coordinator.scheduleFoldRestore(restoredFoldRanges) } + // Also on every run's start and end, which is when a `SET` can have changed a session flag. + refreshSessionGrammar() + } + + /// The grammar the session lexes with: KingbaseES in Oracle mode, MySQL under `NO_BACKSLASH_ESCAPES`, or + /// PostgreSQL with `standard_conforming_strings` off each split text differently from the engine's default. + private func refreshSessionGrammar() { + guard let databaseType = input.databaseType else { return } + let grammar: SQLLexicalGrammar + if let scope = input.databaseScope { + grammar = SQLLexicalResolver.executionGrammar(for: databaseType, scope: scope) + } else if let connectionId = input.connectionId { + grammar = SQLLexicalResolver.executionGrammar(for: databaseType, connectionId: connectionId) + } else { + grammar = databaseType.lexicalGrammar + } + guard grammar != coordinator.sessionGrammar else { return } + coordinator.setSessionGrammar(grammar) + (foldProvider as? SQLLineFoldProvider)?.grammar = grammar + configureCompletion() + } + + private func observeSessionChanges() { + AppEvents.shared.connectionStatusChanged + .receive(on: RunLoop.main) + .sink { [weak self] change in + guard let self, !self.isTornDown, change.connectionId == self.input.connectionId else { return } + self.refreshSessionGrammar() + } + .store(in: &cancellables) } private func makeEditor(state: Binding) -> SourceEditor { @@ -170,7 +213,7 @@ final class SQLEditorController { language: PluginManager.shared.editorLanguage(for: databaseType).treeSitterLanguage, configuration: configuration, state: state, - foldProvider: FoldProviderResolver.provider(for: databaseType), + foldProvider: foldProvider, coordinators: [coordinator], completionDelegate: completionAdapter ) @@ -267,7 +310,8 @@ final class SQLEditorController { schemaProvider: input.schemaProvider, databaseType: input.databaseType, profile: completionProfile, - connectionId: input.connectionId + connectionId: input.connectionId, + grammar: coordinator.sessionGrammar ) } diff --git a/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift b/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift index 52d59a7a87..64b6516e21 100644 --- a/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift +++ b/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift @@ -13,12 +13,11 @@ extension SQLEditorCoordinator { guard let controller, let textView = controller.textView, textView.isEditable else { return } let string = textView.string let scope = FormatScopeResolver.resolve(fullText: string, selectedRange: textView.selectedRange()) - let resolvedType = databaseType ?? .mysql let replacements = InvisibleCharacterRemover.replacements( in: string as NSString, scope: scope.range, skippingLiteralsAndComments: !scope.isSelection, - grammar: resolvedType.lexicalGrammar, + grammar: lexicalGrammar, lineEnding: textView.layoutManager.detectedLineEnding.rawValue ) guard !replacements.isEmpty else { return } diff --git a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift index 6a5697d164..61f0e35e5a 100644 --- a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -71,6 +71,11 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie } func scheduleEditorFocusClaim() { + // Query tabs share one editor, which installs only once, so a later tab's claim is taken here. + if hasInstalledEditorServices, !isDestroyed, let textView = controller?.textView, let window = textView.window { + window.makeFirstResponder(textView) + return + } focusClaimPending = true } @@ -115,6 +120,9 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie private var vimCursorManager: VimCursorManager? var onCloseTab: (() -> Void)? var onExecuteQuery: (() -> Void)? + var onSelectTab: ((Int) -> Void)? + var onSelectAdjacentTab: ((Int) -> Void)? + var tabCount: (() -> Int)? var onRunStatement: ((String, Int) -> Bool)? var currentAIAvailability: (() -> AIQueryActionAvailability)? var onAIAction: ((AIQueryAction, AIQueryTarget) -> Void)? @@ -123,6 +131,12 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie var tabID: UUID? var connectionId: UUID? var sourceFileURL: URL? + /// The connection's execution grammar, which `SQLEditorController` sets from the live session. + private(set) var sessionGrammar: SQLLexicalGrammar? + + var lexicalGrammar: SQLLexicalGrammar { + sessionGrammar ?? (databaseType ?? .mysql).lexicalGrammar + } /// Whether the editor text view is currently the first responder. /// Used to guard cursor propagation — when the find panel highlights @@ -183,7 +197,7 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie installFoldPreview(controller: controller) installStatementRunControls(controller: controller) installInlineSuggestionManager(controller: controller) - diagnosticsController.configure(databaseType: databaseType) + diagnosticsController.configure(databaseType: databaseType, grammar: lexicalGrammar) diagnosticsController.install(on: controller) diagnosticsController.scheduleRefresh(for: controller) applyKeyBindings(controller: controller) @@ -323,6 +337,9 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie releaseWindowSentinel() onCloseTab = nil onExecuteQuery = nil + onSelectTab = nil + onSelectAdjacentTab = nil + tabCount = nil onRunStatement = nil statementRunController.onRun = nil statementRunController.clear(in: controller) @@ -351,7 +368,7 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie } private func installStatementRunControls(controller: TextViewController) { - statementRunController.grammar = (databaseType ?? .mysql).lexicalGrammar + statementRunController.grammar = lexicalGrammar statementRunController.statementModel = QueryStatementModel.forDatabaseType(databaseType ?? .mysql) statementRunController.isHighlightEnabled = AppSettingsManager.shared.editor.highlightCurrentStatement statementRunController.onRun = { [weak self] sql, offset in @@ -360,6 +377,21 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie statementRunController.install(on: controller) } + /// Moves the run controls and diagnostics to `grammar`, as a connect, a reconnect or a `SET` changes it. + func setSessionGrammar(_ grammar: SQLLexicalGrammar) { + guard grammar != sessionGrammar else { return } + sessionGrammar = grammar + guard hasInstalledEditorServices, !isDestroyed, let controller else { return } + statementRunController.grammar = grammar + statementRunController.refreshControls(in: controller) + statementRunController.refreshHighlight(in: controller) + diagnosticsController.configure(databaseType: databaseType, grammar: grammar) + refreshDiagnostics( + in: controller, + isLargeDocument: controller.textView.textStorage.length > Self.languageServiceLengthLimit + ) + } + /// Turns the run controls off for the length of a query, because a tab runs one at a time. func setStatementRunControlsEnabled(_ isEnabled: Bool) { statementRunController.setEnabled(isEnabled, in: controller) @@ -481,15 +513,15 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie guard let textView = controller?.textView else { return } let formatter: QueryFormatting do { - guard let made = try QueryFormatterFactory.make(for: databaseType, sourceFileURL: sourceFileURL) else { return } + guard let made = try QueryFormatterFactory.make( + for: databaseType, + sourceFileURL: sourceFileURL, + grammar: formatGrammar + ) else { return } formatter = made } catch { Self.logger.error("Formatter rules unreadable: \(error.publicLogShape, privacy: .public)") - AlertHelper.showErrorSheet( - title: String(localized: "Couldn't Format Query"), - message: error.localizedDescription, - window: textView.window - ) + showFormatError(error, in: textView.window) return } let scope = FormatScopeResolver.resolve( @@ -513,9 +545,24 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie controller?.setCursorPositions([CursorPosition(range: NSRange(location: caretLocation, length: 0))]) } catch { Self.logger.error("SQL Formatting error: \(error.publicLogShape, privacy: .public)") + showFormatError(error, in: textView.window) } } + private var formatGrammar: SQLLexicalGrammar? { + guard let databaseType else { return nil } + guard let connectionId else { return databaseType.lexicalGrammar } + return SQLLexicalResolver.executionGrammar(for: databaseType, connectionId: connectionId) + } + + private func showFormatError(_ error: Error, in window: NSWindow?) { + AlertHelper.showErrorSheet( + title: String(localized: "Couldn't Format Query"), + message: error.localizedDescription, + window: window + ) + } + // MARK: - Inline Suggestion Manager private func installInlineSuggestionManager(controller: TextViewController) { @@ -666,6 +713,15 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie commandHandler.onCloseTab = { [weak self] in self?.onCloseTab?() } + commandHandler.onSelectTab = { [weak self] number in + self?.onSelectTab?(number) + } + commandHandler.onSelectAdjacentTab = { [weak self] offset in + self?.onSelectAdjacentTab?(offset) + } + commandHandler.tabCount = { [weak self] in + self?.tabCount?() ?? 0 + } engine.onCommand = { [weak self] command in self?.commandHandler.handle(command) } diff --git a/apple/TablePro/Views/Export/ExportDialog.swift b/apple/TablePro/Views/Export/ExportDialog.swift index be3031f153..b23a834de8 100644 --- a/apple/TablePro/Views/Export/ExportDialog.swift +++ b/apple/TablePro/Views/Export/ExportDialog.swift @@ -30,6 +30,7 @@ struct ExportDialog: View { /// Which object kinds the last load actually read, so a format switch knows whether the tree it /// already holds can answer for the new format without another round trip. @State private var loadedObjectKinds: Set = [] + @State private var formatReconciliation: Task? /// Bumped each time a reload replaces the tree, so a container read started against the old /// tree cannot land in the new one. @@ -159,10 +160,6 @@ struct ExportDialog: View { restoreSettingsSnapshot() } } - .onChange(of: config.formatId) { _ in - resetOptionValues() - Task { await reconcileObjectKindsForFormat() } - } .onExitCommand { if !isExporting { isPresented = false @@ -238,7 +235,7 @@ struct ExportDialog: View { get: { config.formatId }, set: { newValue in guard let format = formatsToOffer.first(where: { Self.offerTag(for: $0) == newValue }) else { - config.formatId = newValue + selectFormat(newValue) return } Task { await installOfferedFormat(format) } @@ -254,16 +251,30 @@ struct ExportDialog: View { if let settable = plugin as? any SettablePluginDiscoverable { settingsSnapshot?.include(settable) } - config.formatId = format.formatId + selectFormat(format.formatId) } - private var currentOptionColumnCount: Int { - guard let plugin = currentPlugin else { return 0 } - return type(of: plugin).perTableOptionColumns.count + // One path for every format change, so a profile can wait for the reset and reload instead of racing it. + @discardableResult + private func selectFormat(_ formatId: String) -> Task? { + guard formatId != config.formatId else { return formatReconciliation } + config.formatId = formatId + resetOptionValues() + let reconciliation = Task { await reconcileObjectKindsForFormat() } + formatReconciliation = reconciliation + return reconciliation } - private var currentDefaultOptionValues: [Bool] { - currentPlugin?.defaultTableOptionValues() ?? [] + private var optionLayout: ExportOptionLayout { + guard let plugin = currentPlugin else { return .none } + let pluginType = type(of: plugin) + return ExportOptionLayout( + columns: pluginType.perTableOptionColumns, + defaultValues: plugin.defaultTableOptionValues(), + readsRows: !ExportFormatCatalog.writesStructureOnly(plugin) + ) { columnId, kind in + pluginType.supportsOption(columnId: columnId, for: kind) + } } // MARK: - Layout Constants @@ -293,15 +304,6 @@ struct ExportDialog: View { profileMenu Spacer() - - if let plugin = currentPlugin { - ForEach(type(of: plugin).perTableOptionColumns) { column in - Text(column.label) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.secondary) - .frame(width: column.width, alignment: .center) - } - } } .padding(.horizontal, 12) .padding(.vertical, 10) @@ -336,6 +338,7 @@ struct ExportDialog: View { ExportObjectTreeView( databaseItems: $databaseItems, formatId: config.formatId, + options: optionLayout, databaseType: databaseType, loadColumns: { await columnNames(for: $0) }, onNeedsObjects: { databaseID in @@ -418,18 +421,19 @@ struct ExportDialog: View { private func applyProfile(_ profile: ExportProfile) async { profileApplication &+= 1 let application = profileApplication + let reconcile = availableFormatIds.contains(profile.formatId) + ? selectFormat(profile.formatId) + : formatReconciliation + let formatId = config.formatId + await reconcile?.value let named = Set(profile.entries.map(\.container)) for item in databaseItems where item.hasUnloadedObjects && named.contains(item.name) { + guard application == profileApplication else { return } await loadUnloadedObjects(databaseID: item.id) } - // A profile picked while this one waited for its reads is the user's latest choice. - guard application == profileApplication else { return } - if availableFormatIds.contains(profile.formatId) { - config.formatId = profile.formatId - } - databaseItems = normalizedForCurrentFormat( - ExportProfileStorage.apply(profile, to: databaseItems) - .clearingRowScopes(unavailableOn: databaseType)) + // A profile or a format picked while this one waited for its reads is the user's latest choice. + guard application == profileApplication, formatId == config.formatId else { return } + databaseItems = databaseItems.applyingProfile(profile, layout: optionLayout, databaseType: databaseType) } private func saveProfile() { @@ -653,33 +657,24 @@ struct ExportDialog: View { @MainActor private func reconcileObjectKindsForFormat() async { guard case .tables(let connection, _) = mode else { return } - let wanted = supportedObjectKinds - guard !loadedObjectKinds.isEmpty else { return } - guard wanted.isSubset(of: loadedObjectKinds) else { - await loadDatabaseItems(of: connection) + switch ExportKindReconciliation.next(loadedKinds: loadedObjectKinds, wanted: supportedObjectKinds) { + case .unchanged: return + case .narrowed(let kinds): + databaseItems = databaseItems.narrowed(to: kinds) + loadedObjectKinds = kinds + case .reload: + await loadDatabaseItems(of: connection) } - databaseItems = databaseItems.narrowed(to: wanted) } private func resetOptionValues() { databaseItems = normalizedForCurrentFormat( - databaseItems.resettingOptionValues(to: currentDefaultOptionValues)) + databaseItems.resettingOptionValues(to: optionLayout.defaultValues)) } - /// Aligns every row's option values with the chosen format's columns and clears the ones the - /// row's kind does not support, so a routine never carries a `Data` flag that would count it as - /// exportable for a phase it has no rows for. private func normalizedForCurrentFormat(_ items: [ExportDatabaseItem]) -> [ExportDatabaseItem] { - let normalized = items.normalizingOptionValues( - optionColumnCount: currentOptionColumnCount, - defaultOptionValues: currentDefaultOptionValues - ) - guard let plugin = currentPlugin else { return normalized } - let pluginType = type(of: plugin) - return normalized.maskingUnsupportedOptions(columns: pluginType.perTableOptionColumns) { columnId, kind in - pluginType.supportsOption(columnId: columnId, for: kind) - } + items.normalized(for: optionLayout) } // MARK: - Actions @@ -769,13 +764,18 @@ struct ExportDialog: View { @MainActor private func loadDatabaseItems(of connection: DatabaseConnection) async { let priorRows = ExportTreeBuilder.snapshots(of: databaseItems) + let kinds = supportedObjectKinds do { let items = try await treeBuilder(for: connection).build(priorRows: priorRows) - loadedObjectKinds = supportedObjectKinds + loadedObjectKinds = kinds treeGeneration += 1 databaseItems = normalizedForCurrentFormat(items.adoptingIdentities(of: databaseItems)) isLoading = false applyDefaultFileName(for: connection) + // The format changed while this read ran, and its reconcile had no tree to work on yet. + if kinds != supportedObjectKinds { + await reconcileObjectKindsForFormat() + } } catch is CancellationError { isLoading = false } catch { @@ -845,12 +845,11 @@ struct ExportDialog: View { guard generation == treeGeneration, let index = databaseItems.firstIndex(where: { $0.id == databaseID }) else { return } - let merged = databaseItems[index].mergingLoadedObjects(loaded) - guard !merged.objects.isEmpty, let normalized = normalizedForCurrentFormat([merged]).first else { + guard let merged = databaseItems[index].mergingLoadedObjects(loaded, layout: optionLayout) else { databaseItems.remove(at: index) return } - databaseItems[index] = normalized + databaseItems[index] = merged } private func applyDefaultFileName(for connection: DatabaseConnection) { diff --git a/apple/TablePro/Views/Export/ExportObjectRows.swift b/apple/TablePro/Views/Export/ExportObjectRows.swift index 05c1658dc4..bef475a196 100644 --- a/apple/TablePro/Views/Export/ExportObjectRows.swift +++ b/apple/TablePro/Views/Export/ExportObjectRows.swift @@ -95,16 +95,11 @@ internal struct ExportTreeContainerRow: View { } } -/// One object row. The option toggles stay positionally aligned with the format's full column list -/// for every kind, so a column this kind does not support leaves an empty slot of the same width -/// rather than shifting the ones after it. +// The checkbox only includes or excludes the object; what it writes is in the option columns. internal struct ExportTreeObjectRow: View { internal let object: ExportObjectItem internal let offersRowScope: Bool - internal let optionColumns: [PluginExportOptionColumn] - internal let supportsOption: (String, PluginExportObjectKind) -> Bool - internal let setSelected: (Bool) -> Void - internal let setOption: (Int, Bool) -> Void + internal let toggle: () -> Void internal let setRowScope: (PluginExportRowScope) -> Void internal let loadColumns: () async -> [String] @@ -114,21 +109,10 @@ internal struct ExportTreeObjectRow: View { internal var body: some View { HStack(spacing: 4) { - if optionColumns.isEmpty { - Toggle(object.name, isOn: Binding(get: { object.isSelected }, set: setSelected)) - .toggleStyle(.checkbox) - .labelsHidden() - .frame(width: 18) - } else { - TristateCheckbox( - state: checkboxState, - accessibilityLabel: object.name, - accessibilityValue: ExportObjectKindPresentation.checkboxValue(for: checkboxState) - ) { - setSelected(checkboxState != .checked) - } + Toggle(object.name, isOn: Binding(get: { object.isSelected }, set: { _ in toggle() })) + .toggleStyle(.checkbox) + .labelsHidden() .frame(width: 18) - } Image(systemName: ExportObjectKindPresentation.iconName(for: object.kind)) .foregroundStyle(ExportObjectKindPresentation.iconColor(for: object.kind)) @@ -153,12 +137,6 @@ internal struct ExportTreeObjectRow: View { if offersRowScope { rowScopeButton } - - if !optionColumns.isEmpty { - ForEach(Array(optionColumns.enumerated()), id: \.element.id) { index, column in - optionToggle(index: index, column: column) - } - } } } @@ -195,31 +173,4 @@ internal struct ExportTreeObjectRow: View { } } } - - @ViewBuilder - private func optionToggle(index: Int, column: PluginExportOptionColumn) -> some View { - if supportsOption(column.id, object.kind) { - Toggle(column.label, isOn: Binding( - get: { object.optionValues[safe: index] ?? column.defaultValue }, - set: { setOption(index, $0) } - )) - .toggleStyle(.checkbox) - .labelsHidden() - .disabled(!object.isSelected) - .frame(width: column.width, alignment: .center) - } else { - Color.clear.frame(width: column.width, height: 1) - } - } - - private var checkboxState: TristateCheckbox.State { - guard object.isSelected else { return .unchecked } - let supported = optionColumns.indices.filter { - supportsOption(optionColumns[$0].id, object.kind) - } - guard !supported.isEmpty else { return .checked } - let onCount = supported.count { object.optionValues[safe: $0] == true } - if onCount == 0 { return .unchecked } - return onCount == supported.count ? .checked : .mixed - } } diff --git a/apple/TablePro/Views/Export/ExportObjectTreeView.swift b/apple/TablePro/Views/Export/ExportObjectTreeView.swift index 31ac4afeaf..a2129390d7 100644 --- a/apple/TablePro/Views/Export/ExportObjectTreeView.swift +++ b/apple/TablePro/Views/Export/ExportObjectTreeView.swift @@ -14,9 +14,13 @@ import TableProPluginKit /// bindings are re-driven during an animated outline diff. That is the same Apple bug the /// connection sidebar's database tree hit, and the same answer: `NSOutlineView`, which also gives /// genuinely lazy children for a database holding thousands of objects. +/// +/// Option columns are real table columns, so each heading sits over its checkboxes whatever the +/// scroller style. internal struct ExportObjectTreeView: NSViewRepresentable { @Binding internal var databaseItems: [ExportDatabaseItem] internal let formatId: String + internal let options: ExportOptionLayout internal let databaseType: DatabaseType /// Reads one object's column names for the row-scope popover. Supplied by the dialog, which is @@ -34,6 +38,9 @@ internal struct ExportObjectTreeView: NSViewRepresentable { let outlineView = CheckboxOutlineView() outlineView.headerView = nil outlineView.style = .plain + outlineView.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle + outlineView.intercellSpacing = NSSize(width: 4, height: 0) + outlineView.allowsColumnReordering = false outlineView.rowSizeStyle = .custom outlineView.rowHeight = 24 outlineView.allowsMultipleSelection = true @@ -48,7 +55,8 @@ internal struct ExportObjectTreeView: NSViewRepresentable { } outlineView.setAccessibilityLabel(String(localized: "Objects to export")) - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ExportObjectColumn")) + let column = NSTableColumn(identifier: ExportObjectTreeCoordinator.nameColumn) + column.title = String(localized: "Name") column.resizingMask = .autoresizingMask outlineView.addTableColumn(column) outlineView.outlineTableColumn = column @@ -71,8 +79,14 @@ internal struct ExportObjectTreeView: NSViewRepresentable { } } +fileprivate extension NSUserInterfaceItemIdentifier { + static let exportOptionCell = NSUserInterfaceItemIdentifier("ExportOptionCell") +} + @MainActor internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { + internal static let nameColumn = NSUserInterfaceItemIdentifier("ExportObjectColumn") + internal var owner: ExportObjectTreeView private weak var outlineView: NSOutlineView? @@ -105,6 +119,9 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou let formatChanged = formatId != self.formatId self.databases = databases self.formatId = formatId + if formatChanged { + syncOptionColumns() + } guard fingerprint != shapeFingerprint else { guard formatChanged else { @@ -135,7 +152,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou guard let identities = pendingReloadIdentities else { let rows = IndexSet(integersIn: 0 ..< outlineView.numberOfRows) guard !rows.isEmpty else { return } - outlineView.reloadData(forRowIndexes: rows, columnIndexes: IndexSet(integer: 0)) + outlineView.reloadData(forRowIndexes: rows, columnIndexes: allColumns(of: outlineView)) return } pendingReloadIdentities = nil @@ -147,7 +164,41 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou rows.insert(row) } guard !rows.isEmpty else { return } - outlineView.reloadData(forRowIndexes: rows, columnIndexes: IndexSet(integer: 0)) + outlineView.reloadData(forRowIndexes: rows, columnIndexes: allColumns(of: outlineView)) + } + + private func allColumns(of outlineView: NSOutlineView) -> IndexSet { + IndexSet(integersIn: 0 ..< outlineView.numberOfColumns) + } + + // As wide as the plugin asks or the translated heading needs. + private func syncOptionColumns() { + guard let outlineView else { return } + for column in outlineView.tableColumns where column.identifier != Self.nameColumn { + outlineView.removeTableColumn(column) + } + let options = owner.options.columns + outlineView.headerView = options.isEmpty ? nil : NSTableHeaderView() + for (index, option) in options.enumerated() { + let column = NSTableColumn( + identifier: NSUserInterfaceItemIdentifier("ExportOptionColumn.\(index).\(option.id)") + ) + column.title = option.label + column.headerCell.alignment = NSTextAlignment.center + let width = max(option.width, ceil(column.headerCell.cellSize.width) + 8) + column.width = width + column.minWidth = width + column.maxWidth = width + column.resizingMask = [] + outlineView.addTableColumn(column) + } + } + + private func optionIndex(of tableColumn: NSTableColumn?) -> Int? { + guard let tableColumn else { return nil } + let parts = tableColumn.identifier.rawValue.split(separator: ".") + guard parts.count >= 2, parts[0] == "ExportOptionColumn", let index = Int(parts[1]) else { return nil } + return owner.options.columns.indices.contains(index) ? index : nil } /// What the builder opened, read once per container when the tree first meets it, so a reload @@ -238,22 +289,34 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return false } - /// Space toggles every selected row, which is what the checkbox in each of them does on a - /// click. A container row toggles everything beneath it, exactly as clicking its checkbox does. + // One direction for every row: on unless every target is already checked. internal func toggleSelectedRows() { guard let outlineView else { return } - let nodes = outlineView.selectedRowIndexes.compactMap { + let selected = outlineView.selectedRowIndexes.compactMap { outlineView.item(atRow: $0) as? ExportOutlineNode } - guard !nodes.isEmpty else { return } - for node in nodes { + // The rows a click could change, minus any row under another of them: toggling both flipped it back. + let actionable = selected.filter { node in + if case .pending = node.kind { return false } + return selectionIsEnabled(for: node) + } + let identities = Set(actionable.map(\.identity)) + let targets = actionable.filter { node in + var parent = outlineView.parent(forItem: node) as? ExportOutlineNode + while let ancestor = parent { + if identities.contains(ancestor.identity) { return false } + parent = outlineView.parent(forItem: ancestor) as? ExportOutlineNode + } + return true + } + guard !targets.isEmpty else { return } + let turningOn = targets.contains { selectionState(for: $0) != .checked } + for node in targets { switch node.kind { case .database, .group: - toggleContainer(node) + setContainer(node, selected: turningOn) case .object(let databaseID, let objectID): - let isSelected = databases.first(where: { $0.id == databaseID })? - .objects.first(where: { $0.id == objectID })?.isSelected ?? false - setSelection(!isSelected, databaseID: databaseID, objectID: objectID) + setSelection(turningOn, databaseID: databaseID, objectID: objectID) case .pending: continue } @@ -279,6 +342,9 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou item: Any ) -> NSView? { guard let node = item as? ExportOutlineNode else { return nil } + if let index = optionIndex(of: tableColumn) { + return optionCell(column: index, node: node, in: outlineView) + } let identifier = NSUserInterfaceItemIdentifier("ExportObjectRow") let cell = outlineView.makeView(withIdentifier: identifier, owner: self) as? HostingTableCellView ?? HostingTableCellView(identifier: identifier) @@ -308,6 +374,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou state: containerState(for: node), toggle: { [weak self] in self?.toggleContainer(node) } ) + .disabled(!selectionIsEnabled(for: node)) ) case .group(_, let objectKind): return AnyView( @@ -318,6 +385,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou state: containerState(for: node), toggle: { [weak self] in self?.toggleContainer(node) } ) + .disabled(!selectionIsEnabled(for: node)) ) case .object(let databaseID, let objectID): guard let databaseIndex = databases.firstIndex(where: { $0.id == databaseID }), @@ -329,14 +397,9 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return AnyView( ExportTreeObjectRow( object: object, - offersRowScope: object.offersRowScope(on: owner.databaseType) && formatReadsRows, - optionColumns: optionColumns, - supportsOption: supportsOption, - setSelected: { [weak self] isSelected in - self?.setSelection(isSelected, databaseID: databaseID, objectID: objectID) - }, - setOption: { [weak self] index, value in - self?.setOption(index, to: value, databaseID: databaseID, objectID: objectID) + offersRowScope: object.offersRowScope(on: owner.databaseType) && owner.options.readsRows, + toggle: { [weak self] in + self?.toggleObject(databaseID: databaseID, objectID: objectID) }, setRowScope: { [weak self] scope in self?.setRowScope(scope, databaseID: databaseID, objectID: objectID) @@ -360,30 +423,6 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou // MARK: - Selection - private var plugin: (any ExportFormatPlugin)? { - PluginManager.shared.exportPlugin(forFormat: formatId) - } - - // A format that writes structure alone ignores a row scope, so it offers none. - private var formatReadsRows: Bool { - plugin.map { !ExportFormatCatalog.writesStructureOnly($0) } ?? true - } - - private var optionColumns: [PluginExportOptionColumn] { - guard let plugin else { return [] } - return type(of: plugin).perTableOptionColumns - } - - private var defaultOptionValues: [Bool] { - plugin?.defaultTableOptionValues() ?? [] - } - - private var supportsOption: (String, PluginExportObjectKind) -> Bool { - guard let plugin else { return { _, _ in true } } - let pluginType = type(of: plugin) - return { columnId, kind in pluginType.supportsOption(columnId: columnId, for: kind) } - } - private func objects(under node: ExportOutlineNode) -> [ExportObjectItem] { switch node.kind { case .database(let databaseID): @@ -408,8 +447,21 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return database } - private func containerState(for node: ExportOutlineNode) -> TristateCheckbox.State { + // An object a column checkbox turned all off stays out of a tick, or click order would decide the dump. + private func selectableObjects(under node: ExportOutlineNode) -> [ExportObjectItem] { + objects(under: node).filter(owner.options.writesSomething) + } + + // A container where every loaded object writes nothing and no unread kind can arrive: a tick would + // select nothing. An object row stays enabled, since ticking it restores the format's defaults. + internal func selectionIsEnabled(for node: ExportOutlineNode) -> Bool { + if case .object = node.kind { return true } let items = objects(under: node) + return items.isEmpty || unloadedDatabase(at: node) != nil || items.contains(where: owner.options.writesSomething) + } + + internal func containerState(for node: ExportOutlineNode) -> TristateCheckbox.State { + let items = selectableObjects(under: node) guard !items.isEmpty else { return unloadedDatabase(at: node)?.selectsLoadedObjects == true ? .checked : .unchecked } @@ -418,14 +470,24 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return selected == items.count ? .checked : .mixed } + private func selectionState(for node: ExportOutlineNode) -> TristateCheckbox.State { + guard case .object(let databaseID, let objectID) = node.kind else { return containerState(for: node) } + return object(databaseID: databaseID, objectID: objectID)?.isSelected == true ? .checked : .unchecked + } + + private func object(databaseID: UUID, objectID: UUID) -> ExportObjectItem? { + databases.first(where: { $0.id == databaseID })?.objects.first(where: { $0.id == objectID }) + } + + private func toggleContainer(_ node: ExportOutlineNode) { + setContainer(node, selected: containerState(for: node) != .checked) + } + /// Ticking a container whose kinds are not all read yet also asks for them, and they arrive /// selected unless something in it is unticked first. - private func toggleContainer(_ node: ExportOutlineNode) { - let items = objects(under: node) + private func setContainer(_ node: ExportOutlineNode, selected turningOn: Bool) { + let items = turningOn ? selectableObjects(under: node) : objects(under: node) let unloaded = unloadedDatabase(at: node) - let turningOn = items.isEmpty - ? !(unloaded?.selectsLoadedObjects ?? true) - : items.contains { !$0.isSelected } let ids = Set(items.map(\.id)) var reloading: Set = [] if let unloaded { reloading.insert("db:\(unloaded.id.uuidString)") } @@ -447,6 +509,95 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou normalizeOptions(for: ids) } + internal func toggleObject(databaseID: UUID, objectID: UUID) { + guard let object = object(databaseID: databaseID, objectID: objectID) else { return } + setSelection(!object.isSelected, databaseID: databaseID, objectID: objectID) + } + + // MARK: - Option columns + + private func optionCell(column index: Int, node: ExportOutlineNode, in outlineView: NSOutlineView) -> NSView? { + let label = owner.options.columns[index].label + let state: NSControl.StateValue + let isEnabled: Bool + let title: String + let action: () -> Void + switch node.kind { + case .database, .group: + guard let containerState = optionState(ofColumn: index, under: node) else { return nil } + state = containerState + isEnabled = true + title = containerTitle(of: node) + action = { [weak self] in self?.toggleOption(index, under: node) } + case .object(let databaseID, let objectID): + guard let object = object(databaseID: databaseID, objectID: objectID), + owner.options.supports(columnAt: index, kind: object.kind) + else { return nil } + let isOn = object.optionValues[safe: index] ?? owner.options.columns[index].defaultValue + state = isOn ? .on : .off + isEnabled = object.isSelected + title = object.name + action = { [weak self] in + self?.setOption(index, to: !isOn, databaseID: databaseID, objectID: objectID) + } + case .pending: + return nil + } + let cell = outlineView.makeView(withIdentifier: .exportOptionCell, owner: self) as? CheckboxTableCellView + ?? CheckboxTableCellView(identifier: .exportOptionCell) + cell.configure( + state: state, + isEnabled: isEnabled, + accessibilityLabel: String(format: String(localized: "%1$@ for %2$@"), label, title), + action: action + ) + return cell + } + + private func containerTitle(of node: ExportOutlineNode) -> String { + switch node.kind { + case .database(let databaseID): + return databases.first(where: { $0.id == databaseID })?.name ?? "" + case .group(_, let objectKind): + return ExportObjectKindPresentation.groupTitle(for: objectKind) + case .object, .pending: + return "" + } + } + + private func optionValues(ofColumn index: Int, under node: ExportOutlineNode) -> [Bool] { + let unloaded = unloadedDatabase(at: node) + return owner.options.values( + ofColumn: index, + in: objects(under: node), + unloadedKinds: unloaded?.unloadedKinds ?? [], + arriving: unloaded?.arrivingOptionValues ?? [:] + ) + } + + internal func optionState(ofColumn index: Int, under node: ExportOutlineNode) -> NSControl.StateValue? { + let values = optionValues(ofColumn: index, under: node) + guard !values.isEmpty else { return nil } + if values.allSatisfy({ $0 }) { return .on } + return values.contains(true) ? .mixed : .off + } + + // Never selects: on a container with nothing ticked it sets what each object carries once ticked. + internal func toggleOption(_ index: Int, under node: ExportOutlineNode) { + guard let state = optionState(ofColumn: index, under: node) else { return } + let turningOn = state != .on + let options = owner.options + let ids = Set(objects(under: node).filter { options.supports(columnAt: index, kind: $0.kind) }.map(\.id)) + let unloaded = unloadedDatabase(at: node).flatMap { database in + database.unloadedKinds.contains { options.supports(columnAt: index, kind: $0) } ? database : nil + } + var reloading: Set = [] + if let unloaded { reloading.insert("db:\(unloaded.id.uuidString)") } + mutateDatabases(touching: ids, reloading: reloading) { databases in + databases = databases.settingOption(index, to: turningOn, objectIDs: ids, arrivingIn: unloaded?.id) + } + } + private func setSelection(_ isSelected: Bool, databaseID: UUID, objectID: UUID) { mutateDatabases(touching: [objectID]) { databases in guard let databaseIndex = databases.firstIndex(where: { $0.id == databaseID }), @@ -461,10 +612,10 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou /// A row selected with every one of its options off would be counted as selected and export /// nothing, so selecting it restores the format's defaults for the kinds that support them. private func normalizeOptions(for ids: Set) { - let columns = optionColumns + let columns = owner.options.columns guard !columns.isEmpty else { return } - let defaults = defaultOptionValues - let supports = supportsOption + let defaults = owner.options.defaultValues + let supports = owner.options.supports mutateDatabases(touching: ids) { databases in for databaseIndex in databases.indices { for objectIndex in databases[databaseIndex].objects.indices @@ -483,13 +634,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou private func setOption(_ index: Int, to value: Bool, databaseID: UUID, objectID: UUID) { mutateDatabases(touching: [objectID]) { databases in - guard let databaseIndex = databases.firstIndex(where: { $0.id == databaseID }), - let objectIndex = databases[databaseIndex].objects.firstIndex(where: { $0.id == objectID }), - databases[databaseIndex].objects[objectIndex].optionValues.indices.contains(index) - else { return } - databases[databaseIndex].objects[objectIndex].optionValues[index] = value - databases[databaseIndex].objects[objectIndex].isSelected = - databases[databaseIndex].objects[objectIndex].optionValues.contains(true) + databases = databases.settingOption(index, to: value, objectIDs: [objectID]) } } diff --git a/apple/TablePro/Views/Export/ExportTreeBuilder.swift b/apple/TablePro/Views/Export/ExportTreeBuilder.swift index 00d3cf425e..eeaf5bfc78 100644 --- a/apple/TablePro/Views/Export/ExportTreeBuilder.swift +++ b/apple/TablePro/Views/Export/ExportTreeBuilder.swift @@ -150,6 +150,7 @@ struct ExportRowSnapshot { let kind: PluginExportObjectKind let isSelected: Bool let optionValues: [Bool] + var rowScope: PluginExportRowScope = .unrestricted } /// Builds the export dialog's container tree. @@ -191,7 +192,8 @@ struct ExportTreeBuilder { container: database.name, kind: object.kind, isSelected: object.isSelected, - optionValues: object.optionValues + optionValues: object.optionValues, + rowScope: object.rowScope ) } } @@ -443,7 +445,8 @@ struct ExportTreeBuilder { inContainer: containerRef, isCurrentContainer: isCurrentContainer ), - optionValues: priorRow?.optionValues ?? [] + optionValues: priorRow?.optionValues ?? [], + rowScope: priorRow?.rowScope ?? .unrestricted ) } } diff --git a/apple/TablePro/Views/Import/ImportDialog.swift b/apple/TablePro/Views/Import/ImportDialog.swift index dc8ad9a6af..7763ff9013 100644 --- a/apple/TablePro/Views/Import/ImportDialog.swift +++ b/apple/TablePro/Views/Import/ImportDialog.swift @@ -443,7 +443,7 @@ struct ImportDialog: View { forPreview: previewData, isWholeFile: previewData.count < maxPreviewSize, family: TransactionEngineFamily.of(connection.type), - grammar: connection.type.lexicalGrammar + grammar: sessionGrammar ) { Self.logger.info("Preview did not decode as \(selectedEncoding.rawValue, privacy: .public); switching to the detected \(detected.rawValue, privacy: .public)") detectedEncoding = detected @@ -464,6 +464,12 @@ struct ImportDialog: View { } } + /// What the import itself splits with, so the count and the detected encoding match the run. + @MainActor + private var sessionGrammar: SQLLexicalGrammar { + SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id) + } + @MainActor private func countStatements(url: URL) async { isCountingStatements = true @@ -471,7 +477,7 @@ struct ImportDialog: View { do { let encoding = selectedEncoding.encoding - let grammar = connection.type.lexicalGrammar + let grammar = sessionGrammar let parser = SQLFileParser() let count = try await Task.detached { try await parser.countStatements(url: url, encoding: encoding, grammar: grammar) diff --git a/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift b/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift index d23b875f30..099d929d3d 100644 --- a/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift +++ b/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift @@ -154,11 +154,13 @@ struct IntegrationsActivityLogPane: View { .disabled(isLoading) } + /// `sidebar.trailing` is the glyph AppKit's own inspector toggle draws, and unlike + /// `sidebar.right` it mirrors in a right-to-left layout. private var inspectorToggle: some View { Button { showInspector.toggle() } label: { - Label(String(localized: "Details"), systemImage: "sidebar.right") + Label(String(localized: "Details"), systemImage: "sidebar.trailing") } .help(String(localized: "Show details")) } diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index 366fd67835..e96674225f 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -11,12 +11,6 @@ import SwiftUI import TableProEditorKit import TableProPluginKit -// A change to either field cancels the old task and starts a new one, which coalesces rapid Cmd+Number switches. -private struct TabLoadKey: Hashable { - let tabId: UUID? - let loadEpoch: Int -} - // Only a tab showing rows reruns: an error, a plan or a write's count is not a view of the new database's data. @MainActor internal enum ContainerChangeRerunPolicy { @@ -169,10 +163,7 @@ struct MainEditorContentView: View { .onAppear { updateHasQueryText() } - .task(id: TabLoadKey( - tabId: tabManager.selectedTabId, - loadEpoch: tabManager.selectedTab?.loadEpoch ?? 0 - )) { + .task(id: tabManager.selectedTabId) { coordinator.lazyLoadCurrentTabIfNeeded() } } @@ -219,6 +210,9 @@ struct MainEditorContentView: View { versionHistoryContent(tab: tab) case .tableListing: tableListingContent(tab: tab) + case .routineDebug: + RoutineDebugTabContent(tabId: tab.id, databaseType: connection.type) + .id(tab.id) } } @@ -559,15 +553,15 @@ struct MainEditorContentView: View { private func createTableContent(tab: QueryTab) -> some View { Group { if let draft = coordinator.createTableDrafts[tab.id] { - CreateTableView( - connection: connection, - scope: structureScope(for: tab), - coordinator: coordinator, - draft: draft - ) + CreateTableView(draft: draft, coordinator: coordinator) } else { Color.clear - .onAppear { coordinator.createTableDrafts[tab.id] = CreateTableDraft() } + .onAppear { + coordinator.createTableDrafts[tab.id] = CreateTableDraft( + connection: connection, + scope: structureScope(for: tab) + ) + } } } .id(tab.id) @@ -582,13 +576,9 @@ struct MainEditorContentView: View { Group { if let session = coordinator.structureSessions[tab.id], session.identity == identity { TableStructureView( - tableName: tableName, - connection: connection, - databaseName: scope?.database ?? "", - schemaName: scope?.schema, + session: session, toolbarState: coordinator.toolbarState, - coordinator: coordinator, - session: session + coordinator: coordinator ) } else { Color.clear @@ -886,14 +876,12 @@ struct MainEditorContentView: View { private func statusBar(tab: QueryTab, selectedRowCount: Int) -> some View { let resolvedRows = resolvedTableRows(for: tab) - let structureFooter = coordinator.structureSessions[tab.id]?.footer ?? StructureFooterCapability() let isExecuting = coordinator.tabExecution.isBusy(tab.id) let snapshot = StatusBarSnapshot( tab: tab, tableRows: resolvedRows, displayRowCount: coordinator.displayIDs(forTab: tab.id)?.count, isFetching: isExecuting, - hasStructureActions: structureFooter.isActive, isQueryPlan: tab.display.activeExplainResult != nil, paginationCapability: coordinator.paginationCapability, hasServerOutput: !(tab.display.activeResultSet?.serverOutput.isEmpty ?? true), @@ -941,7 +929,6 @@ struct MainEditorContentView: View { onGoToPage: onGoToPage, onRequestExactCount: { coordinator.paginationCoordinator.requestExactRowCount() } ), - structureFooter: structureFooter, execution: ExecutionReadout( tabId: tab.id, execution: coordinator.tabExecution, @@ -962,9 +949,7 @@ struct MainEditorContentView: View { } }, onToggleFilters: { coordinator.toggleFilterPanel() }, - onFetchAll: { coordinator.fetchAllRows() }, - onStructureAdd: { coordinator.structureActions?.addRow?() }, - onStructureRemove: { coordinator.structureActions?.removeRow?() } + onFetchAll: { coordinator.fetchAllRows() } ) } @@ -1215,6 +1200,9 @@ extension MainEditorContentView { coordinator.commandActions?.closeTab() }, onExecuteQuery: { coordinator.runQuery(viewport: .firstRow) }, + onSelectTab: { number in coordinator.commandActions?.selectTab(number: number) }, + onSelectAdjacentTab: { offset in coordinator.commandActions?.selectTab(offsetBy: offset) }, + tabCount: { coordinator.tabManager.visibleTabs.count }, onRunStatement: { sql, offset in coordinator.runStatement(sql, sourceOffset: offset) }, isExecuting: isExecuting, currentAIAvailability: { coordinator.aiQueryActionAvailability }, diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift index 268fc780cf..be655cf9ae 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift @@ -143,6 +143,8 @@ extension MainContentCoordinator { if let schemaModel = activeGridModelProvider(for: tab)?.activeGridModel { committed = schemaModel.commitEditing() && committed } + // A run of typing in the structure inspector ends, so the command's undo step stands apart from it. + activeStructureEditor?.commitPendingEdits() return committed } @@ -235,9 +237,9 @@ extension MainContentCoordinator { dataGridEntries[tabId]?.displayInputs = inputs } - // Each model holds a whole result's formatted text, so only the tabs in use keep theirs. + // The only memory a tab switch releases: formatted text, rebuilt per drawn cell. Rows stay until the user replaces them. func pruneGridDisplayCaches() { - let budget = MemoryPressureAdvisor.budgetForInactiveTabs() + 1 + let budget = GridDisplayCacheBudget.unmountedGrids() + 1 guard dataGridEntries.count > budget else { return } let ordered = dataGridEntries.sorted { $0.value.lastUsed > $1.value.lastUsed } for (tabId, entry) in ordered.dropFirst(budget) where !entry.model.isMounted { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift index 5415915038..8caead141f 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift @@ -10,6 +10,9 @@ extension MainContentCoordinator { if #available(macOS 14.0, *) { FeatureTipSignals.editorQueryRan() } + if request.wasSuccessful { + MacAnalyticsProvider.shared.markFirstQueryExecuted() + } } let recorder = services.queryHistoryManager return Task(priority: .utility) { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift index 1be85f35cf..f7b85bde40 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift @@ -1,9 +1,7 @@ import AppKit import Foundation -/// The single answer to "would closing or quitting destroy something the user cannot get back". -/// The close path (one tab) and the quit path (every tab of every connection) previously each -/// carried their own partial version of this and had drifted apart. +// The one answer to "would closing or quitting destroy something the user cannot get back", for close and quit alike. extension MainContentCoordinator { var hasPendingDestructiveTableOps: Bool { guard let session = DatabaseManager.shared.session(for: connectionId) else { return false } @@ -14,62 +12,34 @@ extension MainContentCoordinator { trailingPaneState?.inspector.editState.hasEdits ?? false } - /// Only the selected tab's editors are mounted, so only the selected tab has live state on the - /// coordinator to read. + // Only the selected tab's editors are mounted, so only it has live state on the coordinator. func isSelectedTab(_ tab: QueryTab) -> Bool { tabManager.selectedTabId == tab.id } - /// Work in one tab that only saving can recover. - /// - /// Where that work lives depends on whether the tab is selected, because a tab's editors are - /// mounted only while it is. The selected tab's live edits sit on the coordinator, in - /// `changeManager` and `toolbarState`; a background tab carries whatever was snapshotted into - /// `pendingChanges` the last time it was switched away from. Reading the snapshot for the - /// selected tab answers "no" for the gesture users make most, editing a cell and closing the - /// tab they are looking at, because nothing has switched away to write the snapshot yet. - /// - /// Connection-scoped work is deliberately absent. A staged TRUNCATE or an unsaved sidebar edit - /// belongs to the connection rather than to any one tab, so letting it gate a tab's close would - /// pose a question neither Save nor Don't Save could answer for the tab being closed. - /// - /// A scratch query tab is absent too, and that one is a decision rather than an omission: its - /// text is persisted with the tab and filed in `RecentlyClosedTabStore` on close, so it comes - /// back. Grid edits do not. `TabChangeSnapshot` is not `Codable` and `PersistedTab` carries no - /// change fields, so closing a table tab is the one gesture that destroys them for good. + // Connection-scoped work (a staged TRUNCATE, a sidebar edit) is left out: no tab's Save or Don't Save answers it. + // A scratch query's text is left out too: it is persisted and comes back from Reopen Closed Tab. func hasUnsavedWork(in tab: QueryTab?) -> Bool { guard let tab else { return false } return savability(of: tab) != .nothingAtRisk } - /// Whether one tab, named by id, is holding work a move would lose. - /// - /// Detaching a tab into its own window carries the `QueryTab` and nothing the coordinator holds - /// beside it, so this is what the command reads before offering itself. An id that names no tab - /// answers true: refusing a move is recoverable, performing one on a tab this window cannot see - /// is not. + // An unknown id answers true: refusing a move is recoverable, moving a tab this window cannot see is not. func hasUnsavedWork(forTab id: UUID) -> Bool { guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { return true } return savability(of: tab) != .nothingAtRisk } - /// What a save can actually do for one tab, which is not the same question as whether it holds - /// work. A batch close asks both: it saves what it can reach and leaves the rest open, so the - /// two answers have to come from one switch. Reading the categories apart, in a second - /// classifier beside this one, is how the alert and the save came to disagree about what Save - /// covers. + // One switch answers both "holds work" and "can Save reach it", so a batch close's alert and save cannot disagree. enum TabWorkSavability: Equatable { case nothingAtRisk - /// Reachable without the tab on screen: its file, and the ALTERs staged in its own session. + // Reachable without the tab on screen: its file, and the ALTERs staged in its own session. case saveable - /// Only the tab's own editors can save it: grid edits, staged principals, a table draft. + // Only the tab's own editors can save it: grid edits, staged principals, a table draft. case mountedOnly } - /// The unreachable kinds are asked about first. A tab can hold more than one: a query opened - /// from a file, edited, with unsaved cell edits in its result. Answering `.saveable` for the file - /// would let a close write the file and destroy the grid edits beside it, which is the loss this - /// category exists to prevent. + // Unreachable kinds first: answering `.saveable` for a dirty file would let a close drop the grid edits beside it. func savability(of tab: QueryTab) -> TabWorkSavability { guard isSelectedTab(tab) else { switch tab.tabType { @@ -90,12 +60,7 @@ extension MainContentCoordinator { tab.content.isFileDirty ? .saveable : .nothingAtRisk } - /// The selected tab's editors are mounted, so everything they hold is reachable and saving the - /// selected tab covers all of it. A deselected tab left its editors behind, which is why - /// `savability` answers for it from what they wrote down before they went: users and roles keeps - /// its own record because its view model outlives the view while `usersRolesActions` does not, - /// so the staged principals survive a deselect even though nothing on the coordinator can still - /// reach them to apply. + // Users & Roles keeps its own record because its view model outlives `usersRolesActions`. private func liveUnsavedWork(in tab: QueryTab) -> Bool { switch tab.tabType { case .usersRoles: @@ -104,15 +69,12 @@ extension MainContentCoordinator { return toolbarState.hasCreateTablePending || hasTableDraftWork(in: tab) default: return changeManager.hasChanges - || toolbarState.hasStructureChanges || hasStagedStructureEdits(in: tab) || hasStagedTypeChanges(in: tab) } } - /// Staged ALTERs read from the tab's own session rather than from `toolbarState`, which only - /// ever describes the tab on screen. A background tab keeps its session, so this is the only - /// answer that holds once the user has switched away from it. + // Read from the tab's session, mounted or not, so it holds in Data view and in a background tab. func hasStagedStructureEdits(in tab: QueryTab) -> Bool { (structureSessions[tab.id]?.changeManager.hasChanges ?? false) || (erDiagramModels[tab.id]?.holdsUnsavedWork ?? false) @@ -131,19 +93,12 @@ extension MainContentCoordinator { createTableDrafts[tab.id]?.holdsWork ?? false } - /// The dot on the tab and in the window's close button. Deliberately broader than - /// `hasUnsavedWork(in:)`: a scratch query tab shows the dot because its text is unsaved, yet - /// closing it asks nothing because the text comes back. It is never narrower, so anything that - /// would raise the save prompt is marked before the user reaches for the close button. + // Never narrower than `hasUnsavedWork(in:)`; broader for a scratch tab, whose text is unsaved but comes back. func showsUnsavedIndicator(for tab: QueryTab) -> Bool { tab.showsUnsavedIndicator || hasUnsavedWork(in: tab) } - /// Pushes the selected tab's answer onto the window's close button. - /// - /// The editor's text binding does this on every keystroke, which covers typing. A command that - /// changes the text without going through that binding has to say so itself, or the dot - /// describes the tab as it was before the command ran. + // For changes that bypass the editor's text binding, which pushes the dot on every keystroke itself. func refreshUnsavedIndicator() { guard let tab = tabManager.selectedTab, let window = contentWindow else { return } let showsIndicator = showsUnsavedIndicator(for: tab) @@ -156,13 +111,11 @@ extension MainContentCoordinator { changeManager.hasChanges || hasPendingDestructiveTableOps || hasSidebarEdits - || toolbarState.hasStructureChanges || toolbarState.hasCreateTablePending || tabManager.tabs.contains { hasUnsavedWork(in: $0) } } - /// Tabs resolved against live view state (cursor offset, sort column names) so a reopened tab - /// comes back where the user left it. + // Resolved against live view state (cursor, sort column names) so a reopened tab comes back where it was left. func tabsForRecoveryCapture() -> [QueryTab] { tabManager.tabs.map { enrichedForPersistence($0) } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index fd76bf1db0..1ca309a771 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -76,7 +76,7 @@ extension MainContentCoordinator { return } if isAutoLoad, services.databaseManager.driver(for: connectionId)?.status != .connected { - pendingLoadTrigger = trigger + parkedTableLoad = ParkedTableLoad(tabId: tabId, trigger: trigger) return } handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn, serverOutput: serverOutput) diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryTasks.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryTasks.swift index eae085f421..e3cfd3d489 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryTasks.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryTasks.swift @@ -24,7 +24,7 @@ extension MainContentCoordinator { traceConnectUnavailable(traceToken) guard tabExecution.settle(claim) else { return false } retireQueryTask(.claim(claim)) - pendingLoadTrigger = trigger + parkedTableLoad = ParkedTableLoad(tabId: claim.tabId, trigger: trigger) declineTableLoad(for: claim.tabId) return false } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 44ec500a21..5e1c694871 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -39,15 +39,22 @@ extension MainContentCoordinator { ) } ) + let viewModel = QuickSwitcherViewModel(connectionId: connectionId) let panelView = QuickSwitcherPanelView( - connectionId: connectionId, + viewModel: viewModel, databaseType: connection.type, openTables: openTables, browseSchema: browseSchema, onSelect: { [weak self] item, intent in self?.handleQuickSwitcherSelection(item, intent: intent) }, onDismiss: { [weak self] in self?.quickSwitcherPanel?.dismiss() } ) - quickSwitcherPanel.present(panelView, over: contentWindow, identity: Self.openQuicklyPanelIdentity) + quickSwitcherPanel.present( + panelView, + over: contentWindow, + identity: Self.openQuicklyPanelIdentity, + retainsPanel: true, + onClose: { [weak viewModel] in viewModel?.releaseResults() } + ) InteractionTracer.shared.end( .quickOpenPanel, key: connectionId.uuidString, after: quickSwitcherPanel.presentedWindow ) diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift index 244e8e2069..3bc6fa3447 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift @@ -58,7 +58,7 @@ extension MainContentCoordinator { return } if tab.display.resultsViewMode == .structure { - structureActions?.refresh?() + activeStructureSession?.refresh() return } reloadActiveTableData(hasPendingTableOps: hasPendingTableOps, onDiscard: onDiscard) diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift index 47db884572..3062fb2c1a 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift @@ -29,9 +29,11 @@ extension MainContentCoordinator { /// two. They all read this now. var canEditActiveResult: Bool { guard let tab = tabManager.selectedTab else { return false } + // A claimed execution replaces these rows and clears whatever was staged on them meanwhile, as the data files grid does when busy. return tab.tableContext.isEditable && tab.tableContext.allowsRowEditing && !safeModeLevel.blocksAllWrites && activeResultEditRefusal == nil + && !tabExecution.isExecuting(tab.id) } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift index ff9cafde52..3d10617af6 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift @@ -11,7 +11,7 @@ import TableProPluginKit extension MainContentCoordinator { // MARK: - SQL Preview - /// Routes SQL preview request to the appropriate handler based on current tab mode + // A Structure view with nothing staged previews the grid edits, which is what its Save would write. func handlePreviewSQL( pendingTruncates: Set, pendingDeletes: Set, @@ -24,9 +24,10 @@ extension MainContentCoordinator { } if let tabId = tabManager.selectedTabId, userTypeEditors[tabId]?.hasChanges == true { previewUserTypeChanges(tabId: tabId) - } else if tabManager.selectedTab?.display.resultsViewMode == .structure { - // Structure view handles its own preview via direct call - structureActions?.previewSQL?() + } else if tabManager.selectedTab?.tabType == .createTable { + activeCreateTableDraft?.previewSQL(coordinator: self) + } else if let session = activeStructureSession, session.hasChanges { + session.previewChanges() } else { generatePreviewSQL( pendingTruncates: pendingTruncates, @@ -36,7 +37,6 @@ extension MainContentCoordinator { } } - /// Generate SQL preview of all pending changes with inlined parameters func generatePreviewSQL( pendingTruncates: Set, pendingDeletes: Set, @@ -50,7 +50,9 @@ extension MainContentCoordinator { ) toolbarState.previewStatements = plan.displayStatements } catch { - toolbarState.previewStatements = ["-- Error generating SQL: \(error.localizedDescription)"] + toolbarState.previewStatements = [ + String(format: String(localized: "-- Error generating SQL: %@"), error.localizedDescription) + ] } activeSheet = .sqlPreview } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Structure.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Structure.swift new file mode 100644 index 0000000000..c1eef9e90e --- /dev/null +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Structure.swift @@ -0,0 +1,25 @@ +// +// MainContentCoordinator+Structure.swift +// TablePro +// + +import Foundation + +extension MainContentCoordinator { + // The selected tab's structure session while its Structure view is on screen. Commands act on it; a session + // behind the Data view keeps its staged edits for the close prompt but takes no commands. + var activeStructureSession: StructureEditingSession? { + guard let tab = tabManager.selectedTab, tab.display.resultsViewMode == .structure else { return nil } + return structureSessions[tab.id] + } + + var activeCreateTableDraft: CreateTableDraft? { + guard let tab = tabManager.selectedTab, tab.tabType == .createTable else { return nil } + return createTableDrafts[tab.id] + } + + // Whichever structure editor is on screen, for the inspector. + var activeStructureEditor: StructureDefinitionEditor? { + activeStructureSession?.editor ?? activeCreateTableDraft?.editor + } +} diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift index 49fd8a17f4..b2bb6f89cc 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift @@ -13,12 +13,16 @@ extension MainContentCoordinator { guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { continue } // A close removes the tab before the selection moves, so this is the only save of filter rows never applied. saveLastFilters(of: tab) - RecentlyClosedTabStore.shared.push(tab: tab, connection: connection) + if tab.tabType.isTransient { + RoutineDebugSessions.shared.endSession(forTab: id, window: contentWindow) + } else { + RecentlyClosedTabStore.shared.push(tab: tab, connection: connection) + } releaseResources(of: tab) releaseExecution(of: tab) tabManager.closeTab(id: id) } - guard tabManager.tabs.isEmpty else { return } + guard tabManager.tabs.allSatisfy({ $0.tabType.isTransient }) else { return } persistence.clearForUserClosedAllTabs() } @@ -91,7 +95,6 @@ extension MainContentCoordinator { createTableDrafts.removeValue(forKey: tabId) erDiagramModels.removeValue(forKey: tabId) tabsWithStagedPrincipals.remove(tabId) - toolbarState.hasStructureChanges = false toolbarState.hasCreateTablePending = false toolbarState.hasPrincipalChanges = false toolbarState.hasTypeChanges = false @@ -109,7 +112,6 @@ extension MainContentCoordinator { tableMetadataCache.removeValue(forKey: tab.id) guard isSelectedTab(tab) else { return } changeManager.clearChangesAndUndoHistory() - toolbarState.hasStructureChanges = false toolbarState.hasCreateTablePending = false toolbarState.hasPrincipalChanges = false toolbarState.hasTypeChanges = false diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift index bb1290d0c5..d79285c440 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift @@ -37,7 +37,7 @@ extension MainContentCoordinator { } guard let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected else { - if isTableTab { pendingLoadTrigger = .restore } + if isTableTab { parkedTableLoad = ParkedTableLoad(tabId: selected.id, trigger: .restore) } return } @@ -144,6 +144,11 @@ extension MainContentCoordinator { ) return } + // The connected panes lay out on the window's next frame; a read that landed first built the + // restored tab in that turn, one stall of both. Waited before the read, so the saves the read + // unlocks never see a tab list the restore has not merged into yet. + await window.nextFrame() + guard !isTearingDown else { return } let restoreStart = Date() let result = await persistence.restoreFromDisk() Self.lifecycleLogger.info( diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index d89a25e631..00257963f2 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -9,7 +9,6 @@ import Foundation import os import TableProEditorKit -import TableProUtilities extension MainContentCoordinator { func handleTabChange( @@ -63,13 +62,9 @@ extension MainContentCoordinator { } let saveMs = Int(Date().timeIntervalSince(saveStart) * 1_000) - // Deferred a tick: the sort and budget calculation is costly with many open tabs. - if tabManager.tabs.count > 2 { - let activeIds: Set = Set([oldTabId, newTabId].compactMap { $0 }) - Task { @MainActor [weak self] in - self?.evictInactiveTabs(excluding: activeIds) - self?.pruneGridDisplayCaches() - } + // Deferred a tick: releasing a large formatted cache is not work the switch should wait on. + Task { @MainActor [weak self] in + self?.pruneGridDisplayCaches() } let restoreStart = Date() @@ -117,11 +112,11 @@ extension MainContentCoordinator { ) // No `reloadVersion` bump: it is window-wide, and bumping it on a switch re-formatted the incoming grid's whole result (#2424). - let needsReload = needsRowReload(newId) + let isStale = tabSessionRegistry.isStale(newId) lazyLoadCurrentTabIfNeeded() TabSwitchTracer.shared.selectionApplied( tabId: newId, - rows: TabSwitchRows(loads: isLoadingRows(newId), needsReload: needsReload), + rows: TabSwitchRows(loads: isLoadingRows(newId), needsReload: isStale), window: contentWindow, isShowing: { [weak manager = tabManager] in manager?.isShowing(newId) ?? false } ) @@ -153,14 +148,10 @@ extension MainContentCoordinator { guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return .resident } return TabSwitchRows( loads: isLoadingRows(tabId) || canAutoLoadTableTab(tab), - needsReload: needsRowReload(tabId) + needsReload: tabSessionRegistry.isStale(tabId) ) } - private func needsRowReload(_ tabId: UUID) -> Bool { - tabSessionRegistry.isEvicted(tabId) || tabSessionRegistry.isStale(tabId) - } - // Only a traced load reports its end; a multi-statement run does not, and would hold the switch open. private func isLoadingRows(_ tabId: UUID) -> Bool { TableLoadTracer.shared.activeToken(for: tabId) != nil @@ -185,84 +176,4 @@ extension MainContentCoordinator { guard let tabId = tabManager.selectedTabId else { return false } return tabExecution.isExecuting(tabId) } - - // Must agree with `canAutoLoadTableTab`: a tab evicted with no route back to its rows shows an empty grid until refreshed. - func canEvictReloadableTableRows(_ tab: QueryTab) -> Bool { - guard tab.id != tabManager.selectedTabId, - tab.tabType == .table, - tab.execution.errorMessage == nil, - tab.content.query.contains(where: { !$0.isWhitespace }), - !tab.pendingChanges.hasChanges, - !tab.display.hasPinnedResults, - !tab.pagination.isLoading, - !tab.pagination.isLoadingMore, - !tabExecution.isBusy(tab.id), - tableLoadTasks[tab.id] == nil, - !tabSessionRegistry.isEvicted(tab.id), - let rows = tabSessionRegistry.existingTableRows(for: tab.id), - !rows.rows.isEmpty - else { return false } - return true - } - - @discardableResult - func evictReloadableTableRows(for tabId: UUID) -> Bool { - guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), - canEvictReloadableTableRows(tabManager.tabs[index]) - else { return false } - - tabManager.mutate(at: index) { tab in - for resultSet in tab.display.resultSets where !resultSet.isPinned { - resultSet.tableRows.discardRowsKeepingMetadata() - } - tab.loadEpoch &+= 1 - } - tabSessionRegistry.evict(for: tabId) - reloadGridModel(forTab: tabId) - return true - } - - func evictInactiveTabs(excluding activeTabIds: Set) { - let start = Date() - let candidates: [(tab: QueryTab, rows: TableRows)] = tabManager.tabs.compactMap { tab in - guard !activeTabIds.contains(tab.id), - tab.execution.lastExecutedAt != nil, - canEvictReloadableTableRows(tab), - let rows = tabSessionRegistry.existingTableRows(for: tab.id) - else { return nil } - return (tab, rows) - } - - let sorted = candidates.sorted { - let t0 = $0.tab.execution.lastExecutedAt ?? .distantFuture - let t1 = $1.tab.execution.lastExecutedAt ?? .distantFuture - if t0 != t1 { return t0 < t1 } - let size0 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $0.rows.rows.count, - columnCount: $0.rows.columns.count - ) - let size1 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $1.rows.rows.count, - columnCount: $1.rows.columns.count - ) - return size0 > size1 - } - - let maxInactiveLoaded = MemoryPressureAdvisor.budgetForInactiveTabs() - guard sorted.count > maxInactiveLoaded else { - Self.lifecycleLogger.debug( - "[switch] evictInactiveTabs no-op candidates=\(sorted.count) budget=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" - ) - return - } - let toEvict = sorted.dropLast(maxInactiveLoaded) - - var evicted = 0 - for entry in toEvict { - if evictReloadableTableRows(for: entry.tab.id) { evicted += 1 } - } - Self.lifecycleLogger.debug( - "[switch] evictInactiveTabs evicted=\(evicted) attempted=\(toEvict.count) candidates=\(sorted.count) keptInactive=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" - ) - } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift index 74b9acd5d0..b87f556999 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift @@ -76,8 +76,8 @@ extension MainContentCoordinator { for tab in tabs { guard let session = structureSessions[tab.id] else { continue } let isOnScreen = tab.id == selectedId && tab.display.resultsViewMode == .structure - if isOnScreen, !session.changeManager.hasChanges, let refresh = structureActions?.refresh { - refresh() + if isOnScreen, !session.hasChanges { + session.refresh() } else { session.markStructureStale() } @@ -119,10 +119,18 @@ extension MainContentCoordinator { private var selectedTabRefreshState: TableRowsRefreshPlan.SelectedTabState? { guard let tab = tabManager.selectedTab else { return nil } - let holdsEdits = changeManager.hasChanges - || tab.pendingChanges.hasChanges - || isEditingActiveGrid - return TableRowsRefreshPlan.SelectedTabState(id: tab.id, holdsEdits: holdsEdits, load: selectedTabLoad(tab.id)) + return TableRowsRefreshPlan.SelectedTabState( + id: tab.id, + holdsEdits: holdsUnsavedWork(tab), + load: selectedTabLoad(tab.id) + ) + } + + // The one answer for every reload path, the lazy load included: an open cell counts, or a reload drops what is typed in it. + func holdsUnsavedWork(_ tab: QueryTab) -> Bool { + if tab.pendingChanges.hasChanges { return true } + guard tab.id == changeManagerTabId else { return false } + return changeManager.hasChanges || isEditingActiveGrid } private func selectedTabLoad(_ tabId: UUID) -> TableRowsRefreshPlan.SelectedTabLoad { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index d19feeaa42..da40de84c0 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -20,8 +20,6 @@ extension MainContentCoordinator { "[switch] coordinator.handleWindowDidBecomeKey connId=\(self.connectionId, privacy: .public) selectedTabId=\(self.tabManager.selectedTabId?.uuidString ?? "nil", privacy: .public)" ) isKeyWindow = true - evictionTask?.cancel() - evictionTask = nil consumeDeferredRestoreLoadIfNeeded() refreshSourceFileDiskChanges() @@ -40,16 +38,6 @@ extension MainContentCoordinator { "[switch] coordinator.handleWindowDidResignKey connId=\(self.connectionId, privacy: .public)" ) isKeyWindow = false - - evictionTask?.cancel() - evictionTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(5)) - guard let self, !Task.isCancelled else { return } - Self.lifecycleLogger.debug( - "[switch] coordinator evictInactiveRowData firing (5s after resignKey) connId=\(self.connectionId, privacy: .public)" - ) - self.evictInactiveRowData() - } } // Synchronous on purpose: no grace period, no delayed Task. @@ -65,9 +53,6 @@ extension MainContentCoordinator { persistence.saveAggregatedSync() } - evictionTask?.cancel() - evictionTask = nil - trailingPaneState?.teardown() teardown() @@ -175,7 +160,7 @@ extension MainContentCoordinator { session.isConnected else { tracer.anomaly(.connectionNotReady, token: traceToken) tracer.finish(token: traceToken, outcome: .notConnected) - pendingLoadTrigger = trigger + parkedTableLoad = ParkedTableLoad(tabId: tab.id, trigger: trigger) declineTableLoad(for: tab.id) return } @@ -211,19 +196,25 @@ extension MainContentCoordinator { lazyLoadCurrentTabIfNeeded(trigger: .restore) } + // The parked trigger goes only to the tab that parked it: run against another selected tab, it executed a query tab's SQL. + func loadSelectedTableTabAfterConnect() { + let parked = parkedTableLoad + parkedTableLoad = nil + let trigger = parked.flatMap { $0.tabId == tabManager.selectedTabId ? $0.trigger : nil } + lazyLoadCurrentTabIfNeeded(trigger: trigger ?? .userInitiated) + } + + // Rows a tab holds stay until the user asks for others; only a first load or a change TablePro made to the table loads here. func canAutoLoadTableTab(_ tab: QueryTab) -> Bool { guard tab.tabType == .table else { return false } guard tab.execution.errorMessage == nil else { return false } guard !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } - let rows = tabSessionRegistry.tableRows(for: tab.id) - let needsReload = tabSessionRegistry.isEvicted(tab.id) || tabSessionRegistry.isStale(tab.id) - let hasFreshRows = !rows.rows.isEmpty && !needsReload - let hasExecuted = tab.execution.lastExecutedAt != nil && !needsReload - guard !hasFreshRows, !hasExecuted else { return false } + let hasLoaded = tab.execution.lastExecutedAt != nil + || !tabSessionRegistry.tableRows(for: tab.id).rows.isEmpty + guard !hasLoaded || tabSessionRegistry.isStale(tab.id) else { return false } - let hasPendingEdits = changeManager.hasChanges || tab.pendingChanges.hasChanges - return !hasPendingEdits + return !holdsUnsavedWork(tab) } private func clearAbandonedExecutingFlagIfNeeded(for tab: QueryTab) { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 86bd465bca..3c82d37c5b 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -2,9 +2,6 @@ // MainContentView+Bindings.swift // TablePro // -// Extension containing computed bindings for MainContentView. -// Extracted to reduce main view complexity. -// import SwiftUI import TableProPluginKit @@ -12,7 +9,8 @@ import TableProPluginKit extension MainContentView { // MARK: - Inspector Selection - // Whose rows the active grid model's selection indexes: the data tab's, a schema grid's, or nobody's. + // Whose rows the active grid model's selection indexes: the data tab's, a schema grid's, or nobody's. A schema + // grid's selection is drawn by the structure inspector, which observes its editor itself. var gridSelectionOwner: GridSelectionOwner { GridSelectionOwner.resolve( tabType: coordinator.tabManager.selectedTab?.tabType, @@ -24,41 +22,27 @@ extension MainContentView { gridSelectionOwner == .dataGrid ? coordinator.activeGridModel : nil } - var selectedInspectorRow: InspectorRow? { - guard gridSelectionOwner == .schemaGrid, - let displayRow = coordinator.activeGridModel?.selection.affectedRows.first else { return nil } - return coordinator.inspectorRowSource?.inspectorRow(atDisplayRow: displayRow) + var structureInspectorSource: StructureInspectorSource? { + if let session = coordinator.activeStructureSession { return .table(session) } + if let draft = coordinator.activeCreateTableDraft { return .createTable(draft) } + return nil } // MARK: - Inspector Subject var hasInspectableRow: Bool { - switch gridSelectionOwner { - case .schemaGrid: return selectedInspectorRow != nil - case .dataGrid: return dataGridModel?.selection.isEmpty == false - case .none: return false - } + guard gridSelectionOwner == .dataGrid else { return false } + return dataGridModel?.selection.isEmpty == false } - // A schema grid's selection is a column definition with no position, so it is not named "Row 0 of 0". var inspectorSubject: InspectorSubject { switch gridSelectionOwner { - case .schemaGrid: - guard let row = selectedInspectorRow else { - guard let table = currentTab?.tableContext.tableName else { return .empty } - return .tableOnly(table: table) - } - let name = row.fields.first?.value ?? "" - return .columnDefinition( - column: name.isEmpty ? String(localized: "Column") : name, - table: currentTab?.tableContext.tableName - ) case .dataGrid: guard let table = qualifiedTableName else { return dataRowSubject(table: String(localized: "Result")) ?? .empty } return dataRowSubject(table: table) ?? .tableOnly(table: table) - case .none: + case .schemaGrid, .none: // A chart, a dashboard or a diagram owns no grid; the table is still the subject of its statistics. guard let table = qualifiedTableName else { return .empty } return .tableOnly(table: table) @@ -109,9 +93,6 @@ extension MainContentView { // MARK: - Sidebar Edit State var isSidebarEditable: Bool { - if gridSelectionOwner == .schemaGrid { - return selectedInspectorRow?.isEditable ?? false - } guard let model = dataGridModel, let tab = coordinator.tabManager.selectedTab, tab.tabType == .table || tab.tableContext.tableName != nil, @@ -176,7 +157,8 @@ extension MainContentView { metadataVersion: currentTab?.metadataVersion ?? -1, resultsViewMode: currentTab?.display.resultsViewMode ?? .data, inspectorRowSourceRevision: coordinator.inspectorRowSourceRevision, - gridDisplayRevision: coordinator.gridDisplayRevision + gridDisplayRevision: coordinator.gridDisplayRevision, + structureEditor: coordinator.activeStructureEditor.map { ObjectIdentifier($0) } ) } } @@ -188,6 +170,8 @@ struct InspectorTrigger: Equatable { let resultsViewMode: ResultsViewMode let inspectorRowSourceRevision: Int let gridDisplayRevision: Int + // A structure session or Create Table draft appears after its view first mounts, which moves nothing else here. + var structureEditor: ObjectIdentifier? } enum InspectorValueDisplayFormatResolver { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index e7c1f83bff..2278ef0f22 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -147,11 +147,9 @@ extension MainContentView { func updateSidebarEditState() { // A selection change, refresh or undo rebuilds the fields, so the typed run ends here, not in another row's step. coordinator.endInspectorEditRun() + // A structure definition is edited by the structure inspector, not the row fields. switch gridSelectionOwner { - case .schemaGrid: - updateSchemaSidebarEditState() - return - case .none: + case .schemaGrid, .none: clearSidebarEditState() return case .dataGrid: @@ -307,36 +305,4 @@ extension MainContentView { trailingPaneState.inspector.editState.onFieldRemoved = nil trailingPaneState.inspector.editState.onDetachedFieldChanged = nil } - - // Edits go back to the owning grid so they land in its change manager with one undo step, like an inline edit. - private func updateSchemaSidebarEditState() { - guard let displayRow = coordinator.activeGridModel?.selection.affectedRows.first, - let row = selectedInspectorRow - else { - clearSidebarEditState() - return - } - - trailingPaneState.inspector.editState.configure(schemaFields: row.fields, displayRow: displayRow) - - guard row.isEditable else { - clearSidebarEditHandlers() - return - } - - let capturedCoordinator = coordinator - trailingPaneState.inspector.editState.onFieldExpressionChanged = nil - trailingPaneState.inspector.editState.onFieldValuesChanged = nil - trailingPaneState.inspector.editState.onFieldReverted = nil - trailingPaneState.inspector.editState.onFieldRemoved = nil - trailingPaneState.inspector.editState.onDetachedFieldChanged = nil - trailingPaneState.inspector.editState.onFieldChanged = { fieldIndex, newValue, _ in - capturedCoordinator.inspectorRowSource?.commitInspectorField( - displayRow: displayRow, - fieldIndex: fieldIndex, - value: newValue.asText - ) - capturedCoordinator.inspectorRowSourceRevision += 1 - } - } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 4885f746c6..961b683705 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -2,9 +2,6 @@ // MainContentView+Helpers.swift // TablePro // -// Extension containing helper methods and inspector context -// for MainContentView. Extracted to reduce main view complexity. -// import SwiftUI import TableProPluginKit @@ -31,17 +28,7 @@ extension MainContentView { let sessions = DatabaseManager.shared.activeSessions guard let session = sessions[connection.id] else { return } if session.isConnected { - if let trigger = coordinator.pendingLoadTrigger { - let hasPendingEdits = - changeManager.hasChanges - || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) - if !hasPendingEdits { - coordinator.pendingLoadTrigger = nil - consumePendingLoad(trigger: trigger) - } - } else { - coordinator.lazyLoadCurrentTabIfNeeded() - } + coordinator.loadSelectedTableTabAfterConnect() } toolbarState.updateConnectionState(from: session.reportedStatus) toolbarState.syncFromSession(for: connection) @@ -55,17 +42,6 @@ extension MainContentView { ) } - private func consumePendingLoad(trigger: TableLoadTrigger) { - if let tabId = tabManager.selectedTab?.id { - coordinator.resolveTableTabSchemaIfNeeded(tabId: tabId) - } - if tabManager.selectedTab?.tabType == .table { - coordinator.lazyLoadCurrentTabIfNeeded(trigger: trigger) - } else { - coordinator.runQuery(viewport: .firstRow, trigger: trigger) - } - } - // MARK: - Inspector Context func scheduleInspectorUpdate() { @@ -96,7 +72,7 @@ extension MainContentView { isRowDeleted: isSelectedRowDeleted, tableMetadata: tableMetadataForCurrentTab, jsonRow: jsonRowSnapshotForSidebar, - userDefinedTypeScope: structureTypeScope + structure: structureInspectorSource ) updateAssistantContext() } @@ -117,17 +93,6 @@ extension MainContentView { currentTab.flatMap { coordinator.currentTableMetadata(for: $0) } } - // The tab's own database and schema, never the sidebar's: a tab bound to another database edits that one. - private var structureTypeScope: DatabaseScope? { - guard let tab = currentTab, tab.tabType == .table || tab.tabType == .createTable else { return nil } - let database = tab.tableContext.databaseName ?? coordinator.browseDatabaseName - return DatabaseScope( - connectionId: coordinator.connection.id, - database: database, - schema: tab.tableContext.schemaName - ) - } - private func cachedQueryResultsSummary() -> String? { guard let tab = currentTab else { return nil } if let cache = queryResultsSummaryCache, diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift index 8c80a72b19..148cceb37f 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift @@ -21,7 +21,6 @@ import SwiftUI windowTitle: .constant("SQL Query"), windowSubtitle: .constant(""), windowRepresentedURL: .constant(nil), - sidebarState: SharedSidebarState(), pendingTruncates: .constant([]), pendingDeletes: .constant([]), tableOperationOptions: .constant([:]), diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 6d1c4b77d1..9388979b8f 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -68,7 +68,7 @@ extension MainContentView { if let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected { coordinator.lazyLoadCurrentTabIfNeeded() } else { - coordinator.pendingLoadTrigger = .userInitiated + coordinator.parkedTableLoad = ParkedTableLoad(tabId: selectedTab.id, trigger: .userInitiated) } } @@ -76,19 +76,32 @@ extension MainContentView { // One resolution of what is staged, so commit, its verb and Preview SQL agree; staged principals once left Cmd+S dim. func updateToolbarPendingState() { + let selectedTab = tabManager.selectedTab + let hasDataChanges = changeManager.hasChanges || !pendingTruncates.isEmpty || !pendingDeletes.isEmpty + // The session, not the mounted view: leaving Structure for Data used to dim Save over staged edits. + let hasStagedStructure = selectedStructureSession?.hasChanges ?? false + let previewsSchemaChanges = toolbarState.hasTypeChanges || coordinator.selectedDiagramHasStagedEdits + || (hasStagedStructure && selectedTab?.display.resultsViewMode == .structure) let kind = PendingChangeKind.resolve( - tabType: tabManager.selectedTab?.tabType, - hasDataChanges: changeManager.hasChanges || !pendingTruncates.isEmpty || !pendingDeletes.isEmpty, - hasStructureChanges: toolbarState.hasStructureChanges || toolbarState.hasTypeChanges + tabType: selectedTab?.tabType, + hasDataChanges: hasDataChanges, + hasStructureChanges: hasStagedStructure || toolbarState.hasTypeChanges || coordinator.selectedDiagramHasStagedEdits, hasCreateTablePending: toolbarState.hasCreateTablePending, hasPrincipalChanges: toolbarState.hasPrincipalChanges, - isFileDirty: tabManager.selectedTab?.content.isFileDirty ?? false + isFileDirty: selectedTab?.content.isFileDirty ?? false ) toolbarState.pendingChange = kind toolbarState.hasPendingChanges = kind != nil - // Narrower than the commit: a dirty query file and staged principals raise the commit but have no grid SQL to show. - toolbarState.hasDataPendingChanges = kind == .data || kind == .structure + toolbarState.hasDataPendingChanges = PendingChangeKind.offersPreview( + of: kind, + hasDataChanges: hasDataChanges, + previewsSchemaChanges: previewsSchemaChanges + ) + } + + var selectedStructureSession: StructureEditingSession? { + tabManager.selectedTabId.flatMap { coordinator.structureSessions[$0] } } // The edited dot is written directly: the unsaved work it reports stays in the window under an agent conversation. diff --git a/apple/TablePro/Views/Main/MainContentCommandActions+Structure.swift b/apple/TablePro/Views/Main/MainContentCommandActions+Structure.swift new file mode 100644 index 0000000000..d6fb86f73c --- /dev/null +++ b/apple/TablePro/Views/Main/MainContentCommandActions+Structure.swift @@ -0,0 +1,117 @@ +// +// MainContentCommandActions+Structure.swift +// TablePro +// + +import Foundation + +// Structure commands go to the selected tab's own session or Create Table draft, which hold their edits mounted or not. +extension MainContentCommandActions { + // MARK: - Save and discard + + // From Data, only when no grid edit is ahead of it: Save stays live after the user leaves Structure. + var stagedStructureSession: StructureEditingSession? { + guard let coordinator, + let tab = coordinator.tabManager.selectedTab, + let session = coordinator.structureSessions[tab.id], + session.hasChanges else { return nil } + if tab.display.resultsViewMode == .structure { return session } + return hasDataWork ? nil : session + } + + var canDiscardChanges: Bool { discardTarget != nil } + + func discardChanges() { + switch discardTarget { + case .structure(let session): + session.discardChangesAfterConfirming() + case .data: + Task { await discardDataWorkAfterConfirming() } + case nil: + break + } + } + + private enum DiscardTarget { + case structure(StructureEditingSession) + case data + } + + // Create Table has no saved state to return to. + private var discardTarget: DiscardTarget? { + guard let tab = coordinator?.tabManager.selectedTab, + tab.tabType == .table || tab.tabType == .query else { return nil } + if let session = stagedStructureSession { return .structure(session) } + return hasDataWork ? .data : nil + } + + private func discardDataWorkAfterConfirming() async { + let confirmed = await AlertHelper.confirmDestructive( + title: String(localized: "Discard Changes?"), + message: String(localized: "The changes you have not saved will be lost."), + confirmButton: String(localized: "Discard Changes"), + cancelButton: String(localized: "Cancel"), + window: closeAnchorWindow + ) + guard confirmed else { return } + discardDataWork() + } + + // MARK: - Rows + + var canAddSchemaRow: Bool { + if let session = coordinator?.activeStructureSession { return session.canAddRow } + return coordinator?.activeCreateTableDraft?.canAddRow ?? false + } + + var canRemoveSchemaRows: Bool { + if let session = coordinator?.activeStructureSession { return session.canRemoveRows } + return coordinator?.activeCreateTableDraft?.canRemoveRows ?? false + } + + var canPasteSchemaRows: Bool { + if let session = coordinator?.activeStructureSession { return session.canPasteRows } + guard let draft = coordinator?.activeCreateTableDraft, draft.canAddRow else { return false } + return draft.editor.canPaste + } + + func addSchemaRow() { + if let session = coordinator?.activeStructureSession { + session.addRow() + } else { + coordinator?.activeCreateTableDraft?.addRow() + } + } + + func removeSchemaRows() { + if let session = coordinator?.activeStructureSession { + session.removeSelectedRows() + } else { + coordinator?.activeCreateTableDraft?.removeSelectedRows() + } + } + + func duplicateSchemaRows() { + if let session = coordinator?.activeStructureSession { + session.duplicateSelectedRows() + } else if let draft = coordinator?.activeCreateTableDraft, draft.canAddRow { + draft.editor.duplicateSelection() + } + } + + func copySchemaRows() { + if let session = coordinator?.activeStructureSession { + session.copySelectedRows() + } else { + coordinator?.activeCreateTableDraft?.editor.copySelection() + } + } + + func pasteSchemaRows() { + if let session = coordinator?.activeStructureSession { + session.pasteRows() + } else if canPasteSchemaRows { + coordinator?.activeCreateTableDraft?.editor.paste() + } + } +} diff --git a/apple/TablePro/Views/Main/MainContentCommandActions+UndoState.swift b/apple/TablePro/Views/Main/MainContentCommandActions+UndoState.swift index 76dd50c879..f35e03c609 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions+UndoState.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions+UndoState.swift @@ -5,18 +5,37 @@ import AppKit -/// A connection hosts three undo domains, picked by the active tab: Users & Roles and -/// the structure editor keep their own histories, and everything else registers on -/// the window's `UndoManager`, which `TabWindowController` resolves to the selected -/// connection's own history. Availability and the menu title both have to follow -/// whichever one is live, so they resolve through the same branch the commands do. +// The structure editor's own history, which the window's undo manager never sees. +@MainActor +protocol StructureUndoHistory: AnyObject { + var canUndo: Bool { get } + var canRedo: Bool { get } + var undoMenuItemTitle: String { get } + var redoMenuItemTitle: String { get } + func undo() + func redo() +} + +extension StructureEditingSession: StructureUndoHistory {} + +extension CreateTableDraft: StructureUndoHistory {} + +// Users & Roles, the diagram and the structure editor keep their own histories; everything else registers on the +// window's undo manager. Availability, title and command resolve through the same branch so they cannot disagree. extension MainContentCommandActions { private var windowUndoManager: UndoManager? { coordinator?.contentWindow?.undoManager } - private var isStructureMode: Bool { - coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure + // A Create Table tab keeps its view mode at Data, so it is asked for by tab type. + private var showsStructureEditor: Bool { + guard let tab = coordinator?.tabManager.selectedTab else { return false } + return tab.tabType == .createTable || tab.display.resultsViewMode == .structure + } + + private var structureHistory: (any StructureUndoHistory)? { + if let session = coordinator?.activeStructureSession { return session } + return coordinator?.activeCreateTableDraft } // The diagram stages on one session per table, so it keeps its own order across them. @@ -31,12 +50,11 @@ extension MainContentCommandActions { coordinator?.tabManager.selectedTab?.tabType == .erDiagram } - /// A field still being typed into has an undo step waiting rather than registered, and the menu - /// has to offer it: the command ends the run before it reaches the undo manager. + // A field still being typed into has its step waiting rather than registered; the command ends the run first. var canUndo: Bool { if isUsersRolesTab { return coordinator?.usersRolesActions?.canUndo() ?? false } if isDiagramTab { return selectedDiagram?.canUndoStagedEdit ?? false } - if isStructureMode { return coordinator?.structureActions?.undo != nil } + if showsStructureEditor { return structureHistory?.canUndo ?? false } if coordinator?.changeManager.hasCoalescedUndoRun == true { return true } return windowUndoManager?.canUndo ?? false } @@ -44,13 +62,17 @@ extension MainContentCommandActions { var canRedo: Bool { if isUsersRolesTab { return coordinator?.usersRolesActions?.canRedo() ?? false } if isDiagramTab { return selectedDiagram?.canRedoStagedEdit ?? false } - if isStructureMode { return coordinator?.structureActions?.redo != nil } + if showsStructureEditor { return structureHistory?.canRedo ?? false } return windowUndoManager?.canRedo ?? false } var resolvedUndoTitle: String { if isUsersRolesTab { return undoMenuTitle } if isDiagramTab { return String(localized: "Undo") } + if showsStructureEditor { + guard let history = structureHistory, history.canUndo else { return String(localized: "Undo") } + return history.undoMenuItemTitle + } if coordinator?.changeManager.hasCoalescedUndoRun == true { return String(localized: "Undo Edit Cell") } @@ -61,61 +83,47 @@ extension MainContentCommandActions { var resolvedRedoTitle: String { if isUsersRolesTab { return redoMenuTitle } if isDiagramTab { return String(localized: "Redo") } + if showsStructureEditor { + guard let history = structureHistory, history.canRedo else { return String(localized: "Redo") } + return history.redoMenuItemTitle + } guard let manager = windowUndoManager, manager.canRedo else { return String(localized: "Redo") } return manager.redoMenuItemTitle } // MARK: - Commands - /// A Create Table tab keeps its `resultsViewMode` at `.data`, so it needs its own arm. Without - /// one, Cmd+Z in the visual table editor reached the window's undo manager, which owns none of - /// the draft, and the grid's own undo had no caller at all. func undoChange() { coordinator?.endInspectorEditRun() - endTableCommentRun() if isUsersRolesTab { coordinator?.usersRolesActions?.undo() return } - if coordinator?.tabManager.selectedTab?.tabType == .createTable { - coordinator?.createTableActions?.undo?() - return - } if isDiagramTab { selectedDiagram?.undoStagedEdit() return } - if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { - coordinator?.structureActions?.undo?() + if showsStructureEditor { + structureHistory?.undo() return } - coordinator?.contentWindow?.undoManager?.undo() + windowUndoManager?.undo() } func redoChange() { coordinator?.endInspectorEditRun() - endTableCommentRun() if isUsersRolesTab { coordinator?.usersRolesActions?.redo() return } - if coordinator?.tabManager.selectedTab?.tabType == .createTable { - coordinator?.createTableActions?.redo?() - return - } if isDiagramTab { selectedDiagram?.redoStagedEdit() return } - if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { - coordinator?.structureActions?.redo?() + if showsStructureEditor { + structureHistory?.redo() return } - coordinator?.contentWindow?.undoManager?.redo() - } - - private func endTableCommentRun() { - guard let coordinator, let tabId = coordinator.tabManager.selectedTabId else { return } - coordinator.structureSessions[tabId]?.changeManager.endTableCommentRun() + windowUndoManager?.redo() } } diff --git a/apple/TablePro/Views/Main/MainContentCommandActions.swift b/apple/TablePro/Views/Main/MainContentCommandActions.swift index a58475ef42..5a3f62be3f 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions.swift @@ -171,9 +171,8 @@ final class MainContentCommandActions: ObservableObject { // MARK: - Row Operations (Group A: Called Directly) func addNewRow() { - // Structure inserts a column, index or FK row by sub-tab; the data path adds data rows only. switch selectionOwner { - case .schemaGrid: coordinator?.structureActions?.addRow?() + case .schemaGrid: addSchemaRow() case .dataGrid: coordinator?.addNewRow() case .none: break } @@ -185,13 +184,13 @@ final class MainContentCommandActions: ObservableObject { return coordinator?.activeGridModel?.selectedRowIDs ?? [] } - // A Create Table tab has no structure handler; claiming its selection would make commands inert and shadow sidebar delete. - private var selectionOwner: GridSelectionOwner { + // A structure grid with no session or draft behind it takes no commands; claiming it would shadow sidebar delete. + var selectionOwner: GridSelectionOwner { let owner = GridSelectionOwner.resolve( tabType: coordinator?.tabManager.selectedTab?.tabType, resultsViewMode: coordinator?.tabManager.selectedTab?.display.resultsViewMode ) - guard owner == .schemaGrid, coordinator?.structureActions == nil else { return owner } + guard owner == .schemaGrid, coordinator?.activeStructureEditor == nil else { return owner } return .none } @@ -205,8 +204,9 @@ final class MainContentCommandActions: ObservableObject { } func deleteSelectedRows() { - if selectionOwner == .schemaGrid { - coordinator?.structureActions?.removeRow?() + // A selected definition that cannot be removed refuses here; Delete never reaches the sidebar's tables. + if selectsSchemaDefinition { + removeSchemaRows() return } @@ -220,13 +220,17 @@ final class MainContentCommandActions: ObservableObject { } func duplicateRow() { + if selectionOwner == .schemaGrid { + duplicateSchemaRows() + return + } guard let rowID = singleSelectedDataGridRowID else { return } coordinator?.duplicateRow(rowID) } func copySelectedRows() { switch selectionOwner { - case .schemaGrid: coordinator?.structureActions?.copyRows?() + case .schemaGrid: copySchemaRows() case .dataGrid: coordinator?.copyRowsToClipboard(selectedDataRowIDs) case .none: break } @@ -244,7 +248,7 @@ final class MainContentCommandActions: ObservableObject { func pasteRows() { switch selectionOwner { - case .schemaGrid: coordinator?.structureActions?.pasteRows?() + case .schemaGrid: pasteSchemaRows() case .dataGrid: coordinator?.pasteRows() case .none: break } @@ -375,15 +379,20 @@ final class MainContentCommandActions: ObservableObject { SharedSidebarState.forConnection(connection.id).sidebarLayout = layout } + // Not the data tab's state in Structure: Add Row there used to light from the rows' editability. var isCurrentTabEditable: Bool { - guard let coordinator, coordinator.tabManager.selectedTab != nil, selectionOwner != .none else { - return false + guard let coordinator, coordinator.tabManager.selectedTab != nil else { return false } + switch selectionOwner { + case .schemaGrid: return canAddSchemaRow || canRemoveSchemaRows + case .dataGrid: return coordinator.canEditActiveResult + case .none: return false } - return coordinator.canEditActiveResult } + // Add and Duplicate Row gate on this; a structure grid adds only where its section allows it. var isCurrentTabSchemaResolved: Bool { guard let coordinator, let tabId = coordinator.tabManager.selectedTabId else { return false } + if selectionOwner == .schemaGrid { return canAddSchemaRow } return coordinator.tabSessionRegistry.tableRows(for: tabId).hasAuthoritativeSchema } @@ -431,7 +440,7 @@ final class MainContentCommandActions: ObservableObject { } switch selectionOwner { case .schemaGrid: - return coordinator?.structureActions?.pasteRows != nil && TableStructureView.canPasteStructureRows + return canPasteSchemaRows case .dataGrid: return tab.tabType == .table && isCurrentTabEditable && isCurrentTabSchemaResolved && ClipboardService.shared.hasText @@ -480,7 +489,12 @@ final class MainContentCommandActions: ObservableObject { // Same check as the sidebar's Delete item, which the menu bar used to skip on engines with no statement for it. var canDropSelectedTables: Bool { - TableOperationEligibility.canDrop(selectedTables.wrappedValue, context: tableOperationEligibility) + guard !selectsSchemaDefinition else { return false } + return TableOperationEligibility.canDrop(selectedTables.wrappedValue, context: tableOperationEligibility) + } + + private var selectsSchemaDefinition: Bool { + selectionOwner == .schemaGrid && coordinator?.activeStructureEditor?.focusedSection != nil } private var tableOperationEligibility: TableOperationEligibility.Context { @@ -522,10 +536,6 @@ final class MainContentCommandActions: ObservableObject { coordinator?.isEditingActiveGrid == true } - var hasStructureChanges: Bool { - coordinator?.toolbarState.hasStructureChanges ?? false - } - // MARK: - Unsaved Changes Check // Every connection in the window: closing it closes them all, and skipping a background one is silent data loss. @@ -777,10 +787,7 @@ final class MainContentCommandActions: ObservableObject { guard await applySelectedTypeChanges() else { return false } // Data grid changes or pending table operations take priority - let hasDataChanges = coordinator.changeManager.hasChanges - || !pendingTruncates.wrappedValue.isEmpty - || !pendingDeletes.wrappedValue.isEmpty - if hasDataChanges { + if hasDataWork { return await withCheckedContinuation { continuation in coordinator.saveCompletionContinuation = continuation saveChanges() @@ -879,13 +886,19 @@ final class MainContentCommandActions: ObservableObject { // MARK: - Data Operations (Group A: Called Directly) - // Any Structure sub-tab saves: DDL, Parts and Triggers are read-only views, and refusing left Cmd+S silently inert. - private func applyStagedStructureChanges() { - guard let coordinator, - let tabId = coordinator.tabManager.selectedTabId, - let session = coordinator.structureSessions[tabId], - session.changeManager.hasChanges else { return } - Task { _ = await session.applyStagedChanges(coordinator: coordinator) } + var hasDataWork: Bool { + coordinator?.changeManager.hasChanges == true + || !pendingTruncates.wrappedValue.isEmpty + || !pendingDeletes.wrappedValue.isEmpty + } + + // What Save would write for the grid: cell edits and the staged TRUNCATE and DROP. + func discardDataWork() { + var truncates = pendingTruncates.wrappedValue + var deletes = pendingDeletes.wrappedValue + coordinator?.handleDiscard(pendingTruncates: &truncates, pendingDeletes: &deletes) + pendingTruncates.wrappedValue = truncates + pendingDeletes.wrappedValue = deletes } func restorePreviousValues() { @@ -899,18 +912,17 @@ final class MainContentCommandActions: ObservableObject { return } if coordinator?.tabManager.selectedTab?.tabType == .createTable { - coordinator?.createTableActions?.createTable?() + guard let coordinator, let draft = coordinator.activeCreateTableDraft else { return } + draft.create(coordinator: coordinator) return } if saveSelectedDiagram() { return } if saveSelectedTypeChanges() { return } - if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { - applyStagedStructureChanges() - } else if coordinator?.changeManager.hasChanges == true - || !pendingTruncates.wrappedValue.isEmpty - || !pendingDeletes.wrappedValue.isEmpty { + if let session = stagedStructureSession, let coordinator { + Task { _ = await session.applyStagedChanges(coordinator: coordinator) } + } else if hasDataWork { // Data grid first: its edits sync to the sidebar editState, and its path uses the plugin driver for statements. var truncates = pendingTruncates.wrappedValue var deletes = pendingDeletes.wrappedValue @@ -1099,9 +1111,17 @@ final class MainContentCommandActions: ObservableObject { } func runQuery() { + if let coordinator, let draft = coordinator.activeCreateTableDraft { + draft.create(coordinator: coordinator) + return + } coordinator?.runQuery(viewport: .keepPlace) } + var canCreateDraftTable: Bool { + coordinator?.activeCreateTableDraft?.isReadyToCreate ?? false + } + func runQueryWithoutLimit() { coordinator?.runQuery(viewport: .keepPlace, bypassRowLimit: true) } diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index c293e1242e..1e4fd80455 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -191,11 +191,6 @@ final class MainContentCoordinator: ObservableObject { weak var sidebarViewModel: SidebarViewModel? - weak var structureActions: StructureViewActionHandler? - - // Lets Save Changes (Cmd+S) route to table creation. Set by `CreateTableView` on appear. - weak var createTableActions: CreateTableActionHandler? - weak var usersRolesActions: UsersRolesActionHandler? // Not in the view: it dies on every tab and Data/Structure switch, and the close gate must see unseen tabs' staged work. @@ -212,9 +207,6 @@ final class MainContentCoordinator: ObservableObject { // `saveCompletionContinuation` is one slot: a second close gesture would overwrite it and strand the first task forever. internal var tabClosesInFlight: Set = [] - // The non-data grid that owns the selection, read by the inspector. Set by `TableStructureView` and `CreateTableView`. - weak var inspectorRowSource: (any InspectorRowSource)? - // Bumped when a published schema row changes, so the inspector re-reads it. @Published var inspectorRowSourceRevision: Int = 0 @@ -272,7 +264,7 @@ final class MainContentCoordinator: ObservableObject { @Published var exportFormatId: String? // Kept after the sheet closes, so reopening Search Database after opening a result shows the results again. var databaseSearchSession: DatabaseSearchSession? - @Published var pendingLoadTrigger: TableLoadTrigger? + var parkedTableLoad: ParkedTableLoad? var deferredRestoreLoadTabId: UUID? var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:] @@ -373,9 +365,6 @@ final class MainContentCoordinator: ObservableObject { // Updated by TabWindowController; read by event handlers such as the sidebar table-selection filter. var isKeyWindow = false - // Scheduled in `handleWindowDidResignKey`, fires 5s later. - var evictionTask: Task? - var refreshCoalesceTask: Task? var refreshPendingTrailing = false @@ -421,12 +410,15 @@ final class MainContentCoordinator: ObservableObject { } // Union across windows: saving one window's tabs erased torn-off ones. Deduped, since SwiftUI's throwaway coordinators repeat tabs. + // The connection's saved tab set. A debug tab is never saved, so a window holding only debug tabs counts as empty. static func aggregatedTabs(for connectionId: UUID) -> [QueryTab] { var seen = Set() return activeCoordinators.values .filter { $0.connectionId == connectionId } .flatMap { coordinator in - coordinator.tabManager.tabs.map(coordinator.enrichedForPersistence) + coordinator.tabManager.tabs + .filter { !$0.tabType.isTransient } + .map(coordinator.enrichedForPersistence) } .filter { seen.insert($0.id).inserted } } @@ -576,13 +568,6 @@ final class MainContentCoordinator: ObservableObject { } }() - // Called on resign key. The selected tab keeps its rows; `canEvictReloadableTableRows` keeps tabs that can't reload themselves. - func evictInactiveRowData() { - for tab in tabManager.tabs { - evictReloadableTableRows(for: tab.id) - } - } - // MARK: - Initialization init( @@ -883,6 +868,10 @@ final class MainContentCoordinator: ObservableObject { columnScopeRequeryTask?.cancel() releaseImportFile() + let debugTabIds = tabManager.tabs.filter { $0.tabType.isTransient }.map(\.id) + if !debugTabIds.isEmpty { + RoutineDebugSessions.shared.endSessions(forTabs: debugTabIds, window: contentWindow) + } tabManager.tabs.removeAll() tabManager.selectedTabId = nil diff --git a/apple/TablePro/Views/Main/MainContentView.swift b/apple/TablePro/Views/Main/MainContentView.swift index fdc7b3f76a..3954b0d3e5 100644 --- a/apple/TablePro/Views/Main/MainContentView.swift +++ b/apple/TablePro/Views/Main/MainContentView.swift @@ -22,7 +22,6 @@ struct MainContentView: View { @Binding var windowSubtitle: String @Binding var windowRepresentedURL: URL? @ObservedObject var schemaService = SchemaService.shared - @ObservedObject var sidebarState: SharedSidebarState @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set @Binding var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] @@ -59,7 +58,6 @@ struct MainContentView: View { windowTitle: Binding, windowSubtitle: Binding, windowRepresentedURL: Binding, - sidebarState: SharedSidebarState, pendingTruncates: Binding>, pendingDeletes: Binding>, tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]>, @@ -74,7 +72,6 @@ struct MainContentView: View { self._windowTitle = windowTitle self._windowSubtitle = windowSubtitle self._windowRepresentedURL = windowRepresentedURL - self.sidebarState = sidebarState self._pendingTruncates = pendingTruncates self._pendingDeletes = pendingDeletes self._tableOperationOptions = tableOperationOptions @@ -266,11 +263,7 @@ struct MainContentView: View { } ) case .sqlPreview: - SQLReviewSheet( - isPresented: dismissBinding, - statements: coordinator.toolbarState.previewStatements, - databaseType: coordinator.toolbarState.databaseType - ) + sqlPreviewSheet(dismiss: dismissBinding) case .rewind: if let plan = coordinator.rewindPlan { RewindReviewSheet(plan: plan) { @@ -306,12 +299,32 @@ struct MainContentView: View { } } + // A structure session's preview offers its Save; a grid preview only shows what Save would write. + private func sqlPreviewSheet(dismiss: Binding) -> SQLReviewSheet { + SQLReviewSheet( + isPresented: dismiss, + statements: coordinator.toolbarState.previewStatements, + databaseType: coordinator.toolbarState.databaseType, + primaryAction: coordinator.activeStructureSession.flatMap { session -> SQLReviewSheet.PrimaryAction? in + guard session.hasChanges else { return nil } + return SQLReviewSheet.PrimaryAction( + title: String(localized: "Save Changes"), + isDestructive: false, + work: .immediate { + coordinator.activeSheet = nil + session.save() + } + ) + } + ) + } + private var pendingChangeTrigger: PendingChangeTrigger { PendingChangeTrigger( hasDataChanges: changeManager.hasChanges, pendingTruncates: pendingTruncates, pendingDeletes: pendingDeletes, - hasStructureChanges: toolbarState.hasStructureChanges, + hasStructureChanges: selectedStructureSession?.hasChanges ?? false, diagramEditsRevision: toolbarState.diagramEditsRevision, selectedTabId: tabManager.selectedTabId, isFileDirty: tabManager.selectedTab?.content.isFileDirty ?? false, @@ -345,7 +358,14 @@ struct MainContentView: View { await coordinator.loadInspectorRowValues(tabId: tabId, rowID: rowID) }) .background { - if let model = coordinator.activeGridModel { + if let editor = coordinator.activeStructureEditor { + StructureSelectionWatcher( + editor: editor, + onAttach: { scheduleInspectorUpdate() }, + onChange: { rowsChanged in handleGridSelectionChange(rowsChanged: rowsChanged) } + ) + .id(ObjectIdentifier(editor)) + } else if let model = coordinator.activeGridModel { GridSelectionWatcher( model: model, onAttach: { scheduleInspectorUpdate() }, @@ -354,6 +374,15 @@ struct MainContentView: View { .id(ObjectIdentifier(model)) } } + .background { + if let session = selectedStructureSession { + StructureChangeWatcher(session: session) { + updateToolbarPendingState() + coordinator.refreshUnsavedIndicator() + } + .id(ObjectIdentifier(session)) + } + } .onReceive(coordinator.gridCellsEdited) { _ in scheduleInspectorUpdate() } @@ -525,3 +554,35 @@ struct MainContentView: View { ) } } + +// No view of this window observes a session whose Structure view is not on screen, yet Discard and Save change it there. +// The selection moves between four grids, and the window reads only the one holding it. +private struct StructureSelectionWatcher: View { + @ObservedObject var editor: StructureDefinitionEditor + let onAttach: () -> Void + let onChange: (_ rowsChanged: Bool) -> Void + + var body: some View { + let model = editor.focusedModel ?? editor.adapter(for: .columns).model + Color.clear + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .background { + GridSelectionWatcher(model: model, onAttach: onAttach, onChange: onChange) + .id(ObjectIdentifier(model)) + } + .onChange(of: editor.focusedSection) { _ in onChange(true) } + } +} + +private struct StructureChangeWatcher: View { + @ObservedObject var session: StructureEditingSession + let onChange: () -> Void + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .onChange(of: session.hasChanges) { _ in onChange() } + } +} diff --git a/apple/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift b/apple/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift index 5fa786da36..8b87811a4e 100644 --- a/apple/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift +++ b/apple/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import TableProPluginKit internal struct CopyObjectsConfigureView: View { @ObservedObject internal var session: ObjectCopySession @@ -35,6 +36,7 @@ internal struct CopyObjectsConfigureView: View { destinationSection contentSection existingSection + errorHandlingSection Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) @@ -124,6 +126,18 @@ internal struct CopyObjectsConfigureView: View { } } + private var errorHandlingSection: some View { + labelled(String(localized: "On error")) { + Picker(String(localized: "On error"), selection: $session.errorHandling) { + Text("Stop and roll back").tag(ImportErrorHandling.stopAndRollback) + Text("Stop and keep what ran").tag(ImportErrorHandling.stopAndCommit) + Text("Skip and continue").tag(ImportErrorHandling.skipAndContinue) + } + .labelsHidden() + .accessibilityIdentifier("copy-objects-error-handling") + } + } + @ViewBuilder private func labelled(_ title: String, @ViewBuilder content: () -> some View) -> some View { VStack(alignment: .leading, spacing: 6) { diff --git a/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift b/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift index b3c5e3914e..c4a73e2b75 100644 --- a/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift +++ b/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift @@ -186,20 +186,15 @@ struct UserTypeTabView: View { private func editor(_ projection: UserTypeDraft.Projection) -> some View { VStack(spacing: 0) { - HStack { - Spacer() - Picker(String(localized: "Section"), selection: $model.section) { - ForEach(sections, id: \.self) { section in - Text(title(for: section)).tag(section) - } + PaneTabBar( + title: String(localized: "Section"), + selection: $model.section, + identifier: "user-type-section-picker" + ) { + ForEach(sections, id: \.self) { section in + Text(title(for: section)).tag(section) } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - .accessibilityIdentifier("user-type-section-picker") - Spacer() } - .padding(.vertical, 8) Divider() Group { switch model.section { diff --git a/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift b/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift index 4c47fa4b84..a84f4c1fba 100644 --- a/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift +++ b/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift @@ -187,8 +187,7 @@ struct QueryPlanResultView: View { Text(mode.title).tag(mode) } } - .pickerStyle(.segmented) - .controlSize(.small) + .viewSwitcherStyle() .fixedSize() .labelsHidden() .accessibilityIdentifier("query-plan-mode-picker") diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index c9fff03a8d..ce96a30661 100644 --- a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -114,7 +114,13 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { private static let topOffsetRatio: CGFloat = 0.20 + private struct RetainedPanel { + let panel: QuickSwitcherPanel + let hostingController: NSHostingController + } + private var panel: QuickSwitcherPanel? + private var retainedPanels: [String: RetainedPanel] = [:] private var anchor: Anchor? private var presentedIdentity: String? private var onClose: (() -> Void)? @@ -132,32 +138,43 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { /// `onClose` runs however the panel goes, including when another presentation replaces it, /// which is how a caller that keeps state of its own alongside the panel learns to drop it. + /// `retainsPanel` keeps the window and view tree for the next presentation under the same identity, + /// so content must take its model from the caller: a `@StateObject` would survive the root swap. func present( _ content: some View, over parentWindow: NSWindow?, identity: String? = nil, focus: QuickSwitcherPanelFocus = .key, accessibilityIdentifier: String = "quick-switcher-panel", + retainsPanel: Bool = false, onClose: (() -> Void)? = nil ) { dismiss() presentedIdentity = identity self.onClose = onClose - let sizeReportingContent = content.onGeometryChange(for: CGSize.self) { proxy in + let root = AnyView(content.onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { [weak self] size in self?.contentSizeDidChange(size) + }) + let panel: QuickSwitcherPanel + if retainsPanel, let identity, let retained = retainedPanels[identity] { + retained.hostingController.rootView = root + panel = retained.panel + } else { + let hostingController = NSHostingController(rootView: root) + panel = QuickSwitcherPanel( + hostingController: hostingController, + surfaceCornerRadius: QuickSwitcherMetrics.cornerRadius, + focus: focus, + accessibilityIdentifier: accessibilityIdentifier + ) + panel.delegate = self + if retainsPanel, let identity { + retainedPanels[identity] = RetainedPanel(panel: panel, hostingController: hostingController) + } } - let hostingController = NSHostingController(rootView: sizeReportingContent) - - let panel = QuickSwitcherPanel( - hostingController: hostingController, - surfaceCornerRadius: QuickSwitcherMetrics.cornerRadius, - focus: focus, - accessibilityIdentifier: accessibilityIdentifier - ) - panel.delegate = self self.panel = panel let reference = parentWindow?.frame @@ -182,7 +199,9 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { func windowWillClose(_ notification: Notification) { panel?.keyCommandHandler = nil - panel?.contentViewController = nil + if let panel, !retainedPanels.values.contains(where: { $0.panel === panel }) { + panel.contentViewController = nil + } panel = nil anchor = nil presentedIdentity = nil diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 4cfc871dd8..73b026d8f4 100644 --- a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -7,30 +7,28 @@ import AppKit import SwiftUI struct QuickSwitcherPanelView: View { - let connectionId: UUID let databaseType: DatabaseType let openTables: Set let browseSchema: String? let onSelect: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void let onDismiss: () -> Void - @StateObject private var viewModel: QuickSwitcherViewModel + @ObservedObject private var viewModel: QuickSwitcherViewModel init( - connectionId: UUID, + viewModel: QuickSwitcherViewModel, databaseType: DatabaseType, openTables: Set = [], browseSchema: String? = nil, onSelect: @escaping (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void, onDismiss: @escaping () -> Void ) { - self.connectionId = connectionId self.databaseType = databaseType self.openTables = openTables self.browseSchema = browseSchema self.onSelect = onSelect self.onDismiss = onDismiss - self._viewModel = StateObject(wrappedValue: QuickSwitcherViewModel(connectionId: connectionId)) + self.viewModel = viewModel } var body: some View { @@ -39,17 +37,17 @@ struct QuickSwitcherPanelView: View { onSelect(item, intent) onDismiss() } - .task { + .task(id: ObjectIdentifier(viewModel)) { await viewModel.loadItems( databaseType: databaseType, openTables: openTables, browseSchema: browseSchema ) } - .task(id: viewModel.crossConnectionLoadVersion) { + .task(id: ModelTaskKey(model: ObjectIdentifier(viewModel), version: viewModel.crossConnectionLoadVersion)) { await viewModel.loadCrossConnectionItems() } - .task(id: viewModel.crossConnectionQueryLoadVersion) { + .task(id: ModelTaskKey(model: ObjectIdentifier(viewModel), version: viewModel.crossConnectionQueryLoadVersion)) { await viewModel.loadCrossConnectionQueryItems() } .onReceive(AppEvents.shared.queryHistoryDidUpdate) { _ in @@ -61,6 +59,39 @@ struct QuickSwitcherPanelView: View { } } +// The retained panel keeps this view and swaps its model, so each load is keyed by the model as well. +private struct ModelTaskKey: Equatable { + let model: ObjectIdentifier + let version: Version +} + +// Installed on every update, not only when joining the window: the retained panel swaps in a new model. +private struct PanelKeyCommandInstaller: NSViewRepresentable { + let handler: (NSEvent) -> Bool + + func makeNSView(context: Context) -> InstallerView { + InstallerView() + } + + func updateNSView(_ nsView: InstallerView, context: Context) { + nsView.handler = handler + nsView.install() + } + + final class InstallerView: NSView { + var handler: ((NSEvent) -> Bool)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + install() + } + + func install() { + (window as? QuickSwitcherPanel)?.keyCommandHandler = handler + } + } +} + struct QuickSwitcherPanelContent: View { @Environment(\.colorSchemeContrast) private var colorSchemeContrast @@ -89,9 +120,7 @@ struct QuickSwitcherPanelContent: View { .quickSwitcherSurface(cornerRadius: QuickSwitcherMetrics.cornerRadius) } .background { - WindowAccessor { window in - (window as? QuickSwitcherPanel)?.keyCommandHandler = handleKeyCommand - } + PanelKeyCommandInstaller(handler: handleKeyCommand) } } @@ -369,14 +398,14 @@ struct QuickSwitcherPanelContent: View { /// on screen saying so, that first press reads as a key that did nothing. private var footer: some View { HStack(spacing: 14) { - QuickSwitcherKeyHint(symbol: "\u{21A9}", label: String(localized: "Open")) - QuickSwitcherKeyHint(symbol: "\u{2318}\u{21A9}", label: String(localized: "New Tab")) + QuickSwitcherKeyHint(keys: [.returnKey], label: String(localized: "Open")) + QuickSwitcherKeyHint(keys: [.text("\u{2318}"), .returnKey], label: String(localized: "New Tab")) Spacer(minLength: 0) - QuickSwitcherKeyHint(symbol: "\u{2318}1\u{2013}5", label: String(localized: "Scope")) + QuickSwitcherKeyHint(keys: [.text("\u{2318}1\u{2013}5")], label: String(localized: "Scope")) QuickSwitcherKeyHint( - symbol: "\u{238B}", + keys: [.text("\u{238B}")], label: escapeDismissesPanel ? String(localized: "Close") : String(localized: "Clear") ) } diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift index 6aa2616a88..a40ef8b2b6 100644 --- a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift @@ -22,20 +22,37 @@ internal enum QuickSwitcherRowChrome { } internal struct QuickSwitcherKeyHint: View { - let symbol: String + enum Glyph { + case text(String) + case symbol(String) + + // The system font has no U+21A9: its first draw in a process loads a fallback font, about 10 ms. + static let returnKey = Glyph.symbol("return") + } + + let keys: [Glyph] let label: String var body: some View { HStack(spacing: 4) { - Text(symbol) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .frame(minWidth: 20, minHeight: 17) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color(nsColor: .quaternaryFill)) - ) + HStack(spacing: 1) { + ForEach(keys.indices, id: \.self) { index in + switch keys[index] { + case .text(let text): + Text(text) + case .symbol(let name): + Image(systemName: name) + } + } + } + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .frame(minWidth: 20, minHeight: 17) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color(nsColor: .quaternaryFill)) + ) Text(label) .font(.caption) .foregroundStyle(.secondary) diff --git a/apple/TablePro/Views/Results/BinaryValueViewerView.swift b/apple/TablePro/Views/Results/BinaryValueViewerView.swift index 90c7343a20..43d6f59ed2 100644 --- a/apple/TablePro/Views/Results/BinaryValueViewerView.swift +++ b/apple/TablePro/Views/Results/BinaryValueViewerView.swift @@ -57,9 +57,10 @@ internal struct BinaryValueViewerView: View { Text(String(localized: "Text")).tag(BinaryValueViewMode.text) Text(String(localized: "Hex")).tag(BinaryValueViewMode.hex) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("binary-viewer-mode-picker") } else { Text(format.displayName) .font(.headline) diff --git a/apple/TablePro/Views/Results/CellImageViewer.swift b/apple/TablePro/Views/Results/CellImageViewer.swift index 9e3212b212..a6e5fc24f0 100644 --- a/apple/TablePro/Views/Results/CellImageViewer.swift +++ b/apple/TablePro/Views/Results/CellImageViewer.swift @@ -61,9 +61,10 @@ internal struct CellImageViewer: View { Text(String(localized: "Image")).tag(CellImageViewMode.image) Text(sourceKind.label).tag(CellImageViewMode.source) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("image-viewer-mode-picker") Spacer() diff --git a/apple/TablePro/Views/Results/ColumnJumpPanelView.swift b/apple/TablePro/Views/Results/ColumnJumpPanelView.swift index 90dd8bc4df..d5404f6b96 100644 --- a/apple/TablePro/Views/Results/ColumnJumpPanelView.swift +++ b/apple/TablePro/Views/Results/ColumnJumpPanelView.swift @@ -241,12 +241,12 @@ struct ColumnJumpPanelContent: View { private var footer: some View { HStack(spacing: 14) { - QuickSwitcherKeyHint(symbol: "\u{21A9}", label: String(localized: "Jump")) + QuickSwitcherKeyHint(keys: [.returnKey], label: String(localized: "Jump")) Spacer(minLength: 0) QuickSwitcherKeyHint( - symbol: "\u{238B}", + keys: [.text("\u{238B}")], label: escapeDismissesPanel ? String(localized: "Close") : String(localized: "Clear") ) } diff --git a/apple/TablePro/Views/Results/JSONViewerView.swift b/apple/TablePro/Views/Results/JSONViewerView.swift index e127cdb2d4..ea6bdf22c8 100644 --- a/apple/TablePro/Views/Results/JSONViewerView.swift +++ b/apple/TablePro/Views/Results/JSONViewerView.swift @@ -122,9 +122,10 @@ internal struct JSONViewerView: View { Text("Text").tag(JSONViewMode.text) Text("Tree").tag(JSONViewMode.tree) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("json-viewer-mode-picker") Spacer() if let onPopOut { Button { onPopOut(displayText) } label: { diff --git a/apple/TablePro/Views/Results/PhpViewerView.swift b/apple/TablePro/Views/Results/PhpViewerView.swift index 937774f15d..df7030b9fb 100644 --- a/apple/TablePro/Views/Results/PhpViewerView.swift +++ b/apple/TablePro/Views/Results/PhpViewerView.swift @@ -75,9 +75,10 @@ internal struct PhpViewerView: View { Text(String(localized: "Tree")).tag(PhpViewMode.tree) Text(String(localized: "Raw")).tag(PhpViewMode.raw) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("php-viewer-mode-picker") Spacer() if let onPopOut { Button { onPopOut(rawValue) } label: { diff --git a/apple/TablePro/Views/Results/ResultStatusBar.swift b/apple/TablePro/Views/Results/ResultStatusBar.swift index 35605095ad..170346bdc0 100644 --- a/apple/TablePro/Views/Results/ResultStatusBar.swift +++ b/apple/TablePro/Views/Results/ResultStatusBar.swift @@ -38,7 +38,6 @@ struct ResultStatusBar: View { let columnState: StatusBarColumnState let highlightState: StatusBarHighlightState let paginationCallbacks: PaginationCallbacks - let structureFooter: StructureFooterCapability let execution: ExecutionReadout /// Held, not observed: only the readout inside the report slot re-renders when it changes. let selectionSummary: SelectionSummaryState @@ -54,8 +53,6 @@ struct ResultStatusBar: View { let onCloseOtherResultSets: (UUID) -> Void let onToggleFilters: () -> Void let onFetchAll: (() -> Void)? - let onStructureAdd: () -> Void - let onStructureRemove: () -> Void @State private var showColumnPopover = false @State private var showHighlightPopover = false @@ -146,7 +143,7 @@ struct ResultStatusBar: View { if presentation.modeSwitcherIsSegmented { picker - .pickerStyle(.segmented) + .viewSwitcherStyle() .fixedSize() } else { picker @@ -301,20 +298,6 @@ struct ResultStatusBar: View { if model.controls.showsPagination { paginationControls(presentation) } - if model.controls.showsStructureActions { - AddRemoveControlGroup( - addLabel: structureFooter.addLabel, - removeLabel: structureFooter.removeLabel, - canAdd: structureFooter.canAdd, - canRemove: structureFooter.canRemove, - addHelp: structureFooter.unavailableReason, - removeHelp: structureFooter.unavailableReason, - addIdentifier: "structure-footer-add", - removeIdentifier: "structure-footer-remove", - onAdd: onStructureAdd, - onRemove: onStructureRemove - ) - } } .fixedSize() } diff --git a/apple/TablePro/Views/Results/ResultsJsonView.swift b/apple/TablePro/Views/Results/ResultsJsonView.swift index 0c6b96b9d6..ef6606d4a9 100644 --- a/apple/TablePro/Views/Results/ResultsJsonView.swift +++ b/apple/TablePro/Views/Results/ResultsJsonView.swift @@ -123,9 +123,10 @@ internal struct ResultsJsonView: View { Text("Text").tag(JSONViewMode.text) Text("Tree").tag(JSONViewMode.tree) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("results-json-mode-picker") Spacer() diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugChooserSheet.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugChooserSheet.swift new file mode 100644 index 0000000000..b6d6b89ca2 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugChooserSheet.swift @@ -0,0 +1,147 @@ +// +// RoutineDebugChooserSheet.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +// Debug > Debug Routine…: every unit the driver can debug in the browsed schema, packages included where the engine has them. +internal struct RoutineDebugChooserSheet: View { + private enum LoadState { + case loading + case failed(String) + case loaded([Row]) + } + + private struct Row: Identifiable { + let target: PluginDebugTarget + var id: String { target.identity } + } + + let load: @MainActor () async throws -> [PluginDebugTarget] + let onFinish: @MainActor (PluginDebugTarget?) -> Void + + @State private var loadState: LoadState = .loading + @State private var searchText = "" + @State private var selection: Row.ID? + + static func present( + on window: NSWindow, + setup: RoutineDebugSetup, + load: @escaping @MainActor () async throws -> [PluginDebugTarget] + ) async -> PluginDebugTarget? { + await RoutineDebugSheet.present(on: window, setup: setup) { finish in + RoutineDebugChooserSheet(load: load, onFinish: finish) + } + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + footer + } + .frame(width: 520, height: 420) + .task { await reload() } + } + + private var header: some View { + HStack(spacing: 16) { + Text(String(localized: "Debug Routine")) + .font(.headline) + Spacer() + NativeSearchField( + text: $searchText, + placeholder: String(localized: "Search"), + accessibilityIdentifier: "routine-debug-chooser-search" + ) + .frame(width: 180) + } + .padding(20) + } + + @ViewBuilder + private var content: some View { + switch loadState { + case .loading: + ProgressView() + case .failed(let message): + UnavailableStateView { + Label(String(localized: "Can't List Routines"), systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button(String(localized: "Try Again")) { + Task { await reload() } + } + } + case .loaded(let rows) where rows.isEmpty: + UnavailableStateView(String(localized: "Nothing to Debug"), systemImage: "ladybug") + case .loaded: + if filteredRows.isEmpty { + UnavailableStateView.search(text: searchText) + } else { + Table(filteredRows, selection: $selection) { + TableColumn(String(localized: "Name")) { row in + Text(row.target.name) + } + TableColumn(String(localized: "Kind")) { row in + Text(row.target.kindDisplayName) + } + .width(min: 80, ideal: 120, max: 200) + } + .contextMenu(forSelectionType: Row.ID.self) { _ in + EmptyView() + } primaryAction: { identities in + if let identity = identities.first { choose(identity) } + } + } + } + } + + private var footer: some View { + HStack { + Spacer() + Button(String(localized: "Cancel")) { + onFinish(nil) + } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Debug…")) { + if let selection { choose(selection) } + } + .keyboardShortcut(.defaultAction) + .disabled(selection == nil) + } + .padding(20) + } + + private var filteredRows: [Row] { + guard case .loaded(let rows) = loadState else { return [] } + let query = searchText.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { return rows } + return rows.filter { $0.target.name.localizedCaseInsensitiveContains(query) } + } + + private func choose(_ identity: Row.ID) { + guard let row = filteredRows.first(where: { $0.id == identity }) else { return } + onFinish(row.target) + } + + private func reload() async { + loadState = .loading + do { + let targets = try await load() + loadState = .loaded( + targets + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + .map(Row.init(target:)) + ) + } catch { + loadState = .failed(RoutineDebugController.message(for: error)) + } + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugPanes.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugPanes.swift new file mode 100644 index 0000000000..55352966b0 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugPanes.swift @@ -0,0 +1,153 @@ +// +// RoutineDebugPanes.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +internal struct RoutineDebugVariablesView: View { + @ObservedObject var controller: RoutineDebugController + @ObservedObject private var themeEngine = ThemeEngine.shared + + private struct Row: Identifiable { + let id: Int + let variable: PluginDebugVariable + } + + var body: some View { + RoutineDebugPane(title: String(localized: "Variables")) { + if rows.isEmpty { + UnavailableStateView(String(localized: "No Variables"), systemImage: "list.bullet.rectangle") + } else { + Table(rows) { + TableColumn(String(localized: "Name")) { row in + Text(row.variable.name) + } + .width(min: 60, ideal: 120) + TableColumn(String(localized: "Value")) { row in + value(of: row.variable) + .font(themeEngine.valueFontSwiftUI) + .textSelection(.enabled) + } + .width(min: 80, ideal: 200) + TableColumn(String(localized: "Type")) { row in + Text(row.variable.typeName ?? "") + .foregroundStyle(.secondary) + } + .width(min: 50, ideal: 100) + } + } + } + } + + private var rows: [Row] { + controller.variables.enumerated().map { Row(id: $0.offset, variable: $0.element) } + } + + // NULL reads the way the grid writes it, so a value means the same thing in both places. + @ViewBuilder + private func value(of variable: PluginDebugVariable) -> some View { + if let value = variable.value { + Text(value) + } else { + Text(AppSettingsManager.shared.dataGrid.nullDisplay) + .foregroundStyle(.secondary) + } + } +} + +internal struct RoutineDebugCallStackView: View { + @ObservedObject var controller: RoutineDebugController + + private struct Row: Identifiable { + let frame: PluginDebugFrame + var id: Int { frame.level } + } + + var body: some View { + RoutineDebugPane(title: String(localized: "Call Stack")) { + if controller.frames.isEmpty { + UnavailableStateView(String(localized: "No Call Stack"), systemImage: "square.stack.3d.up") + } else { + // The previous pause's stack stays in view, dimmed, until the new one arrives. + List(controller.frames.map(Row.init(frame:)), selection: selection) { row in + VStack(alignment: .leading, spacing: 2) { + Text(row.frame.location?.unit.displayName ?? String(localized: "Unknown")) + if let line = row.frame.location?.line { + Text(String(format: String(localized: "Line %d"), line)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .listStyle(.inset) + .disabled(!controller.stackIsCurrent) + } + } + } + + private var selection: Binding { + Binding( + get: { controller.selectedFrameLevel }, + set: { level in + guard let level else { return } + controller.selectFrame(level: level) + } + ) + } +} + +internal struct RoutineDebugOutputView: View { + @ObservedObject var controller: RoutineDebugController + + var body: some View { + RoutineDebugPane(title: String(localized: "Output")) { + if let grid = controller.resultGrid { + VStack(spacing: 0) { + outputLines + .frame(maxHeight: controller.outputLines.isEmpty ? 0 : 120) + if !controller.outputLines.isEmpty { + Divider() + } + DataGridView(model: grid.model) + } + } else if controller.outputLines.isEmpty { + UnavailableStateView(String(localized: "No Output"), systemImage: "text.alignleft") + } else { + outputLines + } + } + } + + private var outputLines: some View { + ScrollView { + Text(controller.outputLines.joined(separator: "\n")) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + } +} + +// A titled region of the debug tab: a small header over its content, the way Xcode's debug area labels its panes. +internal struct RoutineDebugPane: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(spacing: 0) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .paneBarBackground() + Divider() + content() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugPreparationPrompt.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugPreparationPrompt.swift new file mode 100644 index 0000000000..ab4544ff26 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugPreparationPrompt.swift @@ -0,0 +1,38 @@ +// +// RoutineDebugPreparationPrompt.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +// The plugin's preparation question (Oracle's compile for debug), answerable by the person or withdrawn by the run. +@MainActor +internal enum RoutineDebugPreparationPrompt { + static func confirm( + _ preparation: PluginDebugPreparation, + window: NSWindow?, + setup: RoutineDebugSetup + ) async -> Bool { + guard !setup.isCancelled else { return false } + let alert = NSAlert() + alert.messageText = preparation.message + alert.informativeText = preparation.informativeText ?? "" + alert.alertStyle = .informational + alert.addButton(withTitle: preparation.actionTitle) + AlertHelper.addCancelButton(to: alert, title: String(localized: "Cancel")) + setup.dismissPresentation = { withdraw(alert) } + let response = await AlertHelper.response(to: alert, in: window) + setup.dismissPresentation = nil + return response == .alertFirstButtonReturn && !setup.isCancelled + } + + // Answered as Cancel, so the presenter's completion runs and nothing waits on an alert nobody can see. + private static func withdraw(_ alert: NSAlert) { + if let parent = alert.window.sheetParent { + parent.endSheet(alert.window, returnCode: .alertSecondButtonReturn) + } else if NSApp.modalWindow === alert.window { + NSApp.stopModal(withCode: .alertSecondButtonReturn) + } + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugSheet.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugSheet.swift new file mode 100644 index 0000000000..070d9368b7 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugSheet.swift @@ -0,0 +1,70 @@ +// +// RoutineDebugSheet.swift +// TablePro +// + +import AppKit +import SwiftUI + +// A SwiftUI view as a window sheet whose answer is awaited. The coordinator asks from a task, so there is no binding to present from. +@MainActor +internal enum RoutineDebugSheet { + // A cancelled `setup` answers the sheet as dismissed, the way Cancel would. + static func present( + on parent: NSWindow, + setup: RoutineDebugSetup, + @ViewBuilder content: (@escaping @MainActor (Value?) -> Void) -> Content + ) async -> Value? { + guard !setup.isCancelled, parent.attachedSheet == nil else { return nil } + let presentation = Presentation(parent: parent) + let hosting = NSHostingController(rootView: content { presentation.finish($0) }) + let sheet = NSWindow(contentViewController: hosting) + sheet.isReleasedWhenClosed = false + presentation.sheet = sheet + setup.dismissPresentation = { presentation.finish(nil) } + // A window closed under its sheet, as closing the connection from another window does, never answers it. + let parentClosing = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: parent, + queue: .main + ) { _ in + MainActor.assumeIsolated { + presentation.finish(nil) + } + } + let value = await withCheckedContinuation { continuation in + presentation.continuation = continuation + parent.beginSheet(sheet) + } + NotificationCenter.default.removeObserver(parentClosing) + setup.dismissPresentation = nil + return value + } + + @MainActor + private final class Presentation { + weak var parent: NSWindow? + var sheet: NSWindow? + var continuation: CheckedContinuation? + + init(parent: NSWindow) { + self.parent = parent + } + + func finish(_ value: Value?) { + guard let continuation, let sheet else { return } + self.continuation = nil + self.sheet = nil + if let parent { + parent.endSheet(sheet) + } else { + sheet.orderOut(nil) + } + // Released on the next turn: the hosting view is still inside the button action that answered. + Task { @MainActor in + sheet.contentViewController = nil + } + continuation.resume(returning: value) + } + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugSourceView.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugSourceView.swift new file mode 100644 index 0000000000..8c9b27c66f --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugSourceView.swift @@ -0,0 +1,135 @@ +// +// RoutineDebugSourceView.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProEditorKit +import TableProGrammars + +// The editor owns the ribbon's drawing and the host owns its contents, so the two meet only through this object. +@MainActor +internal final class RoutineDebugSourceBridge: TextViewCoordinator { + private weak var controller: TextViewController? + var onToggleBreakpoint: ((Int) -> Void)? + var onCursorLine: ((Int?) -> Void)? + private var breakpointLines: Set = [] + private var executionLine: Int? + + func prepareCoordinator(controller: TextViewController) { + self.controller = controller + controller.onBreakpointToggle = { [weak self] line in + self?.onToggleBreakpoint?(line) + } + apply() + } + + func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { + let line = newPositions.first?.start.line + onCursorLine?(line.flatMap { $0 > 0 ? $0 : nil }) + } + + func show(breakpoints: Set, executionLine: Int?) { + breakpointLines = breakpoints + self.executionLine = executionLine + apply() + } + + func destroy() { + controller?.onBreakpointToggle = nil + controller = nil + } + + private func apply() { + guard let controller else { return } + if controller.breakpointLines != breakpointLines { + controller.breakpointLines = breakpointLines + } + if controller.executionLine != executionLine { + controller.executionLine = executionLine + } + } +} + +// Read-only on purpose: breakpoint and stop lines index the text the session returned, so an edit would misplace them. +internal struct RoutineDebugSourceView: View { + @ObservedObject var controller: RoutineDebugController + let databaseType: DatabaseType + + @State private var text = "" + @State private var editorState = SourceEditorState() + @State private var bridge = RoutineDebugSourceBridge() + @State private var configuration = Self.makeConfiguration() + + var body: some View { + Group { + if controller.displayedUnit == nil { + placeholder + } else { + SourceEditor( + $text, + language: PluginManager.shared.editorLanguage(for: databaseType).treeSitterLanguage, + configuration: configuration, + state: $editorState, + coordinators: [bridge] + ) + } + } + .onAppear(perform: syncAll) + .onChange(of: controller.displayedSource) { source in + text = source + syncMarkers() + } + .onChange(of: controller.displayedBreakpointLines) { _ in syncMarkers() } + .onChange(of: controller.executionLine) { _ in syncMarkers() } + .onReceive(AppEvents.shared.themeChanged) { _ in + configuration = Self.makeConfiguration() + } + } + + @ViewBuilder + private var placeholder: some View { + if controller.state.isTerminal { + Color.clear + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func syncAll() { + let controller = controller + bridge.onToggleBreakpoint = { [weak controller] line in + controller?.toggleBreakpoint(atLine: line) + } + bridge.onCursorLine = { [weak controller] line in + controller?.cursorLine = line + } + text = controller.displayedSource + syncMarkers() + } + + private func syncMarkers() { + bridge.show(breakpoints: controller.displayedBreakpointLines, executionLine: controller.executionLine) + } + + private static func makeConfiguration() -> SourceEditorConfiguration { + SourceEditorConfiguration( + appearance: .init( + theme: ThemeEngine.shared.makeEditorTheme(), + font: ThemeEngine.shared.editorFonts.font, + wrapLines: false + ), + behavior: .init(isEditable: false), + layout: .init(contentInsets: NSEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)), + peripherals: .init( + showGutter: true, + showLineNumbers: true, + showFoldingRibbon: false, + showSpecialCharacters: false, + showBreakpoints: true + ) + ) + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugStartSheet.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugStartSheet.swift new file mode 100644 index 0000000000..0e6e6e27c0 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugStartSheet.swift @@ -0,0 +1,266 @@ +// +// RoutineDebugStartSheet.swift +// TablePro +// + +import Combine +import SwiftUI +import TableProPluginKit + +@MainActor +internal final class RoutineDebugStartModel: ObservableObject { + struct Argument: Identifiable, Equatable { + let id: Int + let parameter: PluginDebugParameter + var text = "" + var isNull = false + var usesDefault = false + + var takesValue: Bool { + parameter.mode != .output + } + + var value: PluginDebugArgumentValue { + if usesDefault { return .defaultValue } + if isNull { return .null } + return .text(text) + } + } + + let plan: PluginDebugPlan + @Published private(set) var entryPointIndex = 0 + @Published private(set) var arguments: [Argument] = [] + @Published private(set) var optionValues: [String: String] = [:] + + init(plan: PluginDebugPlan, rememberedOption: (String) -> String?) { + self.plan = plan + for field in plan.options { + optionValues[field.id] = field.isSecure ? field.defaultValue ?? "" : rememberedOption(field.id) ?? field.defaultValue ?? "" + } + arguments = Self.arguments(for: plan.entryPoints.first) + } + + var entryPoint: PluginDebugEntryPoint? { + plan.entryPoints.indices.contains(entryPointIndex) ? plan.entryPoints[entryPointIndex] : nil + } + + func selectEntryPoint(_ index: Int) { + guard plan.entryPoints.indices.contains(index), index != entryPointIndex else { return } + entryPointIndex = index + arguments = Self.arguments(for: plan.entryPoints[index]) + } + + func setText(_ text: String, for id: Int) { + update(id) { $0.text = text } + } + + func setNull(_ isNull: Bool, for id: Int) { + update(id) { argument in + argument.isNull = isNull + if isNull { argument.usesDefault = false } + } + } + + func setUsesDefault(_ usesDefault: Bool, for id: Int) { + update(id) { argument in + argument.usesDefault = usesDefault + if usesDefault { argument.isNull = false } + } + } + + func setOption(_ value: String, for fieldId: String) { + optionValues[fieldId] = value + } + + func isVisible(_ field: ConnectionField) -> Bool { + guard let rule = field.visibleWhen else { return true } + return rule.values.contains(optionValues[rule.fieldId] ?? "") + } + + func choice() -> RoutineDebugStartChoice? { + guard let entryPoint else { return nil } + return RoutineDebugStartChoice( + entryPoint: entryPoint, + arguments: arguments.filter(\.takesValue).map { + PluginDebugArgument(parameterName: $0.parameter.name, value: $0.value) + }, + optionValues: optionValues.filter { id, _ in + plan.options.contains { $0.id == id && isVisible($0) } + } + ) + } + + // One whole-value write, so a NULL tick and the text it clears land together. + private func update(_ id: Int, _ change: (inout Argument) -> Void) { + guard let index = arguments.firstIndex(where: { $0.id == id }) else { return } + var argument = arguments[index] + change(&argument) + arguments[index] = argument + } + + private static func arguments(for entryPoint: PluginDebugEntryPoint?) -> [Argument] { + (entryPoint?.parameters ?? []).enumerated().map { index, parameter in + Argument(id: index, parameter: parameter, usesDefault: parameter.hasDefault) + } + } +} + +internal struct RoutineDebugStartSheet: View { + @StateObject private var model: RoutineDebugStartModel + let onFinish: @MainActor (RoutineDebugStartChoice?) -> Void + + init(model: RoutineDebugStartModel, onFinish: @escaping @MainActor (RoutineDebugStartChoice?) -> Void) { + _model = StateObject(wrappedValue: model) + self.onFinish = onFinish + } + + static func present( + plan: PluginDebugPlan, + rememberedOption: (String) -> String?, + on window: NSWindow, + setup: RoutineDebugSetup + ) async -> RoutineDebugStartChoice? { + let model = RoutineDebugStartModel(plan: plan, rememberedOption: rememberedOption) + return await RoutineDebugSheet.present(on: window, setup: setup) { finish in + RoutineDebugStartSheet(model: model, onFinish: finish) + } + } + + // Secrets stay out of defaults; the plan supplies them again next time. + static func rememberedValues(of choice: RoutineDebugStartChoice, in plan: PluginDebugPlan) -> [String: String] { + let secure = Set(plan.options.filter(\.isSecure).map(\.id)) + return choice.optionValues.filter { !secure.contains($0.key) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(String(format: String(localized: "Debug %@"), model.plan.target.name)) + .font(.headline) + + if model.plan.entryPoints.count > 1 { + Picker(String(localized: "Entry Point"), selection: entryPointBinding) { + ForEach(Array(model.plan.entryPoints.enumerated()), id: \.offset) { index, entryPoint in + Text(entryPoint.displayName).tag(index) + } + } + } + + if model.arguments.isEmpty { + Text(String(localized: "This routine takes no arguments.")) + .foregroundStyle(.secondary) + } else { + argumentTable + } + + if !model.plan.options.isEmpty { + Form { + ForEach(model.plan.options.filter { model.isVisible($0) }, id: \.id) { field in + optionRow(field) + } + } + } + + if let note = model.plan.note, !note.isEmpty { + Text(note) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + Button(String(localized: "Cancel")) { + onFinish(nil) + } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Debug")) { + onFinish(model.choice()) + } + .keyboardShortcut(.defaultAction) + .disabled(model.entryPoint == nil) + } + } + .padding(20) + .frame(width: 600) + } + + private var entryPointBinding: Binding { + Binding(get: { model.entryPointIndex }, set: { model.selectEntryPoint($0) }) + } + + // The ideal widths plus the table's insets and column gaps stay inside the sheet, or Default scrolls out of sight. + private var argumentTable: some View { + Table(model.arguments) { + TableColumn(String(localized: "Name")) { argument in + Text(argument.parameter.name) + } + .width(min: 80, ideal: 100) + TableColumn(String(localized: "Type")) { argument in + Text(argument.parameter.typeName) + .foregroundStyle(.secondary) + } + .width(min: 60, ideal: 80) + TableColumn(String(localized: "Value")) { argument in + if argument.takesValue { + TextField( + String(localized: "Value"), + text: Binding(get: { argument.text }, set: { model.setText($0, for: argument.id) }) + ) + .labelsHidden() + .disabled(argument.isNull || argument.usesDefault) + } else { + Text(String(localized: "Output")) + .foregroundStyle(.secondary) + } + } + .width(min: 120, ideal: 140) + TableColumn(String(localized: "NULL")) { argument in + Toggle( + String(localized: "NULL"), + isOn: Binding(get: { argument.isNull }, set: { model.setNull($0, for: argument.id) }) + ) + .labelsHidden() + .disabled(!argument.takesValue) + } + .width(48) + TableColumn(String(localized: "Default")) { argument in + Toggle( + String(localized: "Default"), + isOn: Binding(get: { argument.usesDefault }, set: { model.setUsesDefault($0, for: argument.id) }) + ) + .labelsHidden() + .disabled(!argument.takesValue || !argument.parameter.hasDefault) + } + .width(56) + } + .frame(height: min(CGFloat(model.arguments.count) * 28 + 32, 220)) + } + + @ViewBuilder + private func optionRow(_ field: ConnectionField) -> some View { + let binding = Binding( + get: { model.optionValues[field.id] ?? "" }, + set: { model.setOption($0, for: field.id) } + ) + switch field.fieldType { + case .dropdown(let options): + Picker(field.label, selection: binding) { + ForEach(options, id: \.value) { option in + Text(option.label).tag(option.value) + } + } + case .toggle: + Toggle( + field.label, + isOn: Binding( + get: { binding.wrappedValue == "true" }, + set: { binding.wrappedValue = $0 ? "true" : "false" } + ) + ) + case .secure: + SecureField(field.label, text: binding, prompt: Text(field.placeholder)) + default: + TextField(field.label, text: binding, prompt: Text(field.placeholder)) + } + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugTabView.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugTabView.swift new file mode 100644 index 0000000000..4ff591faf7 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugTabView.swift @@ -0,0 +1,144 @@ +// +// RoutineDebugTabView.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +// The call stack beside the source, and under them Xcode's debug area: its bar of controls and status, then variables beside the output. +// Nested split views first lay out at their minimums, before the window gives them room, so holding priorities, not +// thickness fractions, decide the layout: the source takes the room, the debug area and the stack keep their size, and +// variables and output share theirs. +internal struct RoutineDebugTabView: View { + @ObservedObject var controller: RoutineDebugController + let databaseType: DatabaseType + + var body: some View { + AutosavingSplitView( + autosaveName: "com.TablePro.routineDebug.sourceAndPanes", + isVertical: false, + primaryMinimum: 160, + secondaryMinimum: 200, + primaryHoldingPriority: .splitPaneFlexible + ) { + AutosavingSplitView( + autosaveName: "com.TablePro.routineDebug.stackAndSource", + primaryMinimum: 200, + secondaryMinimum: 280 + ) { + RoutineDebugCallStackView(controller: controller) + } secondary: { + RoutineDebugSourceView(controller: controller, databaseType: databaseType) + } + } secondary: { + VStack(spacing: 0) { + RoutineDebugBar(controller: controller) + Divider() + AutosavingSplitView( + autosaveName: "com.TablePro.routineDebug.variablesAndOutput", + primaryMinimum: 220, + secondaryMinimum: 220, + primaryHoldingPriority: .defaultLow + ) { + RoutineDebugVariablesView(controller: controller) + } secondary: { + RoutineDebugOutputView(controller: controller) + } + } + } + } +} + +// The Debug menu's commands as one-click controls, with the run's status beside them. +internal struct RoutineDebugBar: View { + @ObservedObject var controller: RoutineDebugController + + var body: some View { + HStack(spacing: 12) { + HStack(spacing: 2) { + command(.continueExecution, title: String(localized: "Continue"), symbol: "play") + command(.stepOver, title: String(localized: "Step Over"), symbol: "arrow.uturn.forward") + command(.stepInto, title: String(localized: "Step Into"), symbol: "arrow.down.to.line") + command(.stepOut, title: String(localized: "Step Out"), symbol: "arrow.up.to.line") + Button { + controller.stop() + } label: { + Label(String(localized: "Stop"), systemImage: "stop") + } + .help(String(localized: "Stop Debugging")) + .disabled(!controller.canStop) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + + Divider() + .frame(height: 16) + + statusIcon + Text(controller.statusText) + .lineLimit(1) + .truncationMode(.tail) + .help(controller.statusText) + .accessibilityIdentifier("routine-debug-status") + if let error = controller.inspectionError { + Text(error) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .help(error) + } + Spacer(minLength: 0) + } + .font(.callout) + .padding(.horizontal, 10) + .frame(height: 28) + .paneBarBackground() + } + + private func command(_ command: PluginDebugCommand, title: String, symbol: String) -> some View { + Button { + controller.perform(command) + } label: { + Label(title, systemImage: symbol) + } + .help(title) + .disabled(!controller.canPerform(command)) + } + + @ViewBuilder + private var statusIcon: some View { + switch controller.state { + case .preparing, .starting, .running: + ProgressView() + .controlSize(.small) + case .paused: + Image(systemName: "pause.circle") + .foregroundStyle(.secondary) + case .finished: + Image(systemName: "checkmark.circle") + .foregroundStyle(.secondary) + case .failed: + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + case .stopped: + Image(systemName: "stop.circle") + .foregroundStyle(.secondary) + } + } +} + +// A debug tab exists only with a live controller, so a tab that has none says so rather than drawing empty panes. +internal struct RoutineDebugTabContent: View { + let tabId: UUID + let databaseType: DatabaseType + + var body: some View { + if let controller = RoutineDebugSessions.shared.controller(forTab: tabId) { + RoutineDebugTabView(controller: controller, databaseType: databaseType) + } else { + UnavailableStateView(String(localized: "Debug Session Ended"), systemImage: "ladybug") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift b/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift index 0950b8e615..b8094dbc0c 100644 --- a/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift +++ b/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift @@ -10,7 +10,6 @@ import SwiftUI internal struct FieldEditorContent: View { internal let context: FieldEditorContext internal let kind: FieldEditorKind - internal let databaseType: DatabaseType internal var onSetNull: (() -> Void)? internal var onSetDefault: (() -> Void)? internal var onPopOut: ((String) -> Void)? @@ -29,8 +28,8 @@ internal struct FieldEditorContent: View { /// take away the control that changes it back. private var isPicker: Bool { switch kind { - case .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker: return true - case .json, .phpSerialized, .binaryValue, .image, .geometry, .blobHex, .schemaText, .multiLine, .singleLine: + case .boolean, .enumPicker, .setPicker, .arrayElements, .vector: return true + case .json, .phpSerialized, .binaryValue, .image, .geometry, .blobHex, .multiLine, .singleLine: return false } } @@ -42,8 +41,7 @@ internal struct FieldEditorContent: View { switch kind { case .json, .phpSerialized, .binaryValue, .multiLine: return true case .geometry(let descriptor): return canExpand(kind: descriptor.textEditorKind, state: state) - case .image, .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, - .valuePicker, .schemaText, .singleLine: + case .image, .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .singleLine: return false } } @@ -57,8 +55,7 @@ internal struct FieldEditorContent: View { case .blobHex: return 60 case .multiLine: return ResizableFieldMetrics.defaultTextHeight case .geometry(let descriptor): return pillHeight(for: descriptor.textEditorKind) - case .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker, - .schemaText, .singleLine: + case .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .singleLine: return nil } } @@ -100,12 +97,6 @@ internal struct FieldEditorContent: View { ) case .vector: VectorFieldEditorView(context: context, onSetNull: onSetNull, onSetDefault: onSetDefault) - case .typePicker: - TypePickerFieldView(context: context, databaseType: databaseType) - case .valuePicker(let options): - ValuePickerFieldView(context: context, options: options) - case .schemaText: - SchemaTextFieldView(context: context) case .multiLine: MultiLineEditorView(context: context, onPopOut: onPopOut, isExpanded: isExpanded) case .singleLine: diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContext.swift b/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContext.swift index 2fdf5ea681..46174a22b3 100644 --- a/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContext.swift +++ b/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContext.swift @@ -23,12 +23,8 @@ internal struct FieldEditorContext { /// column type and the value only when this is nil, and resolving costs a parse of the value. internal let editor: FieldEditorKind? - /// A schema field has no NULL or DEFAULT state and no data type to badge. + // False where the store has no NULL or DEFAULT to write, as in a data file. internal let allowsNullAndDefault: Bool - internal let showsTypeBadge: Bool - - /// The table a column type picker offers user-defined types for. Nil outside a structure row. - internal let userDefinedTypeScope: DatabaseScope? internal init( columnName: String, @@ -40,9 +36,7 @@ internal struct FieldEditorContext { isReadOnly: Bool, commitBytes: ((Data) -> Void)? = nil, editor: FieldEditorKind? = nil, - allowsNullAndDefault: Bool = true, - showsTypeBadge: Bool = true, - userDefinedTypeScope: DatabaseScope? = nil + allowsNullAndDefault: Bool = true ) { self.columnName = columnName self.columnType = columnType @@ -54,8 +48,6 @@ internal struct FieldEditorContext { self.commitBytes = commitBytes self.editor = editor self.allowsNullAndDefault = allowsNullAndDefault - self.showsTypeBadge = showsTypeBadge - self.userDefinedTypeScope = userDefinedTypeScope } internal var hasMultipleValues: Bool { valueState == .multipleValues } diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift index c59b5b409c..ec283a95de 100644 --- a/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift +++ b/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift @@ -83,9 +83,8 @@ internal struct GeometryFieldView: View { Text(String(localized: "Text")).tag(Mode.text) Text(String(localized: "Map")).tag(Mode.map) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() - .controlSize(.small) .fixedSize() .accessibilityIdentifier("inspector-geometry-mode") diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/SchemaTextFieldView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/SchemaTextFieldView.swift deleted file mode 100644 index 130bac1db9..0000000000 --- a/apple/TablePro/Views/RowInspector/FieldEditors/SchemaTextFieldView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// SchemaTextFieldView.swift -// TablePro -// - -import SwiftUI - -/// Text editor for a schema field. Commits on Return or when focus leaves, the way -/// the structure grid's cell editor does, so one edit records one schema change -/// instead of one per keystroke. -internal struct SchemaTextFieldView: View { - let context: FieldEditorContext - - @State private var draft: String = "" - @State private var hasSeededDraft = false - @FocusState private var isFocused: Bool - - var body: some View { - TextField(context.placeholderText, text: $draft) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled(true) - .focused($isFocused) - .disabled(context.isReadOnly) - .onAppear(perform: seedDraftOnFirstAppearance) - .onChange(of: context.value.wrappedValue) { newValue in - guard !isFocused else { return } - draft = newValue - } - .onChange(of: isFocused) { focused in - guard !focused else { return } - commit() - } - .onSubmit { commit() } - } - - /// A connection switch or a trailing-pane swap removes the field and re-adds it with the draft - /// intact. Reseeding there overwrote typing not yet committed, and the blur that follows then - /// found nothing to commit. A real change of the stored value still arrives through `onChange`. - private func seedDraftOnFirstAppearance() { - guard !hasSeededDraft else { return } - hasSeededDraft = true - draft = context.value.wrappedValue - } - - private func commit() { - guard draft != context.value.wrappedValue else { return } - context.value.wrappedValue = draft - } -} diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/TypePickerFieldView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/TypePickerFieldView.swift deleted file mode 100644 index 0fac5c11eb..0000000000 --- a/apple/TablePro/Views/RowInspector/FieldEditors/TypePickerFieldView.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// TypePickerFieldView.swift -// TablePro -// - -import SwiftUI - -/// Column type editor for a schema field: free-form text for lengths and precision, -/// plus the same searchable type list the structure grid opens from its cell chevron. -internal struct TypePickerFieldView: View { - let context: FieldEditorContext - let databaseType: DatabaseType - - @State private var isPickerPresented = false - - var body: some View { - HStack(spacing: 4) { - SchemaTextFieldView(context: context) - - Button { - isPickerPresented = true - } label: { - Image(systemName: "chevron.down") - .font(.caption) - .foregroundStyle(.secondary) - } - .buttonStyle(.borderless) - .disabled(context.isReadOnly) - .help(String(localized: "Choose Type")) - .accessibilityLabel(String(localized: "Choose Type")) - .popover(isPresented: $isPickerPresented) { - UserDefinedTypeAwarePicker(scope: context.userDefinedTypeScope) { userDefinedTypes in - TypePickerContentView( - databaseType: databaseType, - currentValue: context.value.wrappedValue, - userDefinedTypes: userDefinedTypes, - onCommit: { context.value.wrappedValue = $0 }, - onDismiss: { isPickerPresented = false } - ) - } - } - } - } -} diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift deleted file mode 100644 index e75f110ec1..0000000000 --- a/apple/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// ValuePickerFieldView.swift -// TablePro -// - -import SwiftUI - -/// A field with suggestions the user may ignore: the same menu the structure grid opens from its -/// cell chevron, over the same free-form text field every other schema field uses. -/// -/// `EnumPickerView` cannot serve here. Its list is the whole set of legal values, so it has no way to -/// hold a value that is not in it, and a column default is an open-ended SQL expression. -internal struct ValuePickerFieldView: View { - internal let context: FieldEditorContext - internal let options: [GridMenuOption] - - @State private var isCustomPresented = false - - var body: some View { - HStack(spacing: 4) { - SchemaTextFieldView(context: context) - - Menu { - ForEach(Array(options.enumerated()), id: \.offset) { _, option in - menuEntry(option) - } - } label: { - Label { Text("Choose Value") } icon: { EmptyView() } - } - .labelStyle(.iconOnly) - .menuStyle(.button) - .buttonStyle(.borderless) - .menuIndicator(.visible) - .fixedSize() - .disabled(context.isReadOnly) - /// The tooltip only. The name already comes from the label's title, and an accessibility - /// label modifier on a `Menu` replaces that name with nothing rather than adding one. - .help(String(localized: "Choose Value")) - /// A SwiftUI `Menu` is an `NSPopUpButton` on macOS, so it publishes `menuButton` or - /// `popUpButton` and never `button`, and no suite has ever resolved one by its label. - /// The identifier is the hook `InspectorFieldRow`'s own value menu already carries, and - /// unlike the label it does not move with the display language. - .accessibilityIdentifier("column-default-menu") - .popover(isPresented: $isCustomPresented) { - CustomValueContentView( - initialValue: context.value.wrappedValue, - escapeStringLiteral: escapeStringLiteral, - stringLiteralPrefix: stringLiteralPrefix, - onCommit: { context.value.wrappedValue = $0 }, - onDismiss: { isCustomPresented = false } - ) - } - } - } - - @ViewBuilder - private func menuEntry(_ option: GridMenuOption) -> some View { - switch option { - case .sectionHeader(let title): - Divider() - Text(title) - case .value(let title, let sql): - Button { - context.value.wrappedValue = sql - } label: { - if sql == context.value.wrappedValue { - Label(title, systemImage: "checkmark") - } else { - Text(title) - } - } - case .clear(let title): - Button(title) { context.value.wrappedValue = "" } - case .custom(let title): - Divider() - Button(title) { isCustomPresented = true } - } - } - - /// The connected driver's own escaping, the same seam the grid's chevron menu uses, so the two - /// surfaces cannot escape one value two ways. It falls back to the shared helper only where the - /// field has no connection behind it, which is every field outside a structure row. - private var escapeStringLiteral: (String) -> String { - guard let connectionId = context.userDefinedTypeScope?.connectionId, - let driver = DatabaseManager.shared.driver(for: connectionId) else { - return SQLEscaping.escapeStringLiteral - } - return driver.escapeStringLiteral - } - - /// From the same connection the escaping comes from, so a default typed here is written the - /// way the engine reads it back. No connection means no prefix, which is what every engine but - /// SQL Server wants anyway. - private var stringLiteralPrefix: String { - guard let connectionId = context.userDefinedTypeScope?.connectionId, - let driver = DatabaseManager.shared.driver(for: connectionId) else { - return "" - } - return SQLStringLiteralPrefix.forDatabaseType(driver.connection.type) - } -} diff --git a/apple/TablePro/Views/RowInspector/InspectorFieldListView.swift b/apple/TablePro/Views/RowInspector/InspectorFieldListView.swift index fcee8688a1..8328ab0a09 100644 --- a/apple/TablePro/Views/RowInspector/InspectorFieldListView.swift +++ b/apple/TablePro/Views/RowInspector/InspectorFieldListView.swift @@ -17,7 +17,6 @@ internal struct InspectorFieldListView: View { @ObservedObject internal var editState: MultiRowEditState internal let isEditable: Bool internal let databaseType: DatabaseType - internal let userDefinedTypeScope: DatabaseScope? internal var offersDatabaseValues = true private var offersFieldRemoval: Bool { @@ -93,14 +92,12 @@ internal struct InspectorFieldListView: View { case .phpSerialized, .binaryValue: return false case .json, .image, .geometry, .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, - .typePicker, .valuePicker, .schemaText, .multiLine, .singleLine: + .multiLine, .singleLine: return true } } - /// A structure row's edits are recorded by its own grid, so `hasEdits` stays false while - /// `hasCommittedEdit` is true. Gating on pending edits alone left the filter permanently - /// unusable on exactly the rows it lists as modified. + // Rows that disagree carry an edit made elsewhere as `hasCommittedEdit`, with no pending value. private var hasAnyModification: Bool { editState.fields.contains { $0.hasEdit || $0.hasCommittedEdit } } @@ -156,19 +153,15 @@ internal struct InspectorFieldListView: View { let commands = editState.setValueCommands(for: field, isWritable: editable && offersDatabaseValues) InspectorFieldRow( context: context(for: field, kind: kind, isEditable: editable), - layout: InspectorFieldLayout.resolve(for: kind, isSchemaField: field.isSchemaField), kind: kind, isModified: field.hasEdit || field.hasCommittedEdit, isPrimaryKey: field.isPrimaryKey, isForeignKey: field.isForeignKey, - databaseType: databaseType, isExpanded: expandedFieldID == field.id, commands: commands, isPendingExpression: field.isPendingExpression, onSetValue: { editState.setValue($0, at: field.columnIndex) }, - onRemoveField: offersFieldRemoval && !field.isSchemaField - ? { editState.removeField(at: field.columnIndex) } - : nil, + onRemoveField: offersFieldRemoval ? { editState.removeField(at: field.columnIndex) } : nil, onToggleExpand: FieldEditorContent.canExpand(kind: kind, state: FieldValueState.resolve(field)) ? { expandedFieldID = expandedFieldID == field.id ? nil : field.id } : nil, @@ -208,9 +201,7 @@ internal struct InspectorFieldListView: View { ? { editState.setFieldToBytes(at: field.columnIndex, data: $0) } : nil, editor: kind, - allowsNullAndDefault: offersDatabaseValues && !field.isSchemaField, - showsTypeBadge: !field.isSchemaField, - userDefinedTypeScope: field.isSchemaField ? userDefinedTypeScope : nil + allowsNullAndDefault: offersDatabaseValues ) } @@ -251,7 +242,6 @@ internal struct InspectorFieldListView: View { offersDatabaseValues, let focusedField, let field = editState.fields.first(where: { $0.id == focusedField }), - !field.isSchemaField, Self.isFieldEditable(field, kind: FieldEditorResolver.resolve(field: field), rowIsEditable: isEditable), let command = Self.stateShortcutCommand( for: key, offered: editState.setValueCommands(for: field, isWritable: true) diff --git a/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift b/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift index c9c57b6e31..20ca42288f 100644 --- a/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift +++ b/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift @@ -10,12 +10,10 @@ import SwiftUI internal struct InspectorFieldRow: View { @ObservedObject private var themeEngine = ThemeEngine.shared internal let context: FieldEditorContext - internal let layout: InspectorFieldLayout internal let kind: FieldEditorKind internal let isModified: Bool internal let isPrimaryKey: Bool internal let isForeignKey: Bool - internal let databaseType: DatabaseType internal let isExpanded: Bool internal let commands: [SetValueCommand] internal var isPendingExpression = false @@ -29,51 +27,24 @@ internal struct InspectorFieldRow: View { internal let fieldID: UUID var body: some View { - Group { - switch layout { - case .inline: inlineRow - case .stacked: stackedRow - } - } - .contentShape(Rectangle()) - .contextMenu { menuContent } - .accessibilityElement(children: .contain) - .accessibilityLabel(accessibilityLabel) + row + .contentShape(Rectangle()) + .contextMenu { menuContent } + .accessibilityElement(children: .contain) + .accessibilityLabel(accessibilityLabel) } - // MARK: - Layouts - - /// A schema row: the label in a fixed trailing-aligned lane, the editor filling what is left. - /// The lane is a constant because the label vocabulary is closed and authored; its widest member - /// renders at 66.4pt, so 96 carries every one of them with room for a longer translation. - private var inlineRow: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - HStack(spacing: 3) { - statusGlyphs - fieldLabel - } - .frame(width: Self.schemaLabelLaneWidth, alignment: .trailing) - editor - .frame(maxWidth: .infinity, alignment: .leading) - modifiedGlyph - valueMenu - } - .padding(.vertical, InspectorMetrics.betweenFields / 2) - } + // MARK: - Layout - /// A data row: name and type on their own line, the value at full width beneath. Every value in - /// the pane then starts at the same x and gets the whole content width, against the 24 to 31pt a - /// long column name used to leave it. - private var stackedRow: some View { + // Always stacked: a column name has no length bound, so a label lane beside the value truncated both. + private var row: some View { VStack(alignment: .leading, spacing: InspectorMetrics.labelToValue) { HStack(spacing: 4) { statusGlyphs fieldLabel Spacer(minLength: 4) modifiedGlyph - if context.showsTypeBadge { - TypeBadge(context.columnType.badgeLabel) - } + TypeBadge(context.columnType.badgeLabel) if let onToggleExpand { Button( isExpanded ? String(localized: "Collapse") : String(localized: "Expand"), @@ -146,7 +117,7 @@ internal struct InspectorFieldRow: View { var parts = [context.columnName] if isPrimaryKey { parts.append(String(localized: "Primary key")) } if isForeignKey { parts.append(String(localized: "Foreign key")) } - if context.showsTypeBadge { parts.append(context.columnType.badgeLabel) } + parts.append(context.columnType.badgeLabel) if isModified { parts.append(String(localized: "Modified")) } return parts.joined(separator: ", ") } @@ -198,7 +169,6 @@ internal struct InspectorFieldRow: View { FieldEditorContent( context: context, kind: kind, - databaseType: databaseType, onSetNull: context.canMutate && commands.contains(.null) ? { onSetValue(.null) } : nil, onSetDefault: context.canMutate && commands.contains(.defaultValue) ? { onSetValue(.defaultValue) } : nil, onPopOut: onPopOut, @@ -218,20 +188,8 @@ internal struct InspectorFieldRow: View { return nil case .geometry: return nil - case .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker, - .schemaText, .multiLine, .singleLine: + case .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .multiLine, .singleLine: return ThemeEngine.shared.valueFontSwiftUI } } - - - /// The lane a schema row's label sits in. The vocabulary is closed and authored, and its widest - /// member, "Ref Columns", renders at 66.4pt in `.subheadline`, so this carries every label with - /// 45% left over for a longer translation. A label that still overruns truncates in the middle - /// and keeps its tooltip rather than taking the width from its own value. - /// - /// It is a constant, not a measurement. A lane derived from the rows on screen would change - /// width as the user scrolls, because a `PreferenceKey` max-reduce inside a `List` only ever - /// sees the realized rows. - private static let schemaLabelLaneWidth: CGFloat = 96 } diff --git a/apple/TablePro/Views/RowInspector/RowInspectorView.swift b/apple/TablePro/Views/RowInspector/RowInspectorView.swift index a9a534f818..03a8555d7f 100644 --- a/apple/TablePro/Views/RowInspector/RowInspectorView.swift +++ b/apple/TablePro/Views/RowInspector/RowInspectorView.swift @@ -5,12 +5,9 @@ import SwiftUI -/// The window's inspector: the currently selected row, in one of two renderings. -/// -/// It replaces a view that carried four modes plus two full-panel takeovers, each takeover drawing -/// its own back button over the rest of the row. There is no navigation here: a structured value -/// grows in place and the pop-out windows take anything larger, so the fields around it never go -/// away. +// The window's inspector: the selected row in one of two renderings, or a structure definition's form while a +// Structure view or a Create Table tab is on screen. No navigation: a structured value grows in place and the +// pop-out windows take anything larger. internal struct RowInspectorView: View { @ObservedObject private var state: RowInspectorState private let paneState: TrailingPaneState @@ -42,8 +39,12 @@ internal struct RowInspectorView: View { ) { section in menuSection(section) } - InspectorSubjectView(subject: context.subject) - content + if let structure = context.structure { + StructureInspectorView(source: structure, tableMetadata: context.tableMetadata) + } else { + InspectorSubjectView(subject: context.subject) + content + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -60,9 +61,7 @@ internal struct RowInspectorView: View { } } - /// Both renderings are views of the same selection, which is the case Apple's inspector guidance - /// covers. The header offers this only while `offeredRendering` has a value, and there the stored - /// mode is the one drawn, so the item it checks is the one on screen. + // Offered only while `offeredRendering` has a value, so the item it checks is the rendering on screen. private var renderingPicker: some View { Picker(String(localized: "Inspector View"), selection: $state.viewMode) { ForEach(InspectorViewMode.allCases, id: \.self) { mode in @@ -73,22 +72,14 @@ internal struct RowInspectorView: View { .labelsHidden() } - /// The rendering on screen, for a selection that can be drawn both ways. A schema grid's - /// selection is a column definition with no types and no foreign keys to follow, and a pane with - /// no row draws table info or nothing, so neither offers a choice: the stored mode would be - /// checked there over a pane drawing something else. + // Only a data row can be drawn both ways; a structure definition or a pane with no row offers no choice. private var offeredRendering: InspectorViewMode? { - guard context.hasRow, context.jsonRow != nil else { return nil } + guard context.structure == nil, context.hasRow, context.jsonRow != nil else { return nil } return showsFields ? .fields : .json } - /// The field list stays mounted and is hidden rather than rebuilt. - /// - /// It owns its search term, its edited-only setting, which field is expanded, which field has - /// focus and the `List`'s scroll position, and none of that survives leaving the hierarchy. - /// Rebuilding it also rebuilds a row per column, which is what scales on a wide table. The JSON - /// rendering is cheap to rebuild and keeps its own reader state in `jsonViewModel`, so it stays - /// conditional. + // The field list stays mounted and hidden: its search, expansion, focus and scroll position would not survive + // a rebuild, and a rebuild makes a row per column. The JSON rendering keeps its state in `jsonViewModel`. @ViewBuilder private var content: some View { if context.hasRow { @@ -96,10 +87,8 @@ internal struct RowInspectorView: View { fieldsMode .opacity(showsFields ? 1 : 0) .allowsHitTesting(showsFields) - /// Neither opacity nor hit-testing stops the keyboard. Without this a field - /// still holding first responder when the mode changed goes on accepting text - /// and the list's Ctrl-Option-N and Ctrl-Option-D behind the JSON view, and - /// each of those stages a real cell change that Save later commits unseen. + // Neither opacity nor hit-testing stops the keyboard: a focused field behind the JSON view + // would go on staging cell changes that Save commits unseen. .disabled(!showsFields) .accessibilityHidden(!showsFields) @@ -114,8 +103,6 @@ internal struct RowInspectorView: View { } } - /// The JSON rendering only exists for a data-grid row, because a schema grid's selection is a - /// column definition with no types and no foreign keys to follow. private var showsFields: Bool { state.viewMode == .fields || context.jsonRow == nil } @@ -125,7 +112,6 @@ internal struct RowInspectorView: View { editState: state.editState, isEditable: context.isEditable && !context.isRowDeleted, databaseType: connection.type, - userDefinedTypeScope: context.userDefinedTypeScope, onPopOut: popOut ) } @@ -140,9 +126,7 @@ internal struct RowInspectorView: View { ) } - /// The inspector's own glyph rather than `sidebar.right`, which is the pane and which the pane's - /// not-connected state drew too: a row not being selected and a connection being down read the - /// same. + // The inspector's own glyph, not `sidebar.right`, which the not-connected state draws. private var emptyState: some View { UnavailableStateView( String(localized: "No Row Selected"), @@ -152,12 +136,9 @@ internal struct RowInspectorView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - /// The pop-out windows are the escape for a value too large for the pane, and the only escape: - /// no competitor ships an in-panel takeover and neither does this any more. private func popOut(field: FieldEditState, text: String, kind: FieldEditorKind) { let isEditable = context.isEditable && !context.isRowDeleted && !field.isServerOwned - /// Captured, not looked up per keystroke: the field's id is reissued on every selection - /// change, and a window left open over a new selection dropped what was typed into it. + // Captured, not looked up per keystroke: the field's id is reissued on every selection change. let columnIndex = field.columnIndex let rowIDs = state.editState.rowIDs let commit: ((String) -> Void)? = isEditable @@ -189,8 +170,7 @@ internal struct RowInspectorView: View { } } -/// The window a field's text opens in. A geometry field's map opens its own window from the field; -/// what reaches here is its Text segment, which goes where that editor's kind would. +// A geometry field's map opens its own window; its Text segment goes where that editor's kind would. internal enum InspectorPopOutWindow: Equatable { case json case php @@ -207,19 +187,14 @@ internal enum InspectorPopOutWindow: Equatable { return .binary(format) case .geometry(let descriptor): return resolve(for: descriptor.textEditorKind) - case .multiLine, .singleLine, .schemaText, .blobHex, .image, .boolean, - .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker: + case .multiLine, .singleLine, .blobHex, .image, .boolean, .enumPicker, .setPicker, .arrayElements, .vector: return .text } } } -/// The JSON rendering's own commands, in the pane header's menu while that rendering is on screen. -/// -/// They were an ellipsis of their own at the end of the JSON filter field, which put two ellipsis -/// menus one above the other in the same column once the pane's header gained one. Its own view so -/// it observes the reader's model: the inspector does not, and a menu built from it would go on -/// showing Always Expand Foreign Keys in the state it had when the pane last redrew. +// The JSON rendering's commands in the pane header's menu. Its own view so it observes the reader's model, which +// the inspector does not. private struct JSONReadingCommands: View { @ObservedObject var viewModel: JSONRowInspectorViewModel diff --git a/apple/TablePro/Views/RowInspector/TableInfoView.swift b/apple/TablePro/Views/RowInspector/TableInfoView.swift index 5baf7bf061..1927087b00 100644 --- a/apple/TablePro/Views/RowInspector/TableInfoView.swift +++ b/apple/TablePro/Views/RowInspector/TableInfoView.swift @@ -5,79 +5,83 @@ import SwiftUI -/// The table's own statistics, shown when the inspector has a table but no row selected. -/// -/// Not a `Form`. `.formStyle(.grouped)` insets its content 30pt per side and there is no way to ask -/// it for less: measured, that 30 holds at 250, 270, 300, 340, 420 and 600pt, so it is not a margin -/// that gives way on a narrow pane. At the inspector's 270pt minimum it left a 116pt value column, -/// which truncated the collation on every engine whose collation has a name (`utf8mb4_0900_ai_ci` -/// needs 126pt, `SQL_Latin1_General_CP1_CI_AS` needs 196), and the previous author reached for a -/// `.help` tooltip on that row rather than the width. It also disagreed with the field list beside -/// it: selecting a row moved every value 20pt sideways. -/// -/// So the sections are drawn here, on `InspectorMetrics.horizontalInset` like everything else in -/// the pane, and a value that still does not fit wraps rather than eliding. Wrapping is what -/// Finder's Get Info does with a long value at this width, measured. +// The table's own statistics, when the inspector has a table but no row selected. Not a grouped `Form`: its 30pt +// insets hold at every pane width and left a 116pt value column at 270pt, which truncated collation names. internal struct TableInfoView: View { - @ObservedObject private var settingsManager = AppSettingsManager.shared internal let metadata: TableMetadata var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 14) { - if settingsManager.general.showObjectComments, - let comment = metadata.comment, !comment.isEmpty { - section(String(localized: "Comment")) { - Text(comment) - .font(.subheadline) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - } + TableInfoSections(metadata: metadata) + .padding(.horizontal, InspectorMetrics.horizontalInset) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// The statistics alone, for a pane that scrolls them with its own content. +internal struct TableInfoSections: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared + internal let metadata: TableMetadata + internal var includesComment = true + + internal init(metadata: TableMetadata, includesComment: Bool = true) { + self.metadata = metadata + self.includesComment = includesComment + } - section(String(localized: "Size")) { - row(String(localized: "Data"), TableMetadata.formatSize(metadata.dataSize)) - row(String(localized: "Indexes"), TableMetadata.formatSize(metadata.indexSize)) - row(String(localized: "Total"), TableMetadata.formatSize(metadata.totalSize)) + var body: some View { + VStack(alignment: .leading, spacing: 14) { + if includesComment, settingsManager.general.showObjectComments, + let comment = metadata.comment, !comment.isEmpty { + section(String(localized: "Comment")) { + Text(comment) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) } + } + + section(String(localized: "Size")) { + row(String(localized: "Data"), TableMetadata.formatSize(metadata.dataSize)) + row(String(localized: "Indexes"), TableMetadata.formatSize(metadata.indexSize)) + row(String(localized: "Total"), TableMetadata.formatSize(metadata.totalSize)) + } - if metadata.rowCount != nil || metadata.avgRowLength != nil { - section(String(localized: "Statistics")) { - if let rows = metadata.rowCount { - row(String(localized: "Rows"), rows.formatted(.number)) - } - if let averageLength = metadata.avgRowLength { - row(String(localized: "Average Row"), TableMetadata.formatSize(averageLength)) - } + if metadata.rowCount != nil || metadata.avgRowLength != nil { + section(String(localized: "Statistics")) { + if let rows = metadata.rowCount { + row(String(localized: "Rows"), rows.formatted(.number)) + } + if let averageLength = metadata.avgRowLength { + row(String(localized: "Average Row"), TableMetadata.formatSize(averageLength)) } } + } - if metadata.engine != nil || metadata.collation != nil { - section(String(localized: "Metadata")) { - if let engine = metadata.engine { - row(String(localized: "Engine"), engine) - } - if let collation = metadata.collation { - row(String(localized: "Collation"), collation) - } + if metadata.engine != nil || metadata.collation != nil { + section(String(localized: "Metadata")) { + if let engine = metadata.engine { + row(String(localized: "Engine"), engine) + } + if let collation = metadata.collation { + row(String(localized: "Collation"), collation) } } + } - if metadata.createTime != nil || metadata.updateTime != nil { - section(String(localized: "Timestamps")) { - if let created = metadata.createTime { - row(String(localized: "Created"), Self.date(created)) - } - if let updated = metadata.updateTime { - row(String(localized: "Updated"), Self.date(updated)) - } + if metadata.createTime != nil || metadata.updateTime != nil { + section(String(localized: "Timestamps")) { + if let created = metadata.createTime { + row(String(localized: "Created"), Self.date(created)) + } + if let updated = metadata.updateTime { + row(String(localized: "Updated"), Self.date(updated)) } } } - .padding(.horizontal, InspectorMetrics.horizontalInset) - .padding(.vertical, 12) - .frame(maxWidth: .infinity, alignment: .leading) } .accessibilityElement(children: .contain) .accessibilityIdentifier("table-info") @@ -93,9 +97,7 @@ internal struct TableInfoView: View { } } - /// The label takes what it needs and the value takes the rest, wrapping when it has to. A fixed - /// label lane would be wrong here for the same reason it is wrong on a data field: these labels - /// are localized, so their widest member is not knowable from the English. + // The label takes what it needs and the value the rest, wrapping: labels are localized, so no fixed lane fits. private func row(_ label: String, _ value: String) -> some View { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(label) diff --git a/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift b/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift index 0f544c4ba3..9fe7349c6e 100644 --- a/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift +++ b/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift @@ -146,10 +146,9 @@ private struct TrailingPaneSurfacePicker: View { .tag(segment) } } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() - .controlSize(.small) .accessibilityIdentifier("trailing-pane-surface") } diff --git a/apple/TablePro/Views/Settings/KeyboardSettingsView.swift b/apple/TablePro/Views/Settings/KeyboardSettingsView.swift index 8f7d496536..670755875e 100644 --- a/apple/TablePro/Views/Settings/KeyboardSettingsView.swift +++ b/apple/TablePro/Views/Settings/KeyboardSettingsView.swift @@ -12,6 +12,7 @@ import SwiftUI struct KeyboardSettingsView: View { @Binding var settings: KeyboardSettings + @ObservedObject private var modules = AppModuleSettings.shared @State private var searchText = "" @State private var conflictAlert: ConflictAlertState? @State private var systemReservedAlert: ShortcutAction? @@ -180,7 +181,7 @@ struct KeyboardSettingsView: View { // MARK: - Helpers private func filteredActions(for category: ShortcutCategory) -> [ShortcutAction] { - let categoryActions = ShortcutAction.allCases.filter { $0.category == category } + let categoryActions = ShortcutAction.settingsRows(in: category, enabledModules: modules.enabledModules) if searchText.isEmpty { return categoryActions } diff --git a/apple/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/apple/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index b6c41bdd66..ae705b6054 100644 --- a/apple/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/apple/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -9,6 +9,7 @@ struct InstalledPluginsView: View { // isInstalling reads the tracker, which the plugin manager does not republish. @ObservedObject private var installTracker = PluginInstallTracker.shared @ObservedObject private var navigation = PluginsSettingsNavigation.shared + @ObservedObject private var modules = AppModuleSettings.shared @State private var selectedPluginId: String? @State private var searchText = "" @@ -27,6 +28,10 @@ struct InstalledPluginsView: View { return pluginManager.plugins.filter { $0.name.localizedCaseInsensitiveContains(searchText) } } + private var filteredModules: [AppModule] { + AppModule.allCases.filter { $0.matches(filter: searchText) } + } + var body: some View { VStack(spacing: 0) { if !pluginManager.rejectedPlugins.isEmpty && !dismissedRejectedBanner { @@ -242,10 +247,20 @@ struct InstalledPluginsView: View { ScrollViewReader { proxy in List(selection: $selectedPluginId) { - ForEach(filteredPlugins) { plugin in - pluginRow(plugin) - .tag(plugin.id) - .id(plugin.id) + if !filteredModules.isEmpty { + // Untagged, so a module row toggles but never takes the plugin selection. + Section(String(localized: "Modules")) { + ForEach(filteredModules, id: \.self) { module in + ModuleToggleRow(module: module, settings: modules) + } + } + } + Section(String(localized: "Plugins")) { + ForEach(filteredPlugins) { plugin in + pluginRow(plugin) + .tag(plugin.id) + .id(plugin.id) + } } } .listStyle(.inset) diff --git a/apple/TablePro/Views/Settings/Plugins/ModuleToggleRow.swift b/apple/TablePro/Views/Settings/Plugins/ModuleToggleRow.swift new file mode 100644 index 0000000000..eb94ca5d06 --- /dev/null +++ b/apple/TablePro/Views/Settings/Plugins/ModuleToggleRow.swift @@ -0,0 +1,33 @@ +import SwiftUI + +// A checkbox per module, the way Safari lists its extensions; this click is the only way a module turns on. +struct ModuleToggleRow: View { + let module: AppModule + @ObservedObject var settings: AppModuleSettings + + var body: some View { + Toggle(isOn: Binding( + get: { settings.isEnabled(module) }, + set: { AppModuleActivation.setEnabled($0, for: module, settings: settings) } + )) { + VStack(alignment: .leading, spacing: 2) { + Text(module.title) + Text(module.summary) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .toggleStyle(.checkbox) + .padding(.vertical, 2) + .accessibilityIdentifier("module-toggle-\(module.rawValue)") + } +} + +extension AppModule { + func matches(filter: String) -> Bool { + filter.isEmpty + || title.localizedCaseInsensitiveContains(filter) + || summary.localizedCaseInsensitiveContains(filter) + } +} diff --git a/apple/TablePro/Views/Settings/Sections/FormatterSection.swift b/apple/TablePro/Views/Settings/Sections/FormatterSection.swift index 233884d11e..24ff238f25 100644 --- a/apple/TablePro/Views/Settings/Sections/FormatterSection.swift +++ b/apple/TablePro/Views/Settings/Sections/FormatterSection.swift @@ -15,7 +15,7 @@ struct FormatterSection: View { private static let previewSQL = """ select u.id as user_id, u.name as user_name, count(o.id) as order_count \ from users u left join orders o on u.id = o.user_id \ - where u.status = 'active' and o.created_at >= '2026-01-01' \ + where u.status = 'active' and cast(o.created_at as date) >= '2026-01-01' \ group by u.id, u.name order by order_count desc; """ private static let indentWidths = [2, 4, 8] @@ -28,6 +28,7 @@ struct FormatterSection: View { @State private var isImporting = false @State private var exportDocument: SQLFormatterRulesDocument? @State private var importError: String? + @State private var exportError: String? private enum IndentWidthChoice: Hashable { case tabWidth @@ -65,16 +66,35 @@ struct FormatterSection: View { } } .help(Text("Engines that match function names by exact case, such as ClickHouse, keep the case you typed.")) + Picker("Data type case:", selection: $settings.rules.dataTypeCase) { + ForEach(SQLFormatterCase.allCases, id: \.self) { formatterCase in + Text(caseName(formatterCase)).tag(formatterCase) + } + } + .help(Text("Engines that match type names by exact case, such as ClickHouse, keep the case you typed.")) Picker("Commas:", selection: $settings.rules.commaPosition) { Text("At the end of the line").tag(SQLFormatterCommaPosition.trailing) Text("At the start of the line").tag(SQLFormatterCommaPosition.leading) } + Picker("AND and OR:", selection: $settings.rules.logicalOperatorNewline) { + Text("At the start of the line").tag(SQLFormatterLogicalOperatorNewline.before) + Text("At the end of the line").tag(SQLFormatterLogicalOperatorNewline.after) + } + .help(Text("Where AND and OR go when a WHERE or HAVING condition breaks across lines.")) Picker("Line width:", selection: $settings.rules.lineWidth) { ForEach(Self.options(Self.lineWidths, including: settings.rules.lineWidth), id: \.self) { width in Text(lineWidthName(width)).tag(width) } } .help(Text("A SELECT list, SET list or WHERE condition that fits in this width stays on one line.")) + Picker("Clause items:", selection: $settings.rules.clauseItems) { + Text("Beside the keyword").tag(SQLFormatterClauseItems.sameLine) + Text("Below the keyword").tag(SQLFormatterClauseItems.newLine) + } + .help(Text(""" + Where the items of a SELECT list, SET list, WITH list, or WHERE or HAVING condition start once it \ + breaks across lines. A single item stays beside its keyword. + """)) Picker("Join condition:", selection: $settings.rules.joinCondition) { Text("On its own line").tag(SQLFormatterJoinCondition.newLine) Text("On the JOIN line").tag(SQLFormatterJoinCondition.sameLine) @@ -95,7 +115,11 @@ struct FormatterSection: View { document: exportDocument, contentType: .json, defaultFilename: SQLFormatterRulesFile.exportFileName - ) { _ in } + ) { result in + if case .failure(let error) = result, (error as? CocoaError)?.code != .userCancelled { + reportExportFailure(error) + } + } Button("Restore Defaults") { settings = .default } .disabled(settings == .default) } @@ -116,6 +140,15 @@ struct FormatterSection: View { } message: { message in Text(message) } + .alert( + String(localized: "Couldn't Export Formatter Rules"), + isPresented: isShowingExportError, + presenting: exportError + ) { _ in + Button(String(localized: "OK")) {} + } message: { message in + Text(message) + } } // MARK: - Preview @@ -207,6 +240,10 @@ struct FormatterSection: View { Binding(get: { importError != nil }, set: { if !$0 { importError = nil } }) } + private var isShowingExportError: Binding { + Binding(get: { exportError != nil }, set: { if !$0 { exportError = nil } }) + } + // MARK: - Import and export private func importRules(_ result: Result) { @@ -226,10 +263,15 @@ struct FormatterSection: View { do { exportDocument = SQLFormatterRulesDocument(data: try SQLFormatterRulesFile.encode(settings.options(for: editor))) } catch { - Self.logger.error("Formatter rules export failed: \(error.publicLogShape, privacy: .public)") + reportExportFailure(error) } } + private func reportExportFailure(_ error: Error) { + Self.logger.error("Formatter rules export failed: \(error.publicLogShape, privacy: .public)") + exportError = error.localizedDescription + } + // MARK: - Labels private var editorKeywordCase: SQLFormatterCase { diff --git a/apple/TablePro/Views/Settings/SettingsView.swift b/apple/TablePro/Views/Settings/SettingsView.swift index 615920ba07..ac5a66df51 100644 --- a/apple/TablePro/Views/Settings/SettingsView.swift +++ b/apple/TablePro/Views/Settings/SettingsView.swift @@ -19,7 +19,8 @@ enum SettingsPane: String, CaseIterable { case .notifications: String(localized: "Notifications") case .ai: String(localized: "AI") case .mcp: "MCP" - case .plugins: String(localized: "Plugins") + // The rawValue and the URL id stay "plugins": they are the stored pane and a published link. + case .plugins: String(localized: "Extensions") case .sync: String(localized: "Sync") /// The stored rawValue stays "account": it is the tab item identifier and the pane the /// window restores, so renaming it would silently reset everyone's last-used pane. diff --git a/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift b/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift index 7e31ec969b..2976e16b9f 100644 --- a/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift +++ b/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift @@ -12,7 +12,6 @@ internal enum FieldEditorResolver { /// Answers from the field's own resolution when it has one. Only a field built before /// `MultiRowEditState` filled it in pays the detectors here. static func resolve(field: FieldEditState) -> FieldEditorKind { - if let editor = field.editor { return editor } if let resolved = field.resolvedEditor { return resolved } return resolve( for: field.columnTypeEnum, diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift index b3b5bb2dcb..adbf4d9282 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift @@ -175,7 +175,23 @@ enum DatabaseTreeFilter { /// each object's own schema, and the database only when the search names one. static func filteredTables(_ tables: [TableInfo], searchText: String, database: String? = nil) -> [TableInfo] { let matched = SidebarSearch(searchText).ranked(tables, name: { $0.name }, database: database, schema: { $0.schema }) - return deduplicated(matched, by: \.id) + return deduplicated(matched, by: TableIdentity.init) + } + + // The fields `TableInfo.id` is built from, without escaping and joining a string for every row. + // The id reads an empty schema as none, so the key does too. + private struct TableIdentity: Hashable { + let name: String + let schema: String? + let type: TableInfo.TableType + let isTemporary: Bool + + init(_ table: TableInfo) { + name = table.name + schema = table.schema?.isEmpty == false ? table.schema : nil + type = table.type + isTemporary = table.isTemporary + } } static func filteredRoutines( @@ -390,23 +406,27 @@ enum DatabaseTreeFilter { let rowsBySchema: [String: [TableInfo]] init(listing: CatalogTableListing.Result, database: String, searchText: String, covered: Set? = nil) { + self.init( + groups: SchemaListingGroups(tables: listing.tables), + unlisted: listing.unlistedSchemas, + database: database, + searchText: searchText, + covered: covered + ) + } + + init(groups: SchemaListingGroups, unlisted: Set, database: String, searchText: String, covered: Set?) { let search = SidebarSearch(searchText) - var listed: Set = [] var matched: Set = [] - var rowsBySchema: [String: [TableInfo]] = [:] - for table in listing.tables { - guard let schema = table.schema else { continue } - listed.insert(schema) - rowsBySchema[schema, default: []].append(table) - guard !matched.contains(schema), - search.matchesObject(named: table.name, database: database, schema: schema) else { continue } + for (schema, rows) in groups.rowsBySchema + where rows.contains(where: { search.matchesObject(named: $0.name, database: database, schema: schema) }) { matched.insert(schema) } - self.listed = listed + self.listed = Set(groups.rowsBySchema.keys) self.matched = matched - self.unlisted = listing.unlistedSchemas + self.unlisted = unlisted self.covered = covered - self.rowsBySchema = rowsBySchema + self.rowsBySchema = groups.rowsBySchema } func answers(for schema: String) -> Bool { @@ -414,6 +434,21 @@ enum DatabaseTreeFilter { } } + // A keystroke changes which schemas match, never which rows each schema lists, so the grouping + // waits for the next listing. Grouping 50,000 rows again on every keystroke copied each of them. + struct SchemaListingGroups { + let rowsBySchema: [String: [TableInfo]] + + init(tables: [TableInfo]) { + var rowsBySchema: [String: [TableInfo]] = [:] + for table in tables { + guard let schema = table.schema else { continue } + rowsBySchema[schema, default: []].append(table) + } + self.rowsBySchema = rowsBySchema + } + } + // Redraws and body evaluations share one scan per database until the listing, search or coverage moves. struct SchemaListingMatchesMemo { private struct Entry { @@ -424,7 +459,13 @@ enum DatabaseTreeFilter { let matches: SchemaListingMatches } + private struct GroupsEntry { + let tables: SameRows + let groups: SchemaListingGroups + } + private var entries: [String: Entry] = [:] + private var groupsEntries: [String: GroupsEntry] = [:] mutating func matches( listing: CatalogTableListing.Result, @@ -432,13 +473,27 @@ enum DatabaseTreeFilter { searchText: String, covered: Set? ) -> SchemaListingMatches { + let tables = SameRows(rows: listing.tables) if let entry = entries[database], entry.searchText == searchText, entry.covered == covered, - entry.unlisted == listing.unlistedSchemas, entry.tables == SameRows(rows: listing.tables) { + entry.unlisted == listing.unlistedSchemas, entry.tables == tables { return entry.matches } - let matches = SchemaListingMatches(listing: listing, database: database, searchText: searchText, covered: covered) + let groups: SchemaListingGroups + if let entry = groupsEntries[database], entry.tables == tables { + groups = entry.groups + } else { + groups = SchemaListingGroups(tables: listing.tables) + groupsEntries[database] = GroupsEntry(tables: tables, groups: groups) + } + let matches = SchemaListingMatches( + groups: groups, + unlisted: listing.unlistedSchemas, + database: database, + searchText: searchText, + covered: covered + ) entries[database] = Entry( - tables: SameRows(rows: listing.tables), + tables: tables, unlisted: listing.unlistedSchemas, searchText: searchText, covered: covered, @@ -449,6 +504,7 @@ enum DatabaseTreeFilter { mutating func removeAll() { entries.removeAll() + groupsEntries.removeAll() } } diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeNode.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeNode.swift index a179675a9d..0a8aaa293c 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeNode.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeNode.swift @@ -64,7 +64,9 @@ internal struct DatabaseTreeSidePhases: Equatable { } } -final class DatabaseTreeNode: SidebarOutlineNode { +// NSObject so the outline's item lookups hash the pointer: a plain Swift class looks up a Hashable conformance on +// every hash, about a quarter of opening a section of 17,000 tables. +final class DatabaseTreeNode: NSObject, SidebarOutlineNode { enum Status: Equatable { case loading case empty @@ -110,6 +112,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { init(id: String, kind: Kind) { self.id = id self.kind = kind + super.init() } var isExpandable: Bool { diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index f19d44f768..e406897762 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -139,6 +139,8 @@ extension DatabaseTreeOutlineCoordinator { ClipboardService.shared.writeText(text) case .showObjectSource(let ref): mainCoordinator?.showObjectSource(ref) + case .debugRoutine(let ref): + debugRoutine(ref) case .dropUserType, .renameUserType, .newUserType: performUserTypeCommand(command) case .refreshRedisKeys: @@ -233,6 +235,12 @@ extension DatabaseTreeOutlineCoordinator { } } + // The run dials its own connections on the row's database, so the browse cursor never has to move, and a switch that + // fails or is cancelled cannot leave the run on another database. + private func debugRoutine(_ ref: DatabaseTreeRoutineRef) { + mainCoordinator?.debugRoutine(ref.routine, database: ref.database, schema: ref.schema) + } + private func performUserTypeCommand(_ command: SidebarMenuCommand) { switch command { case .dropUserType(let ref): diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index 38c3f02108..d48854232f 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -122,7 +122,10 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { tableFolderOptions: tableFolderMenuOptions(clicked: clicked, selected: selectedRefs()), offersBrowsedFolders: creation.offersBrowsedFolders, canShowAllTables: mainCoordinator?.allTablesListing() != nil, - listsAttachedSchemas: listsAttachedSchemas + listsAttachedSchemas: listsAttachedSchemas, + canDebugRoutine: DatabaseTreeMenuContext.canDebugRoutine(clicked?.kind, modules: .shared) { + mainCoordinator?.routineDebugTarget(for: $0) + } ) } diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 593654982c..af9b8bcbac 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -300,7 +300,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { schemaService: schemaService, temporaryTables: temporaryTables, sidebarState: sidebarState, - rootShape: rootShape + rootShape: rootShape, + searchText: searchText ) } diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift index c3560b069a..6dbbfc1048 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift @@ -97,7 +97,8 @@ struct DatabaseTreeRenderInputs: Equatable { schemaService: SchemaService, temporaryTables: TemporaryTableStore, sidebarState: SharedSidebarState?, - rootShape: SidebarRootShape + rootShape: SidebarRootShape, + searchText: String ) { let ownsObjects = { (key: ObjectsKey) in key.connectionId == connectionId } let tables = Self.slice(treeMetadata.tablesState, where: ownsObjects) { Rows($0) } @@ -114,7 +115,9 @@ struct DatabaseTreeRenderInputs: Equatable { ) } var listings: [DatabaseKey: Listing] = [:] - for (key, state) in treeMetadata.allSchemaTablesState where key.connectionId == connectionId { + // The outline reads the listing only under a search, and Open Quickly loads it with none: landing + // then reloaded every row. A search starting refreshes the outline anyway. + for (key, state) in treeMetadata.allSchemaTablesState where key.connectionId == connectionId && !searchText.isEmpty { let listing = Listing( state, covered: treeMetadata.allSchemaTablesSelection(connectionId: connectionId, database: key.database) ) diff --git a/apple/TablePro/Views/Sidebar/FavoriteStarButton.swift b/apple/TablePro/Views/Sidebar/FavoriteStarButton.swift index fa2de4defc..84e0a49642 100644 --- a/apple/TablePro/Views/Sidebar/FavoriteStarButton.swift +++ b/apple/TablePro/Views/Sidebar/FavoriteStarButton.swift @@ -17,22 +17,30 @@ internal struct FavoriteStarButton: View { private var isVisible: Bool { isFavorite || isRowHovered } + // A hidden star is a placeholder, not a button: a button adds AppKit focus views to its row, and the sidebar + // builds every visible row again on each reload. internal var body: some View { - Button(action: toggle) { - Image(systemName: isFavorite ? "star.fill" : "star") - .font(.system(size: 11, weight: .regular)) - .selectionAwareTint(isFavorite ? Color.yellow : Color.secondary) - .frame(width: 20, height: 20) - .contentShape(Rectangle()) + if isVisible { + Button(action: toggle) { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.system(size: 11, weight: .regular)) + .selectionAwareTint(isFavorite ? Color.yellow : Color.secondary) + .frame(width: Self.side, height: Self.side) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHidden(true) + .help(isFavorite + ? String(localized: "Remove from Favorites") + : String(localized: "Add to Favorites")) + } else { + Color.clear + .frame(width: Self.side, height: Self.side) + .accessibilityHidden(true) } - .buttonStyle(.plain) - .opacity(isVisible ? 1 : 0) - .allowsHitTesting(isVisible) - .accessibilityHidden(true) - .help(isFavorite - ? String(localized: "Remove from Favorites") - : String(localized: "Add to Favorites")) } + + private static let side: CGFloat = 20 } /// A hover-revealed control is pointer-only, so the row publishes the same action to VoiceOver. diff --git a/apple/TablePro/Views/Sidebar/FavoritesOutlineNode.swift b/apple/TablePro/Views/Sidebar/FavoritesOutlineNode.swift index 0bfa034b9f..150ce581b1 100644 --- a/apple/TablePro/Views/Sidebar/FavoritesOutlineNode.swift +++ b/apple/TablePro/Views/Sidebar/FavoritesOutlineNode.swift @@ -10,7 +10,8 @@ import Foundation /// A reference type because `NSOutlineView` tracks rows by object identity, which is what lets a /// reload keep the expansion and selection a user set. The coordinator caches these by `id` and /// mutates the cached instance rather than building a new one, exactly as the object tree does. -internal final class FavoritesOutlineNode: SidebarOutlineNode { +/// An `NSObject` for the same cheap hashing as `DatabaseTreeNode`. +internal final class FavoritesOutlineNode: NSObject, SidebarOutlineNode { internal enum Kind { case header(String) case databaseEnvironment(FavoriteDatabaseGroup) @@ -26,6 +27,7 @@ internal final class FavoritesOutlineNode: SidebarOutlineNode { internal init(id: String, kind: Kind) { self.id = id self.kind = kind + super.init() } internal var isExpandable: Bool { diff --git a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift index 79cea97397..0def1ddf99 100644 --- a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift +++ b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift @@ -81,6 +81,20 @@ internal struct DatabaseTreeMenuContext { /// The engine's schema rows are databases attached beside the opened file: reached by their /// qualifier, never made active, and not exported or copied as a unit. internal var listsAttachedSchemas: Bool = false + internal var canDebugRoutine: Bool = false +} + +internal extension DatabaseTreeMenuContext { + // The module is asked first, so with it off the debugger's code is never reached from a menu. + @MainActor + static func canDebugRoutine( + _ clicked: DatabaseTreeNode.Kind?, + modules: AppModuleSettings, + debugTarget: (RoutineInfo) -> PluginDebugTarget? + ) -> Bool { + guard modules.isEnabled(.routineDebugger), case .routine(let ref) = clicked else { return false } + return debugTarget(ref.routine) != nil + } } internal extension DatabaseTreeMenuContext { diff --git a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index a3a61448e6..39d1569256 100644 --- a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -41,7 +41,7 @@ internal enum DatabaseTreeMenuSpec { context: context ) case .routine(let ref): - return routineSections(ref) + return routineSections(ref, context: context) case .trigger(let ref): return triggerSections(ref) case .userType(let ref): @@ -304,7 +304,10 @@ internal enum DatabaseTreeMenuSpec { return [DatabaseTreeMenuSection(copies)] } - private static func routineSections(_ ref: DatabaseTreeRoutineRef) -> [DatabaseTreeMenuSection] { + private static func routineSections( + _ ref: DatabaseTreeRoutineRef, + context: DatabaseTreeMenuContext + ) -> [DatabaseTreeMenuSection] { var copies: [DatabaseTreeMenuItem] = [.command(String(localized: "Copy Name"), .copyText(ref.routine.name))] if let signature = ref.routine.argumentSignature, !signature.isEmpty { copies.append(.command( @@ -312,10 +315,14 @@ internal enum DatabaseTreeMenuSpec { .copyText(RoutineDisplayLabel.copyableSignature(for: ref.routine)) )) } - return [ + var sections = [ DatabaseTreeMenuSection(copies), DatabaseTreeMenuSection([.command(String(localized: "Show DDL"), .showObjectSource(ref.objectRef))]) ] + if context.canDebugRoutine { + sections.append(DatabaseTreeMenuSection([.command(String(localized: "Debug…"), .debugRoutine(ref))])) + } + return sections } private static func triggerSections(_ ref: DatabaseTreeTriggerRef) -> [DatabaseTreeMenuSection] { diff --git a/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index 0f97086115..0357e60911 100644 --- a/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -77,6 +77,7 @@ internal enum SidebarMenuCommand: Equatable { case refreshHierarchicalSchema(String) case copyText(String) case showObjectSource(DatabaseObjectRef) + case debugRoutine(DatabaseTreeRoutineRef) case dropUserType(DatabaseTreeUserTypeRef) case renameUserType(DatabaseTreeUserTypeRef) /// Nil database and schema mean the ones the sidebar is browsing. diff --git a/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift b/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift index 7d2a4fe3b5..5a42170410 100644 --- a/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift +++ b/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift @@ -20,6 +20,12 @@ import AppKit /// The words fit in every language the app ships. Measured at the sidebar's 280pt minimum, which /// leaves the row 260pt inside its insets, the widest is Turkish at 238pt, English needs 158pt and /// Simplified Chinese 100pt. +/// +/// On macOS 27 it takes the tabs role AppKit gives "tab-based navigation and content selection", +/// at small size. Measured there, a regular control in a sidebar draws a glass capsule whatever +/// its style or role; small with the tabs role draws a flat track with a neutral raised thumb. +/// Small without the role would fill the thumb with the accent color, which marks a value choice, +/// so earlier releases keep the regular control. @MainActor internal final class SidebarScopeControl: NSSegmentedControl { internal static let tabs: [SidebarTab] = [.tables, .favorites] @@ -30,6 +36,12 @@ internal final class SidebarScopeControl: NSSegmentedControl { trackingMode = .selectOne segmentDistribution = .fillEqually controlSize = .regular + #if SDK_MACOS_27 + if #available(macOS 27.0, *) { + role = .tabs + controlSize = .small + } + #endif for (index, tab) in Self.tabs.enumerated() { setLabel(Self.title(for: tab), forSegment: index) } diff --git a/apple/TablePro/Views/Sidebar/SidebarView.swift b/apple/TablePro/Views/Sidebar/SidebarView.swift index 08a7436198..f3d3bc0ddd 100644 --- a/apple/TablePro/Views/Sidebar/SidebarView.swift +++ b/apple/TablePro/Views/Sidebar/SidebarView.swift @@ -15,6 +15,8 @@ struct SidebarView: View { @StateObject private var viewModel: SidebarViewModel @ObservedObject private var settingsManager = AppSettingsManager.shared @State private var showsSchemaProgress = false + /// See `presentedSchemaState`. + @State private var showsLoadedCatalog = false @ObservedObject private var schemaService = SchemaService.shared @ObservedObject private var treeMetadata = DatabaseTreeMetadataService.shared @@ -200,6 +202,16 @@ struct SidebarView: View { } .onAppear { coordinator?.sidebarViewModel = viewModel + // A pane put back on screen shows the list it already had. + showsLoadedCatalog = isCatalogLoaded + } + .onChange(of: isCatalogLoaded) { isLoaded in + guard isLoaded else { + showsLoadedCatalog = false + return + } + // A refresh can put the catalog back to loading before the frame, and a stale true skips the next hold. + CommittedTurn.after(in: coordinator?.contentWindow) { showsLoadedCatalog = isCatalogLoaded } } .onChange(of: viewModel.showOperationDialog) { isPresented in guard isPresented else { return } @@ -305,7 +317,7 @@ struct SidebarView: View { @ViewBuilder private var hierarchicalContent: some View { let presentation = SidebarObjectListPresentation.resolveDeferringEmptyStates( - state: schemaService.state(for: connectionId), + state: presentedSchemaState, hasOutlastedGrace: showsSchemaProgress ) Group { @@ -336,9 +348,20 @@ struct SidebarView: View { ) } + private var isCatalogLoaded: Bool { + if case .loaded = schemaService.state(for: connectionId) { return true } + return false + } + + // A connection's first catalog can land in the turn that commits the restored tab's frame, and building the + // outline there made one stall of both. The outline goes up in a later turn, the loading state held till then. + private var presentedSchemaState: SchemaState { + SidebarObjectListPresentation.presentedState(schemaService.state(for: connectionId), showsLoaded: showsLoadedCatalog) + } + private var objectListPresentation: SidebarObjectListPresentation { SidebarObjectListPresentation.resolve( - state: schemaService.state(for: connectionId), + state: presentedSchemaState, hasActiveFilter: !viewModel.filterQuery.isEmpty, hasAnyMatch: hasAnyMatch, hasOutlastedGrace: showsSchemaProgress, diff --git a/apple/TablePro/Views/Structure/ClickHousePartsView.swift b/apple/TablePro/Views/Structure/ClickHousePartsView.swift index 0e9e2e5f2e..6f37691102 100644 --- a/apple/TablePro/Views/Structure/ClickHousePartsView.swift +++ b/apple/TablePro/Views/Structure/ClickHousePartsView.swift @@ -21,7 +21,7 @@ struct ClickHousePartsView: View { @State private var isLoading = true @State private var errorMessage: String? @State private var loadedToken: Int? - @StateObject private var grid = StructureListGridModel(accessibilityLabel: StructureTab.parts.displayName) + @StateObject private var grid = StructureListGridModel(accessibilityLabel: StructurePane.parts.displayName) @StateObject private var selection = StructureListSelection() var body: some View { diff --git a/apple/TablePro/Views/Structure/ColumnCharacterSetMenus.swift b/apple/TablePro/Views/Structure/ColumnCharacterSetMenus.swift new file mode 100644 index 0000000000..aff4253166 --- /dev/null +++ b/apple/TablePro/Views/Structure/ColumnCharacterSetMenus.swift @@ -0,0 +1,198 @@ +// +// ColumnCharacterSetMenus.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +// The Charset and Collation cells of the Structure and Create Table grids: their menus, the names they +// take and when they are read-only. The server refuses a name it does not know (ERROR 1253, unknown +// character set), so once the catalog is read the list is closed and a typed name must be in it. +enum ColumnCharacterSetMenus { + struct Context { + var catalog: MetadataLoadState + // What a column without a character set of its own takes. + var tableCharacterSet: String? + + var loadedCatalog: TableOptionsCatalog? { + guard case .loaded(let catalog) = self.catalog else { return nil } + return catalog + } + } + + enum Entry: Equatable { + // The engine has no catalog, so the text is written as typed. + case unchecked + case unchanged + case set(characterSet: String?, collation: String?) + case refused + } + + static func isCharacterSetField(_ field: StructureColumnField) -> Bool { + field == .charset || field == .collation + } + + // An engine whose driver has no catalog keeps the plain text cell it always had. + static func offersMenus(_ catalog: MetadataLoadState) -> Bool { + if case .loaded(.none) = catalog { return false } + return true + } + + static func options( + for field: StructureColumnField, + column: EditableColumnDefinition, + context: Context + ) -> [GridMenuOption]? { + guard isCharacterSetField(field), takesCharacterSet(column) else { return nil } + let current = field == .charset ? column.charset : column.collation + switch context.catalog { + case .loaded(.none): + return nil + case .idle, .loading: + return [.sectionHeader(String(localized: "Loading…"))] + currentOnly(current) + case .failed: + return [.sectionHeader(String(localized: "Couldn't read the character sets"))] + currentOnly(current) + case .loaded(.some(let catalog)): + if field == .charset { + return characterSetOptions(catalog: catalog, current: current) + } + return collationOptions( + catalog: catalog, + characterSet: column.charset ?? context.tableCharacterSet, + current: current + ) + } + } + + static func entry( + _ text: String, + for field: StructureColumnField, + column: EditableColumnDefinition, + context: Context + ) -> Entry { + guard isCharacterSetField(field) else { return .unchecked } + let name = text.trimmingCharacters(in: .whitespaces) + switch context.catalog { + case .loaded(.none): + return .unchecked + case .idle, .loading, .failed: + return .refused + case .loaded(.some(let catalog)): + if name.isEmpty { + return clearing(field, column: column) + } + guard takesCharacterSet(column) else { return .refused } + if field == .charset { + return characterSetEntry(name, column: column, catalog: catalog) + } + return collationEntry(name, column: column, catalog: catalog) + } + } + + static func locks( + _ field: StructureColumnField, + column: EditableColumnDefinition, + catalog: MetadataLoadState + ) -> Bool { + guard isCharacterSetField(field) else { return false } + switch catalog { + case .loaded(.none): + return false + case .loaded(.some): + return !takesCharacterSet(column) + case .idle, .loading, .failed: + return true + } + } + + static func lockedFieldIndices( + orderedFields: [StructureColumnField], + column: EditableColumnDefinition, + catalog: MetadataLoadState + ) -> Set { + Set(orderedFields.indices.filter { locks(orderedFields[$0], column: column, catalog: catalog) }) + } + + // MARK: - Menus + + private static func characterSetOptions(catalog: TableOptionsCatalog, current: String?) -> [GridMenuOption] { + var options: [GridMenuOption] = [.value(title: String(localized: "Table Default"), sql: "")] + let names = catalog.characterSets.map(\.name) + if let current, !current.isEmpty, !names.contains(where: { matches($0, current) }) { + options.append(.value(title: current, sql: current)) + } + options += GridMenuOption.values(names) + return options + } + + private static func collationOptions( + catalog: TableOptionsCatalog, + characterSet: String?, + current: String? + ) -> [GridMenuOption] { + guard let characterSet = characterSet.flatMap({ catalog.characterSet(named: $0) }) else { + return [.sectionHeader(String(localized: "Choose a character set first"))] + currentOnly(current) + } + var options: [GridMenuOption] = [.sectionHeader(characterSet.name)] + if let current, !current.isEmpty, !characterSet.collations.contains(where: { matches($0, current) }) { + options.append(.value(title: current, sql: current)) + } + options += characterSet.collations.map { name in + GridMenuOption.value( + title: TableOptionsForm.collationTitle(name, isDefault: name == characterSet.defaultCollation), + sql: name + ) + } + return options + } + + private static func currentOnly(_ current: String?) -> [GridMenuOption] { + guard let current, !current.isEmpty else { return [] } + return [.value(title: current, sql: current)] + } + + // MARK: - Entries + + private static func clearing(_ field: StructureColumnField, column: EditableColumnDefinition) -> Entry { + if field == .charset { + return column.charset == nil && column.collation == nil ? .unchanged : .set(characterSet: nil, collation: nil) + } + return column.collation == nil ? .unchanged : .set(characterSet: column.charset, collation: nil) + } + + private static func characterSetEntry( + _ name: String, + column: EditableColumnDefinition, + catalog: TableOptionsCatalog + ) -> Entry { + guard let characterSet = catalog.characterSet(named: name) else { + return matches(name, column.charset) ? .unchanged : .refused + } + guard !matches(characterSet.name, column.charset) else { return .unchanged } + return .set(characterSet: characterSet.name, collation: characterSet.defaultCollation.nilIfEmpty) + } + + // A collation belongs to one character set, so a name from another set moves the column to that set. + private static func collationEntry( + _ name: String, + column: EditableColumnDefinition, + catalog: TableOptionsCatalog + ) -> Entry { + guard !matches(name, column.collation) else { return .unchanged } + guard let owner = catalog.characterSet(owning: name), + let collation = owner.collations.first(where: { matches($0, name) }) else { return .refused } + return .set(characterSet: owner.name, collation: collation) + } + + // MARK: - Helpers + + private static func takesCharacterSet(_ column: EditableColumnDefinition) -> Bool { + ColumnTextType.takesCharacterSet(column.dataType) + } + + private static func matches(_ lhs: String, _ rhs: String?) -> Bool { + guard let rhs else { return false } + return lhs.caseInsensitiveCompare(rhs) == .orderedSame + } +} diff --git a/apple/TablePro/Views/Structure/CreateTableActionHandler.swift b/apple/TablePro/Views/Structure/CreateTableActionHandler.swift deleted file mode 100644 index 9cc91dd862..0000000000 --- a/apple/TablePro/Views/Structure/CreateTableActionHandler.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// CreateTableActionHandler.swift -// TablePro -// - -import Foundation - -@MainActor -final class CreateTableActionHandler { - var createTable: (() -> Void)? - var undo: (() -> Void)? - var redo: (() -> Void)? -} diff --git a/apple/TablePro/Views/Structure/CreateTableDraft.swift b/apple/TablePro/Views/Structure/CreateTableDraft.swift index f13d0189b7..bffd4f2653 100644 --- a/apple/TablePro/Views/Structure/CreateTableDraft.swift +++ b/apple/TablePro/Views/Structure/CreateTableDraft.swift @@ -5,20 +5,37 @@ import Combine import Foundation +import os import TableProPluginKit internal struct CreateTableCompositionKey: Hashable { let scope: DatabaseScope let tableName: String let options: CreateTableOptions + let offersEngineOptions: Bool + let optionDefaults: CreateTableOptionDefaults let columns: [EditableColumnDefinition] let indexes: [EditableIndexDefinition] let foreignKeys: [EditableForeignKeyDefinition] + let checkConstraints: [EditableCheckConstraintDefinition] } +internal enum CreateTablePane: Hashable, CaseIterable { + case definition + case ddl +} + +// One Create Table tab: the definition it drafts over an empty baseline, its table options, and the commands the +// toolbar and menus send it. The coordinator holds it by tab id, so the work outlives the view. @MainActor internal final class CreateTableDraft: ObservableObject { + private static let logger = Logger(subsystem: "com.TablePro", category: "CreateTableDraft") + + internal let connection: DatabaseConnection + // The tab's own database and schema: the table is created there, and the reference menus read there. + internal let scope: DatabaseScope? internal let changeManager = StructureChangeManager() + internal let editor: StructureDefinitionEditor @Published internal var tableName = "" { didSet { @@ -28,29 +45,85 @@ internal final class CreateTableDraft: ObservableObject { } @Published internal var tableOptions = CreateTableOptions() @Published internal var form: CreateTableFormState? + @Published internal var pane: CreateTablePane = .definition @Published internal private(set) var hasResolvedForm = false + @Published internal private(set) var isCreating = false - internal func resolveForm(from spec: @autoclosure () -> PluginCreateTableFormSpec?) { - guard !hasResolvedForm else { return } - hasResolvedForm = true - form = spec().map(CreateTableFormState.init(spec:)) - } + // Server-wide: the character sets, collations and engines the inspector and the column cells offer. + @Published internal private(set) var optionsCatalog: MetadataLoadState = .idle + // Per target database, so a pick is compared with the defaults of the database the table goes in. + @Published internal private(set) var optionDefaults = CreateTableOptionDefaults() @Published internal private(set) var composed: CreateTableStatements? @Published internal private(set) var compositionFailure: String? - // Made on the first mount, which knows the connection; held here so the grid outlives the view. - internal private(set) var gridAdapter: CreateTableGridAdapter? + internal var executionGate: any ExecutionGate = ExecutionGateProvider.shared + private var focusesNameOnAppear = true + private var optionDefaultsScope: DatabaseScope? + private var optionsCatalogRead: Task? private var composedKey: CreateTableCompositionKey? private var compositionGeneration = 0 - private var changeManagerForwarding: AnyCancellable? + private var cancellables: Set = [] - internal init() { - changeManagerForwarding = changeManager.objectWillChange + internal init(connection: DatabaseConnection, scope: DatabaseScope?) { + self.connection = connection + self.scope = scope + editor = StructureDefinitionEditor( + manager: changeManager, + context: .newTable( + connection: connection, + databaseName: scope?.database ?? connection.database, + schemaName: scope?.schema + ) + ) + editor.characterSetContextProvider = { [weak self] in + ColumnCharacterSetMenus.Context( + catalog: self?.optionsCatalog ?? .idle, + tableCharacterSet: self?.tableOptions.charset + ) + } + editor.characterSetCatalogReader = { [weak self] in + self?.readOptionsCatalogIfNeeded() + } + changeManager.objectWillChange .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + $optionsCatalog + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.editor.refresh() } + .store(in: &cancellables) } + // MARK: - Setup + + // Runs on every appearance; each step skips what is done. + internal func prepare(coordinator: MainContentCoordinator?) { + editor.coordinator = coordinator + editor.serverSupport = StructureServerSupport.forConnection(connection.id) + resolveForm(from: DatabaseManager.shared.driver(for: connection.id)?.createTableFormSpec(schema: scope?.schema)) + } + + internal func resolveForm(from spec: @autoclosure () -> PluginCreateTableFormSpec?) { + guard !hasResolvedForm else { return } + hasResolvedForm = true + form = spec().map(CreateTableFormState.init(spec:)) + if form == nil, changeManager.workingColumns.isEmpty { + changeManager.addNewColumn() + editor.refresh() + } + } + + // The Name field takes focus when the tab first shows, not on every return to it. + internal func claimNameFocus() -> Bool { + guard focusesNameOnAppear else { return false } + focusesNameOnAppear = false + return true + } + + // MARK: - State + internal var holdsWork: Bool { !tableName.isEmpty || ObjectCommentDraft.normalized(tableOptions.comment) != nil @@ -60,54 +133,215 @@ internal final class CreateTableDraft: ObservableObject { || changeManager.workingForeignKeys.contains { !$0.name.isEmpty || !$0.columns.isEmpty || !$0.referencedTable.isEmpty } + || changeManager.workingCheckConstraints.contains { !$0.name.isEmpty || !$0.expression.isEmpty } + } + + internal var hasChanges: Bool { holdsWork } + + // The composed statement adds what the driver refuses; until it catches up with the draft, the plan's own. + internal var issues: [SchemaDraftIssue] { + currentComposedIssues ?? plan().issues + } + + internal var isReadyToCreate: Bool { + guard hasResolvedForm, !isCreating else { return false } + if let form { + return form.issues(tableName: tableName).isEmpty + } + let plan = plan() + return plan.definition != nil && (currentComposedIssues ?? plan.issues).isEmpty + } + + private var currentComposedIssues: [SchemaDraftIssue]? { + guard let composed, let composedKey, composedKey == compositionKey else { return nil } + return composed.issues + } + + // The definition the issue names, when it names one row. + internal func entityID(for issue: SchemaDraftIssue) -> UUID? { + guard let row = issue.row else { return nil } + let ids = StructureGridEdits.entityIDs(in: issue.section, manager: changeManager) + return ids.indices.contains(row) ? ids[row] : nil + } + + internal func reveal(_ issue: SchemaDraftIssue) { + guard let id = entityID(for: issue) else { return } + pane = .definition + editor.reveal(section: issue.section, entity: id) + } + + // MARK: - Commands + + // The section commands act on: the one holding the selection, else Columns. + internal var commandSection: StructureSection { editor.commandSection } + + private var editsDefinition: Bool { hasResolvedForm && form == nil } + + internal var canUndo: Bool { editsDefinition && changeManager.canUndo } + internal var canRedo: Bool { editsDefinition && changeManager.canRedo } + internal var undoMenuItemTitle: String { changeManager.undoMenuItemTitle } + internal var redoMenuItemTitle: String { changeManager.redoMenuItemTitle } + + internal func undo() { + guard canUndo else { return } + commitOpenEdits() + changeManager.undo() + editor.refresh() + } + + internal func redo() { + guard canRedo else { return } + commitOpenEdits() + changeManager.redo() + editor.refresh() + } + + internal var canAddRow: Bool { editsDefinition && pane == .definition && editor.canAdd(commandSection) } + + internal func addRow() { + guard canAddRow else { return } + editor.add(commandSection) + } + + internal var canRemoveRows: Bool { + editsDefinition && pane == .definition && editor.canRemoveSelection(in: commandSection) + } + + internal func removeSelectedRows() { + guard canRemoveRows else { return } + editor.removeSelection(in: commandSection) + } + + internal func commitOpenEdits() { + editor.commitPendingEdits() + } + + // MARK: - Table options + + // The driver's catalog decides, never the engine's type: a driver without one has no table options. + internal var catalog: TableOptionsCatalog? { + guard case .loaded(let catalog) = optionsCatalog else { return nil } + return catalog + } + + internal var offersEngineOptions: Bool { catalog != nil } + + // A driver's own form names everything its table takes. + internal var offersTableComment: Bool { + guard editsDefinition else { return false } + let support = DatabaseObjectToolEligibility.Support.of(DatabaseManager.shared.driver(for: connection.id)) + return support.commentableTypes.contains(.table) + } + + internal func selectCharacterSet(_ charset: String?) { + guard charset != tableOptions.charset else { return } + tableOptions = tableOptions.selectingCharacterSet( + charset, + collation: TableOptionsForm.defaultCollation(of: charset, in: catalog) + ) + } + + // Idempotent: a remount skips what is loaded, and a read a switch cancelled runs again on return. + internal func loadTableOptions(provider: any ScopedMetadataProviding = DatabaseManager.shared) async { + guard let scope else { return } + let loader = TableStructureLoader(scope: scope, tableName: "", provider: provider) + await loadOptionsCatalog(using: loader) + guard catalog != nil, optionDefaultsScope != scope else { return } + do { + let defaults = try await loader.defaultTableOptions() + optionDefaults = CreateTableOptionDefaults( + characterSet: defaults?.characterSet, + collation: defaults?.collation, + engine: catalog?.defaultEngine + ) + optionDefaultsScope = scope + tableOptions = tableOptions.seeded(with: optionDefaults) + } catch { + guard StructureFetchFailure(error, taskIsCancelled: Task.isCancelled) != .cancelled else { return } + // Without the database's defaults every pick is written out, so the table still gets what the user chose. + Self.logger.error("Failed to read the default table options: \(error.publicLogShape, privacy: .public)") + } } - internal func gridAdapter(for connection: DatabaseConnection) -> CreateTableGridAdapter { - if let gridAdapter { return gridAdapter } - let adapter = CreateTableGridAdapter(structureChangeManager: changeManager, connection: connection) - gridAdapter = adapter - return adapter + // Opening a column's Charset or Collation menu is the retry. + private func readOptionsCatalogIfNeeded() { + switch optionsCatalog { + case .idle, .failed: + Task { await loadTableOptions() } + case .loading, .loaded: + break + } } - internal static func offersEngineOptions(for databaseType: DatabaseType) -> Bool { - databaseType == .mysql || databaseType == .mariadb + private func loadOptionsCatalog(using loader: TableStructureLoader) async { + if case .loaded = optionsCatalog { return } + // The draft owns the read, so a view task cancelled by a tab switch leaves it running for the view that returns. + if let optionsCatalogRead { + await optionsCatalogRead.value + return + } + optionsCatalog = optionsCatalog.enteringLoad + let read = Task { [weak self] in + let outcome: MetadataFetchOutcome + do { + outcome = .fetched(try await loader.tableOptionsCatalog()) + } catch { + switch StructureFetchFailure(error, taskIsCancelled: Task.isCancelled) { + case .cancelled: + outcome = .cancelled + case .failed(let message): + Self.logger.error("Failed to read the table options catalog: \(error.publicLogShape, privacy: .public)") + outcome = .failed(message) + } + } + guard let self else { return } + optionsCatalog = optionsCatalog.settled(by: outcome, discardingValue: false) + optionsCatalogRead = nil + } + optionsCatalogRead = read + await read.value } - internal func plan(for databaseType: DatabaseType) -> CreateTablePlan { + // MARK: - Composition + + internal func plan() -> CreateTablePlan { CreateTableDraftBuilder.plan( tableName: tableName, options: tableOptions, columns: changeManager.workingColumns, indexes: changeManager.workingIndexes, foreignKeys: changeManager.workingForeignKeys, - dialect: ForeignKeyDialect.forType(databaseType), - includesEngineOptions: Self.offersEngineOptions(for: databaseType), - suppliesItsOwnKey: PluginManager.shared.defaultPrimaryKeyColumn(for: databaseType) != nil + checkConstraints: changeManager.workingCheckConstraints, + dialect: ForeignKeyDialect.forType(connection.type), + includesEngineOptions: offersEngineOptions, + defaults: optionDefaults, + suppliesItsOwnKey: PluginManager.shared.defaultPrimaryKeyColumn(for: connection.type) != nil ) } - internal func compositionKey(scope: DatabaseScope) -> CreateTableCompositionKey { - CreateTableCompositionKey( + internal var compositionKey: CreateTableCompositionKey? { + guard let scope else { return nil } + return CreateTableCompositionKey( scope: scope, tableName: tableName, options: tableOptions, + offersEngineOptions: offersEngineOptions, + optionDefaults: optionDefaults, columns: changeManager.workingColumns, indexes: changeManager.workingIndexes, - foreignKeys: changeManager.workingForeignKeys + foreignKeys: changeManager.workingForeignKeys, + checkConstraints: changeManager.workingCheckConstraints ) } - internal func recompose(databaseType: DatabaseType, scope: DatabaseScope) async { - let key = compositionKey(scope: scope) - guard key != composedKey else { return } + // Keeps the last good statement on screen until the next one lands, and through a failure. + internal func recompose() async { + guard let scope, let key = compositionKey, key != composedKey else { return } compositionGeneration += 1 let generation = compositionGeneration do { - let statements = try await DatabaseManager.shared.createTableStatements( - plan: plan(for: databaseType), - scope: scope - ) + let statements = try await DatabaseManager.shared.createTableStatements(plan: plan(), scope: scope) guard generation == compositionGeneration else { return } composed = statements composedKey = key @@ -119,12 +353,226 @@ internal final class CreateTableDraft: ObservableObject { compositionFailure = error.localizedDescription } } + + internal func formStatements(for request: PluginCreateTableRequest) throws -> [String] { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { + throw PluginCreateTableFormError(message: String(localized: "Not connected to database")) + } + return try driver.createTableStatements(for: request, schema: scope?.schema) + } + + internal var formPreview: CreateTableFormState.Preview? { + form?.preview(tableName: tableName) { request in try formStatements(for: request) } + } + + // MARK: - Preview SQL + + // Refuses what Create Table refuses, so the statements shown are the ones it would run. + internal func previewSQL(coordinator: MainContentCoordinator) { + guard hasResolvedForm, coordinator.activeSheet == nil else { return } + commitOpenEdits() + if let formState = form { + let formIssues = formState.issues(tableName: tableName) + guard formIssues.isEmpty else { + reportIncomplete(formIssues.map(\.qualifiedMessage), on: coordinator) + return + } + do { + let statements = try formStatements(for: formState.request(tableName: tableName)) + presentPreview(statements, on: coordinator) + } catch { + reportPreviewFailure(error, on: coordinator) + } + return + } + + let plan = plan() + guard plan.issues.isEmpty else { + reportIncomplete(plan.issues.map(\.qualifiedMessage), on: coordinator) + return + } + guard let scope else { return } + Task { + do { + let composed = try await DatabaseManager.shared.createTableStatements(plan: plan, scope: scope) + guard composed.issues.isEmpty, !composed.statements.isEmpty else { + reportIncomplete(composed.issues.map(\.qualifiedMessage), on: coordinator) + return + } + presentPreview(composed.statements, on: coordinator) + } catch { + reportPreviewFailure(error, on: coordinator) + } + } + } + + private func presentPreview(_ statements: [String], on coordinator: MainContentCoordinator) { + guard coordinator.activeSheet == nil, coordinator.activeCreateTableDraft === self else { return } + coordinator.toolbarState.previewStatements = statements + coordinator.activeSheet = .sqlPreview + } + + private func reportIncomplete(_ messages: [String], on coordinator: MainContentCoordinator) { + AlertHelper.showErrorSheet( + title: String(localized: "Some Changes Are Incomplete"), + message: messages.joined(separator: "\n"), + window: coordinator.contentWindow + ) + } + + private func reportPreviewFailure(_ error: any Error, on coordinator: MainContentCoordinator) { + Self.logger.error("Create Table preview failed: \(error.publicLogShape, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Preview SQL"), + message: error.localizedDescription, + window: coordinator.contentWindow + ) + } + + // MARK: - Create + + // What Cmd+S and the toolbar's Create Table run. + internal func create(coordinator: MainContentCoordinator) { + guard hasResolvedForm, !isCreating else { return } + commitOpenEdits() + if form != nil { + createFromForm(coordinator: coordinator) + } else { + createFromDefinition(coordinator: coordinator) + } + } + + private func createFromDefinition(coordinator: MainContentCoordinator) { + let plan = plan() + guard plan.issues.isEmpty else { + reportFailure(plan.issues.map(\.qualifiedMessage).joined(separator: "\n"), on: coordinator) + return + } + guard let scope else { + reportFailure(String(localized: "Not connected to database"), on: coordinator) + return + } + + isCreating = true + Task { + defer { isCreating = false } + do { + let composed = try await DatabaseManager.shared.createTableStatements(plan: plan, scope: scope) + guard composed.issues.isEmpty, !composed.statements.isEmpty else { + reportFailure(composed.issues.map(\.qualifiedMessage).joined(separator: "\n"), on: coordinator) + return + } + try await run( + composed.statements, + creating: composed.tableName ?? tableName, + in: scope, + coordinator: coordinator + ) + } catch { + Self.logger.error("Create table failed: \(error.publicLogShape, privacy: .public)") + reportFailure(error.localizedDescription, on: coordinator) + } + } + } + + private func createFromForm(coordinator: MainContentCoordinator) { + guard let formState = form else { return } + let formIssues = formState.issues(tableName: tableName) + guard formIssues.isEmpty else { + reportFailure(formIssues.map(\.qualifiedMessage).joined(separator: "\n"), on: coordinator) + return + } + guard let scope else { + reportFailure(String(localized: "Not connected to database"), on: coordinator) + return + } + + let request = formState.request(tableName: tableName) + let schema = scope.schema + isCreating = true + form?.clearSubmissionError() + + Task { + defer { isCreating = false } + do { + let statements = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + try driver.createTableStatements(for: request, schema: schema) + } + guard !statements.isEmpty else { + form?.recordSubmissionError(PluginCreateTableFormError( + message: String(localized: "The form produced no statements to run.") + )) + return + } + try await run(statements, creating: request.tableName, in: scope, coordinator: coordinator) + } catch let formError as PluginCreateTableFormError { + form?.recordSubmissionError(formError) + } catch { + Self.logger.error("Create table failed: \(error.publicLogShape, privacy: .public)") + reportFailure(error.localizedDescription, on: coordinator) + } + } + } + + // The draft keeps its work after a create, so the tab stays open; the new table opens in its own tab. + private func run( + _ statements: [String], + creating name: String, + in scope: DatabaseScope, + coordinator: MainContentCoordinator + ) async throws { + do { + try await DatabaseManager.shared.executeCreateTable( + statements: statements, + databaseType: connection.type, + scope: scope, + gate: executionGate + ) + } catch let incomplete as CreateTableIncompleteError { + openCreatedTable(name, in: scope, showStructure: true, coordinator: coordinator) + AlertHelper.showErrorSheet( + title: String(localized: "Table Created with Errors"), + message: incomplete.message, + recoverySuggestion: String( + localized: "The table exists, but a statement after CREATE TABLE failed. Finish the change in its Structure tab." + ), + window: coordinator.contentWindow + ) + return + } + openCreatedTable(name, in: scope, showStructure: false, coordinator: coordinator) + } + + private func openCreatedTable( + _ name: String, + in scope: DatabaseScope, + showStructure: Bool, + coordinator: MainContentCoordinator + ) { + coordinator.openTableTab( + name, + schema: scope.schema, + database: scope.database.nilIfEmpty, + showStructure: showStructure + ) + AppCommands.shared.objectChanged.send( + DatabaseObjectChange(connectionId: connection.id, scope: scope, name: name, kind: .rows) + ) + } + + private func reportFailure(_ message: String, on coordinator: MainContentCoordinator) { + AlertHelper.showErrorSheet( + title: String(localized: "Create Table Failed"), + message: message, + window: coordinator.contentWindow + ) + } } extension CreateTableDraft: ActiveGridModelProviding { - // The definition grid, unless the driver supplied a form of its own. + // The section holding the selection while the Definition page is on screen; a driver's form has no grid. var activeGridModel: DataGridModel? { - guard hasResolvedForm, form == nil else { return nil } - return gridAdapter?.model + guard editsDefinition, pane == .definition else { return nil } + return editor.focusedModel ?? editor.adapter(for: .columns).model } } diff --git a/apple/TablePro/Views/Structure/CreateTableFormEditor.swift b/apple/TablePro/Views/Structure/CreateTableFormEditor.swift deleted file mode 100644 index 604d8d0f48..0000000000 --- a/apple/TablePro/Views/Structure/CreateTableFormEditor.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// CreateTableFormEditor.swift -// TablePro -// - -import SwiftUI -import TableProPluginKit - -private enum CreateTableFormPane: CaseIterable { - case form - case preview - - var displayName: String { - switch self { - case .form: String(localized: "Form") - case .preview: String(localized: "Preview") - } - } -} - -internal struct CreateTableFormEditor: View { - @ObservedObject internal var draft: CreateTableDraft - internal let databaseType: DatabaseType - internal let isCreating: Bool - internal let generateStatements: (PluginCreateTableRequest) throws -> [String] - internal let onCreate: () -> Void - - @State private var pane: CreateTableFormPane = .form - - internal var body: some View { - if let form = draft.form { - let issues = form.issues(tableName: draft.tableName) - let messages = [form.submissionError?.message].compactMap { $0 } + issues.map(\.qualifiedMessage) - VStack(spacing: 0) { - HStack(spacing: 8) { - if let first = messages.first { - Label(first, systemImage: "exclamationmark.triangle.fill") - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .help(messages.joined(separator: "\n")) - .accessibilityIdentifier("create-table-validation") - } - - Spacer(minLength: 12) - - Picker(String(localized: "View"), selection: $pane) { - ForEach(CreateTableFormPane.allCases, id: \.self) { pane in - Text(pane.displayName).tag(pane) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - - Spacer(minLength: 12) - - Button( - isCreating ? String(localized: "Creating…") : String(localized: "Create Table"), - action: onCreate - ) - .buttonStyle(.borderedProminent) - .tint(.accentColor) - .disabled(!issues.isEmpty || isCreating) - .keyboardShortcut(.return, modifiers: .command) - .accessibilityIdentifier("create-table-commit") - } - .padding() - - Divider() - - switch pane { - case .form: - CreateTableFormView(draft: draft) - case .preview: - CreateTableFormPreview( - preview: form.preview(tableName: draft.tableName, generate: generateStatements), - databaseType: databaseType - ) - } - } - } - } -} diff --git a/apple/TablePro/Views/Structure/CreateTableGridAdapter.swift b/apple/TablePro/Views/Structure/CreateTableGridAdapter.swift deleted file mode 100644 index 24d07eb1b0..0000000000 --- a/apple/TablePro/Views/Structure/CreateTableGridAdapter.swift +++ /dev/null @@ -1,275 +0,0 @@ -// -// CreateTableGridAdapter.swift -// TablePro -// - -import AppKit -import TableProPluginKit - -// The Create Table grid: every row is a definition the draft holds, edited by the definition a row -// names. Held by the draft, so the grid's state survives the tab being switched away and back. -@MainActor -final class CreateTableGridAdapter { - let model: DataGridModel - let store: StructureGridStateStore - let structureChangeManager: StructureChangeManager - let connection: DatabaseConnection - let referenceMenus: ForeignKeyReferenceMenus - - weak var coordinator: MainContentCoordinator? - var serverSupport = StructureServerSupport.unrestricted - private(set) var structureTab: StructureTab = .columns - - var schemaName: String? { - get { referenceMenus.schemaName } - set { referenceMenus.schemaName = newValue } - } - - private(set) var provider: StructureRowProvider - private var sortDescriptor: StructureSortDescriptor? - private var snapshot = StructureGridSnapshot() - private let changeTracking: StructureGridChangeState - private let changeState: AnyChangeManager - - init(structureChangeManager: StructureChangeManager, connection: DatabaseConnection) { - self.structureChangeManager = structureChangeManager - self.connection = connection - let store = StructureGridStateStore() - self.store = store - self.model = DataGridModel(store: store) - self.referenceMenus = ForeignKeyReferenceMenus(connectionId: connection.id, databaseType: connection.type) - self.changeTracking = StructureGridChangeState(manager: structureChangeManager) - self.changeState = AnyChangeManager(changeTracking) - self.provider = StructureRowProvider( - changeManager: structureChangeManager, - tab: .columns, - databaseType: connection.type, - additionalFields: [.primaryKey], - serverSupport: .unrestricted - ) - referenceMenus.onListsChanged = { [weak self] in - self?.coordinator?.inspectorRowSourceRevision += 1 - } - model.dataSource = self - model.actions = self - } - - func select(_ tab: StructureTab) { - guard tab != structureTab else { return } - model.commitEditing() - structureTab = tab - sortDescriptor = nil - store.switchSubTab(to: tab) - store.sortState = SortState() - refresh() - } - - func refresh() { - changeTracking.tab = structureTab - changeTracking.rowCount = StructureGridEdits.rowCount(on: structureTab, manager: structureChangeManager) - let provider = StructureRowProvider( - changeManager: structureChangeManager, - tab: structureTab, - databaseType: connection.type, - additionalFields: [.primaryKey], - serverSupport: serverSupport, - sortDescriptor: sortDescriptor - ) - self.provider = provider - let rows = provider.asTableRows() - let states = rows.rows.map { row in - StructureGridEdits.visualState( - sourceIndex: StructureRowProvider.sourceIndex(of: row.id) ?? -1, - tab: structureTab, - manager: structureChangeManager, - orderedFields: provider.orderedColumnFields, - tintsModifiedFields: false - ) - } - model.configuration = makeConfiguration(provider: provider) - let previous = snapshot - snapshot = StructureGridSnapshot(rows: rows, states: states) - model.applyHeldSnapshot(from: previous, to: snapshot) - if previous.rows.rows != rows.rows || previous.states != states || previous.rows.columns != rows.columns { - coordinator?.inspectorRowSourceRevision += 1 - } - } - - private func makeConfiguration(provider: StructureRowProvider) -> DataGridConfiguration { - var configuration = DataGridConfiguration() - configuration.dropdownColumns = provider.dropdownColumns - configuration.typePickerColumns = provider.typePickerColumns - configuration.customDropdownOptions = provider.customDropdownOptions - configuration.isEditable = true - configuration.traits = DataGridEngineTraits( - databaseType: connection.type, - connectionScope: DatabaseScope( - connectionId: connection.id, - database: DatabaseManager.shared.browseDatabaseName(for: connection), - schema: schemaName - ), - looksUpUserDefinedTypes: true - ) - configuration.commands = [.addRow, .deleteRows, .valueFilter, .freezeColumns] - return configuration - } - - // MARK: - Commands - - func menuOptions(sourceIndex: Int, field: Int) -> [GridMenuOption]? { - switch structureTab { - case .indexes: - guard structureChangeManager.workingIndexes.indices.contains(sourceIndex) else { return nil } - return StructureRowProvider.indexMenuOptions( - columnIndex: field, - index: structureChangeManager.workingIndexes[sourceIndex], - serverSupport: serverSupport - ) - case .foreignKeys: - guard structureChangeManager.workingForeignKeys.indices.contains(sourceIndex) else { return nil } - return referenceMenus.options( - columnIndex: field, - foreignKey: structureChangeManager.workingForeignKeys[sourceIndex], - tableColumns: structureChangeManager.workingColumns.map(\.name) - ) - case .columns, .checkConstraints, .ddl, .parts, .triggers: - return nil - } - } - - @discardableResult - func applyEdit(rowID: RowID, field: Int, text: String) -> Bool { - guard let sourceIndex = StructureRowProvider.sourceIndex(of: rowID) else { return false } - write(text, field: field, sourceIndex: sourceIndex) - refresh() - return true - } - - private func write(_ text: String, field: Int, sourceIndex: Int) { - guard field >= 0 else { return } - let changedReference = StructureGridEdits.apply( - text, - field: field, - sourceIndex: sourceIndex, - tab: structureTab, - manager: structureChangeManager, - orderedFields: provider.orderedColumnFields, - connection: connection - ) - if let changedReference { - referenceMenus.prefetchReferencedColumns( - of: changedReference.referencedTable, schema: changedReference.referencedSchema - ) - } - } - - func addRow() { - StructureGridEdits.addRow(on: structureTab, manager: structureChangeManager) - refresh() - } - - func deleteRows(_ rowIDs: [RowID]) { - let indices = rowIDs.compactMap(StructureRowProvider.sourceIndex(of:)) - guard !indices.isEmpty else { return } - let displayRows = IndexSet(rowIDs.compactMap { model.displayIndex(of: $0) }) - StructureGridEdits.delete(sourceIndices: indices, tab: structureTab, manager: structureChangeManager) - refresh() - let remaining = model.rowCount - guard remaining > 0, let minRow = displayRows.first, let maxRow = displayRows.last else { - model.clearSelection() - return - } - let target = maxRow < remaining ? maxRow : (minRow > 0 ? minRow - 1 : 0) - if let rowID = model.rowID(at: target) { - model.selectRows([rowID]) - } else { - model.clearSelection() - } - } - - func undo() { - model.commitEditing() - structureChangeManager.undo() - refresh() - } - - func redo() { - model.commitEditing() - structureChangeManager.redo() - refresh() - } -} - -extension CreateTableGridAdapter: DataGridDataSource { - var gridRows: TableRows { snapshot.rows } - var gridChangeState: AnyChangeManager { changeState } - - func gridVisualState(for rowID: RowID) -> RowVisualState? { - snapshot.state(for: rowID) - } - - func gridMenuOptions(for rowID: RowID, column: DataGridColumn) -> [GridMenuOption]? { - guard let sourceIndex = StructureRowProvider.sourceIndex(of: rowID) else { return nil } - return menuOptions(sourceIndex: sourceIndex, field: column.dataIndex) - } -} - -extension CreateTableGridAdapter: DataGridActions { - func gridCommit(_ edits: [DataGridResolvedEdit], actionName: String) -> Bool { - let writes = edits.compactMap { edit -> (Int, Int, String)? in - StructureRowProvider.sourceIndex(of: edit.rowID).map { ($0, edit.dataIndex, edit.newValue.asText ?? "") } - } - guard !writes.isEmpty else { return false } - let writeAll = { - for (sourceIndex, field, text) in writes { - self.write(text, field: field, sourceIndex: sourceIndex) - } - } - if writes.count > 1 { - structureChangeManager.performAsOneUndoStep(writeAll) - } else { - writeAll() - } - refresh() - return true - } - - func gridDeleteRows(_ rowIDs: [RowID]) { - deleteRows(rowIDs) - } - - func gridAddRow() { - addRow() - } - - func gridSortDidChange(_ sort: SortState) { - if let primary = sort.columns.first { - sortDescriptor = StructureSortDescriptor(column: primary.columnIndex, ascending: primary.direction == .ascending) - store.sortState = SortState(columns: [primary], source: .user) - } else { - sortDescriptor = nil - store.sortState = SortState(columns: [], source: .user) - } - refresh() - } -} - -extension CreateTableGridAdapter: InspectorRowSource { - func inspectorRow(atDisplayRow displayRow: Int) -> InspectorRow? { - guard let rowID = model.rowID(at: displayRow), - let sourceIndex = StructureRowProvider.sourceIndex(of: rowID), - let providerRow = provider.displayRow(ofSourceIndex: sourceIndex) else { return nil } - return StructureInspectorRowBuilder.row( - atDisplayRow: providerRow, - tab: structureTab, - provider: provider, - canEditSchema: true, - rowOptions: { menuOptions(sourceIndex: sourceIndex, field: $0) } - ) - } - - func commitInspectorField(displayRow: Int, fieldIndex: Int, value: String?) { - guard let rowID = model.rowID(at: displayRow) else { return } - applyEdit(rowID: rowID, field: fieldIndex, text: value ?? "") - } -} diff --git a/apple/TablePro/Views/Structure/CreateTableView.swift b/apple/TablePro/Views/Structure/CreateTableView.swift index 24f6604657..1d626da883 100644 --- a/apple/TablePro/Views/Structure/CreateTableView.swift +++ b/apple/TablePro/Views/Structure/CreateTableView.swift @@ -3,494 +3,208 @@ // TablePro // -import AppKit -import Combine -import os import SwiftUI import TableProPluginKit -private enum CreateTableTab: CaseIterable { - case columns - case indexes - case foreignKeys - case sqlPreview - - var displayName: String { - switch self { - case .columns: String(localized: "Columns") - case .indexes: String(localized: "Indexes") - case .foreignKeys: String(localized: "Foreign Keys") - case .sqlPreview: String(localized: "SQL Preview") - } - } -} - +// A Create Table tab: the Structure tab's Definition page over an empty table, with the name on top, and the +// CREATE statement it composes. Create Table runs from the toolbar and Cmd+S; the table options are in the inspector. struct CreateTableView: View { - private static let logger = Logger(subsystem: "com.TablePro", category: "CreateTableView") - - let connection: DatabaseConnection - - let scope: DatabaseScope? - var coordinator: MainContentCoordinator? - - @Environment(\.appServices) private var services - @ObservedObject var draft: CreateTableDraft - @ObservedObject private var gridModel: DataGridModel - private let gridAdapter: CreateTableGridAdapter + let coordinator: MainContentCoordinator? - private var structureChangeManager: StructureChangeManager { draft.changeManager } + @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) private var ddlFontSize: Double = 13 - @State private var selectedTab: CreateTableTab = .columns - @State private var isCreating = false - @State private var errorMessage: String? - @State private var showError = false - @State private var actionHandler = CreateTableActionHandler() - - init( - connection: DatabaseConnection, - scope: DatabaseScope?, - coordinator: MainContentCoordinator?, - draft: CreateTableDraft - ) { - self.connection = connection - self.scope = scope - self.coordinator = coordinator - self.draft = draft - let adapter = draft.gridAdapter(for: connection) - self.gridAdapter = adapter - self.gridModel = adapter.model - } + private static let compositionPause: Duration = .milliseconds(150) var body: some View { VStack(spacing: 0) { - configBar - Divider() - editorContent + if draft.hasResolvedForm { + paneBar + Divider() + paneContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Color.clear + } } .navigationTitle(String(localized: "Create Table")) .onAppear { - draft.resolveForm( - from: DatabaseManager.shared.driver(for: connection.id)?.createTableFormSpec(schema: scope?.schema) - ) - actionHandler.createTable = { createTable() } - coordinator?.createTableActions = actionHandler - if draft.form == nil { - attachStructureGrid() - } - coordinator?.toolbarState.hasCreateTablePending = isReadyToCreate + draft.prepare(coordinator: coordinator) + publishReadiness() } .onDisappear { - // Guarded by identity: the outgoing view's onDisappear can land after the incoming one's onAppear. - if coordinator?.createTableActions === actionHandler { - coordinator?.createTableActions = nil - coordinator?.toolbarState.hasCreateTablePending = false - } - if coordinator?.inspectorRowSource === gridAdapter { - coordinator?.inspectorRowSource = nil - } - } - .onChange(of: selectedTab) { _ in gridAdapter.select(structureTab) } - .onChange(of: structureChangeManager.reloadVersion) { _ in gridAdapter.refresh() } - .onChange(of: structureChangeManager.hasChanges) { _ in gridAdapter.refresh() } - .onChange(of: isReadyToCreate) { _ in updateCreateTablePendingState() } - .task(id: compositionKey) { await recomposeAfterPause() } - .alert(String(localized: "Create Table Failed"), isPresented: $showError) { - Button("OK") {} - } message: { - Text(verbatim: RevealedText(errorMessage ?? "").plainText) + // Guarded: the outgoing view's onDisappear can land after the incoming one's onAppear. + if let active = coordinator?.activeCreateTableDraft, active !== draft { return } + coordinator?.toolbarState.hasCreateTablePending = false } + .onChange(of: draft.isReadyToCreate) { _ in publishReadiness() } + .task(id: draft.scope) { await draft.loadTableOptions() } + .task(id: draft.compositionKey) { await recomposeAfterPause() } } - @ViewBuilder - private var editorContent: some View { - if draft.form != nil { - CreateTableFormEditor( - draft: draft, - databaseType: connection.type, - isCreating: isCreating, - generateStatements: { request in try formStatements(for: request) }, - onCreate: { createTable() } - ) - } else if draft.hasResolvedForm { - toolbar - Divider() - tabContent - } else { - Color.clear - } + private func publishReadiness() { + coordinator?.toolbarState.hasCreateTablePending = draft.isReadyToCreate } - private func attachStructureGrid() { - coordinator?.inspectorRowSource = gridAdapter - gridAdapter.coordinator = coordinator - gridAdapter.serverSupport = StructureServerSupport.forConnection(connection.id) - gridAdapter.schemaName = coordinator?.toolbarState.currentSchema - if structureChangeManager.workingColumns.isEmpty { - structureChangeManager.addNewColumn() - } - gridAdapter.select(structureTab) - gridAdapter.refresh() - actionHandler.undo = { gridAdapter.undo() } - actionHandler.redo = { gridAdapter.redo() } + private func recomposeAfterPause() async { + try? await Task.sleep(for: Self.compositionPause) + guard !Task.isCancelled else { return } + await draft.recompose() } - // MARK: - Config Bar - - private var configBar: some View { - HStack(spacing: 12) { - Text("Table Name:") - .font(.body.weight(.medium)) - - TextField("Enter table name", text: $draft.tableName) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled(true) - .frame(maxWidth: 300) - .accessibilityLabel(String(localized: "Table Name")) - .accessibilityIdentifier("create-table-name") - - if offersTableComment { - Text("Comment:") - .font(.body.weight(.medium)) - - TextField("Optional", text: $draft.tableOptions.comment) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 300) - .accessibilityLabel(String(localized: "Comment")) - .accessibilityIdentifier("create-table-comment") - } - - if showMySQLOptions { - Divider() - .frame(height: 20) - - Picker("Engine:", selection: $draft.tableOptions.engine) { - ForEach(CreateTableOptions.engines, id: \.self) { engine in - Text(engine).tag(engine) - } - } - .fixedSize() - - Picker("Charset:", selection: $draft.tableOptions.charset) { - ForEach(CreateTableOptions.charsets, id: \.self) { cs in - Text(cs).tag(cs) - } - } - .fixedSize() - - Picker("Collation:", selection: $draft.tableOptions.collation) { - ForEach(CreateTableOptions.collations[draft.tableOptions.charset] ?? [], id: \.self) { col in - Text(col).tag(col) - } - } - .fixedSize() - } + // MARK: - Panes - Spacer() - } - .padding() - .paneBarBackground() - .onChange(of: draft.tableOptions.charset) { newCharset in - if let first = CreateTableOptions.collations[newCharset]?.first { - draft.tableOptions.collation = first + private var paneBar: some View { + PaneTabBar( + title: String(localized: "Structure"), + selection: paneSelection, + identifier: "create-table-tabs" + ) { + ForEach(CreateTablePane.allCases, id: \.self) { pane in + Text(title(of: pane)).tag(pane) } } } - private var showMySQLOptions: Bool { - CreateTableDraft.offersEngineOptions(for: connection.type) - } - - private var offersTableComment: Bool { - guard draft.hasResolvedForm, draft.form == nil else { return false } - let support = DatabaseObjectToolEligibility.Support.of(DatabaseManager.shared.driver(for: connection.id)) - return support.commentableTypes.contains(.table) - } - - // MARK: - Toolbar - - private var availableTabs: [CreateTableTab] { - var tabs = CreateTableTab.allCases - if !connection.type.supportsForeignKeys { - tabs = tabs.filter { $0 != .foreignKeys } - } - return tabs - } - - private var isGridTab: Bool { - selectedTab != .sqlPreview - } - - private var toolbar: some View { - let issues = draft.composed?.issues ?? draft.plan(for: connection.type).issues - - return HStack(spacing: 8) { - Button(action: { gridAdapter.addRow() }) { - Image(systemName: "plus") - .frame(width: 24, height: 24) - } - .help(String(localized: "Add Row")) - .accessibilityLabel(String(localized: "Add Row")) - .disabled(!isGridTab) - - Button(action: { gridAdapter.deleteRows(gridModel.selectedRowIDs) }) { - Image(systemName: "minus") - .frame(width: 24, height: 24) - } - .help(String(localized: "Delete Selected")) - .accessibilityLabel(String(localized: "Delete Selected")) - .disabled(!isGridTab || gridModel.selection.isEmpty) - - issueMessage(issues) - - Spacer(minLength: 12) - - Picker(String(localized: "Structure"), selection: $selectedTab) { - ForEach(availableTabs, id: \.self) { tab in - Text(tab.displayName).tag(tab) - } + // An open cell edit lands before the pane changes, so the DDL includes it. + private var paneSelection: Binding { + Binding( + get: { draft.pane }, + set: { pane in + draft.commitOpenEdits() + draft.pane = pane } - .pickerStyle(.segmented) - .labelsHidden() - - Spacer(minLength: 12) - - Button(isCreating ? String(localized: "Creating…") : String(localized: "Create Table")) { - createTable() - } - .buttonStyle(.borderedProminent) - .tint(.accentColor) - .disabled(!issues.isEmpty || isCreating) - .keyboardShortcut(.return, modifiers: .command) - .accessibilityIdentifier("create-table-commit") - } - .padding() + ) } - @ViewBuilder - private func issueMessage(_ issues: [SchemaDraftIssue]) -> some View { - if let first = issues.first { - Label(messageText(first), systemImage: "exclamationmark.triangle.fill") - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .help(issues.map(\.qualifiedMessage).joined(separator: "\n")) - .accessibilityIdentifier("create-table-validation") + private func title(of pane: CreateTablePane) -> String { + switch pane { + case .definition: + if draft.form == nil { + return String(localized: "Definition") + } + return String(localized: "Form") + case .ddl: + return "DDL" } } - private func messageText(_ issue: SchemaDraftIssue) -> String { - issue.tab == structureTab ? issue.message : issue.qualifiedMessage - } - - // MARK: - Tab Content - @ViewBuilder - private var tabContent: some View { - switch selectedTab { - case .columns, .indexes, .foreignKeys: - structureGrid - case .sqlPreview: - sqlPreviewView - } - } - - // MARK: - Structure Grid - - private var structureTab: StructureTab { - switch selectedTab { - case .columns: return .columns - case .indexes: return .indexes - case .foreignKeys: return .foreignKeys - case .sqlPreview: return .columns + private var paneContent: some View { + switch draft.pane { + case .definition: + if draft.form != nil { + VStack(spacing: 0) { + CreateTableNameHeader(draft: draft) + Divider() + CreateTableFormView(draft: draft) + } + } else { + StructureDefinitionPage( + editor: draft.editor, + loadState: StructureLoadState(), + header: AnyView(CreateTableNameHeader(draft: draft)) + ) + } + case .ddl: + ddlPane } } - private var structureGrid: some View { - DataGridView(model: gridModel) - } - - // MARK: - SQL Preview - @ViewBuilder - private var sqlPreviewView: some View { - if let composed = draft.composed { + private var ddlPane: some View { + if let preview = draft.formPreview { + CreateTableFormPreview(preview: preview, databaseType: draft.connection.type) + } else if let composed = draft.composed { if composed.statements.isEmpty { CreateTableSQLPreviewStandIn(content: .message(composed.issues.first?.qualifiedMessage)) } else { - DDLTextView(ddl: composed.preview, fontSize: .constant(13), databaseType: connection.type) + DDLTextView(ddl: composed.preview, fontSize: $ddlFontSize, databaseType: draft.connection.type) } } else if let failure = draft.compositionFailure { CreateTableSQLPreviewStandIn(content: .message(failure)) } else { - uncomposedPreview(plan: draft.plan(for: connection.type)) - } - } - - private func uncomposedPreview(plan: CreateTablePlan) -> some View { - CreateTableSQLPreviewStandIn( - content: plan.definition == nil ? .message(plan.issues.first?.qualifiedMessage) : .composing - ) - } - - // MARK: - SQL Generation - - private static let compositionPause: Duration = .milliseconds(150) - - private var compositionKey: CreateTableCompositionKey? { - scope.map { draft.compositionKey(scope: $0) } - } - - private func recomposeAfterPause() async { - guard let scope else { return } - try? await Task.sleep(for: Self.compositionPause) - guard !Task.isCancelled else { return } - await draft.recompose(databaseType: connection.type, scope: scope) - } - - // MARK: - Create Table - - private var isReadyToCreate: Bool { - guard !isCreating else { return false } - if let form = draft.form { - return form.issues(tableName: draft.tableName).isEmpty - } - guard let composed = draft.composed else { return false } - return composed.issues.isEmpty && !composed.statements.isEmpty - } - - private func formStatements(for request: PluginCreateTableRequest) throws -> [String] { - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { - throw PluginCreateTableFormError(message: String(localized: "Not connected to database")) + let plan = draft.plan() + CreateTableSQLPreviewStandIn( + content: plan.definition == nil ? .message(plan.issues.first?.qualifiedMessage) : .composing + ) } - return try driver.createTableStatements(for: request, schema: scope?.schema) } +} - private func updateCreateTablePendingState() { - coordinator?.toolbarState.hasCreateTablePending = isReadyToCreate - } +// The table's name and what still stands between the draft and Create Table. An issue that names a row reveals it. +struct CreateTableNameHeader: View { + @ObservedObject var draft: CreateTableDraft + @FocusState private var isNameFocused: Bool - private func createTable() { - guard draft.form != nil else { - createTableFromStructureGrid() - return - } - createTableFromForm() + private struct Row: Identifiable { + let id: String + let message: String + let reveal: (() -> Void)? } - private func createTableFromStructureGrid() { - guard !isCreating else { return } - let plan = draft.plan(for: connection.type) - guard plan.issues.isEmpty else { - errorMessage = plan.issues.map(\.qualifiedMessage).joined(separator: "\n") - showError = true - return - } - guard let scope else { - errorMessage = String(localized: "Not connected to database") - showError = true - return - } - - isCreating = true - errorMessage = nil - updateCreateTablePendingState() - - Task { - defer { isCreating = false } - do { - let composed = try await DatabaseManager.shared.createTableStatements(plan: plan, scope: scope) - guard composed.issues.isEmpty, !composed.statements.isEmpty else { - errorMessage = composed.issues.map(\.qualifiedMessage).joined(separator: "\n") - showError = true - return + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Table Name:") + TextField(String(localized: "Table Name"), text: $draft.tableName, prompt: Text("Required")) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled(true) + .frame(maxWidth: 300) + .focused($isNameFocused) + .accessibilityIdentifier("create-table-name") + } + let issueRows = rows + if !issueRows.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(issueRows) { row in + issueRow(row) + } } - let createdName = composed.tableName ?? draft.tableName - try await runCreateTable(statements: composed.statements, createdName: createdName, in: scope) - } catch { - Self.logger.error("Create table failed: \(error.publicLogShape, privacy: .public)") - errorMessage = error.localizedDescription - showError = true + .accessibilityElement(children: .contain) + .accessibilityIdentifier("create-table-validation") } } - } - - private func createTableFromForm() { - guard !isCreating, let form = draft.form else { return } - let issues = form.issues(tableName: draft.tableName) - guard issues.isEmpty else { - errorMessage = issues.map(\.qualifiedMessage).joined(separator: "\n") - showError = true - return - } - guard let scope else { - errorMessage = String(localized: "Not connected to database") - showError = true - return + .padding(.horizontal, 10) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .onAppear { + if draft.claimNameFocus() { + isNameFocused = true + } } + } - let request = form.request(tableName: draft.tableName) - let schema = scope.schema - isCreating = true - errorMessage = nil - draft.form?.clearSubmissionError() - updateCreateTablePendingState() - - Task { - defer { isCreating = false } - do { - let statements = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in - try driver.createTableStatements(for: request, schema: schema) - } - guard !statements.isEmpty else { - draft.form?.recordSubmissionError(PluginCreateTableFormError( - message: String(localized: "The form produced no statements to run.") - )) - return - } - try await runCreateTable(statements: statements, createdName: request.tableName, in: scope) - } catch let formError as PluginCreateTableFormError { - draft.form?.recordSubmissionError(formError) - } catch { - Self.logger.error("Create table failed: \(error.publicLogShape, privacy: .public)") - errorMessage = error.localizedDescription - showError = true + private func issueRow(_ row: Row) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + if let reveal = row.reveal { + Button(row.message, action: reveal) + .buttonStyle(.link) + } else { + Text(row.message) + .foregroundStyle(.secondary) } } + .font(.callout) + .fixedSize(horizontal: false, vertical: true) } - private func runCreateTable(statements: [String], createdName: String, in scope: DatabaseScope) async throws { - do { - try await DatabaseManager.shared.executeCreateTable( - statements: statements, - databaseType: connection.type, - scope: scope - ) - } catch let incomplete as CreateTableIncompleteError { - openCreatedTable(createdName, in: scope, showStructure: true) - AlertHelper.showErrorSheet( - title: String(localized: "Table Created with Errors"), - message: incomplete.message, - recoverySuggestion: String( - localized: "The table exists, but a statement after CREATE TABLE failed. Finish the change in its Structure tab." - ), - window: coordinator?.contentWindow + private var rows: [Row] { + if let form = draft.form { + let messages = [form.submissionError?.message].compactMap { $0 } + + form.issues(tableName: draft.tableName).map(\.qualifiedMessage) + return messages.enumerated().map { Row(id: "\($0.offset)", message: $0.element, reveal: nil) } + } + return draft.issues.enumerated().map { offset, issue in + Row( + id: "\(offset)-\(issue.id)", + message: issue.row == nil ? issue.message : issue.qualifiedMessage, + reveal: draft.entityID(for: issue) == nil ? nil : { draft.reveal(issue) } ) - return } - openCreatedTable(createdName, in: scope, showStructure: false) - } - - private func openCreatedTable(_ name: String, in scope: DatabaseScope, showStructure: Bool) { - let created = DatabaseObjectChange(connectionId: connection.id, scope: scope, name: name, kind: .rows) - coordinator?.openTableTab( - name, - schema: scope.schema, - database: scope.database.nilIfEmpty, - showStructure: showStructure - ) - AppCommands.shared.objectChanged.send(created) } } diff --git a/apple/TablePro/Views/Structure/Inspector/CreateTablePropertiesView.swift b/apple/TablePro/Views/Structure/Inspector/CreateTablePropertiesView.swift new file mode 100644 index 0000000000..09d4303321 --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/CreateTablePropertiesView.swift @@ -0,0 +1,117 @@ +// +// CreateTablePropertiesView.swift +// TablePro +// + +import SwiftUI + +// The drafted table's comment and engine options, when nothing on the Definition page is selected. The lists come +// from the server's catalog and start from the target database's defaults; nil writes no clause. +struct CreateTablePropertiesView: View { + @ObservedObject var draft: CreateTableDraft + + private var catalog: TableOptionsCatalog? { + guard case .loaded(let catalog) = draft.optionsCatalog else { return nil } + return catalog + } + + private var offersComment: Bool { + let driver = DatabaseManager.shared.driver(for: draft.editor.context.connection.id) + return DatabaseObjectToolEligibility.Support.of(driver).commentableTypes.contains(.table) + } + + var body: some View { + let offersComment = draft.form == nil && self.offersComment + let catalog = draft.form == nil ? self.catalog : nil + if !offersComment, catalog == nil { + UnavailableStateView( + String(localized: "No Selection"), + systemImage: TrailingPaneSurface.inspector.symbolName, + description: Text(String(localized: "Select a definition to edit it")) + ) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + StructureInspectorSectionHeader(title: String(localized: "Table")) + VStack(alignment: .leading, spacing: 10) { + if offersComment { + StructureInspectorRow(label: String(localized: "Comment")) { + TextField(String(localized: "Optional"), text: $draft.tableOptions.comment, axis: .vertical) + .lineLimit(2...6) + .textFieldStyle(.squareBorder) + .accessibilityLabel(String(localized: "Comment")) + .accessibilityIdentifier("create-table-comment") + } + } + if let catalog { + engineRows(catalog) + } + } + } + .padding(.horizontal, InspectorMetrics.horizontalInset) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.subheadline) + .controlSize(.small) + .disabled(draft.editor.isReadOnly) + } + } + + @ViewBuilder + private func engineRows(_ catalog: TableOptionsCatalog) -> some View { + let options = draft.tableOptions + if !catalog.engines.isEmpty || options.engine != nil { + StructureInspectorRow(label: String(localized: "Engine")) { + Picker(String(localized: "Engine"), selection: $draft.tableOptions.engine) { + if options.engine == nil { + Text(String(localized: "Default")).tag(String?.none) + } + ForEach(TableOptionsForm.engines(in: catalog, keeping: options.engine)) { choice in + Text(choice.title).tag(Optional(choice.name)) + } + } + .labelsHidden() + .accessibilityIdentifier("create-table-engine") + } + } + + StructureInspectorRow(label: String(localized: "Character Set")) { + Picker(String(localized: "Character Set"), selection: characterSetBinding) { + if options.charset == nil { + Text(String(localized: "Default")).tag(String?.none) + } + ForEach(TableOptionsForm.characterSets(in: catalog, keeping: options.charset), id: \.self) { name in + Text(name).tag(Optional(name)) + } + } + .labelsHidden() + .accessibilityIdentifier("create-table-charset") + } + + StructureInspectorRow(label: String(localized: "Collation")) { + Picker(String(localized: "Collation"), selection: $draft.tableOptions.collation) { + if options.collation == nil { + Text(String(localized: "Default")).tag(String?.none) + } + ForEach( + TableOptionsForm.collations(in: catalog, characterSet: options.charset, keeping: options.collation) + ) { choice in + Text(choice.title).tag(Optional(choice.name)) + } + } + .labelsHidden() + .disabled(options.charset == nil) + .accessibilityIdentifier("create-table-collation") + } + } + + // One whole-value write: the collation follows the character set, so a second write would resolve against a + // stale draft. + private var characterSetBinding: Binding { + Binding( + get: { draft.tableOptions.charset }, + set: { draft.selectCharacterSet($0) } + ) + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureDefinitionFormView.swift b/apple/TablePro/Views/Structure/Inspector/StructureDefinitionFormView.swift new file mode 100644 index 0000000000..ec0123cf4f --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureDefinitionFormView.swift @@ -0,0 +1,165 @@ +// +// StructureDefinitionFormView.swift +// TablePro +// + +import SwiftUI + +// The selected column, index, foreign key or check constraint as a form: each field's name above a control the pane's +// width, the layout the Data view's inspector uses, since a label column leaves a 270pt pane too little room. +struct StructureDefinitionFormView: View { + let editor: StructureDefinitionEditor + let form: StructureInspectorForm + let revision: Int + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if form.isDeleted { + Label( + String(localized: "Marked for deletion. Undo or Undo Delete brings it back."), + systemImage: "trash" + ) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("structure-inspector-deleted-note") + } + + VStack(alignment: .leading, spacing: 10) { + ForEach(form.fields) { field in + if case .checkbox = StructureInspectorControl.resolve(field) { + control(for: field) + } else { + StructureInspectorRow(label: field.label, isModified: field.isModified) { + control(for: field) + } + } + } + } + } + .padding(.horizontal, InspectorMetrics.horizontalInset) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.subheadline) + .controlSize(.small) + .onAppear { + // Reading the character sets is what a Charset or Collation menu opening does in the grid. + guard form.section == .columns, form.isEditable else { return } + editor.readCharacterSetCatalogIfNeeded() + } + } + + @ViewBuilder + private func control(for field: StructureInspectorForm.Field) -> some View { + let identifier = StructureInspectorPresentation.controlIdentifier(section: form.section, field: field.id) + let value = field.value ?? "" + switch StructureInspectorControl.resolve(field) { + case .text: + StructureInspectorTextField( + label: field.label, value: value, revision: revision, + onType: { type($0, into: field) }, onEnd: { editor.endInspectorTyping() } + ) + .accessibilityLabel(field.label) + .accessibilityIdentifier(identifier) + case .multiline: + StructureInspectorTextField( + label: field.label, value: value, revision: revision, isMultiline: true, + onType: { type($0, into: field) }, onEnd: { editor.endInspectorTyping() } + ) + .accessibilityLabel(field.label) + .accessibilityIdentifier(identifier) + case .checkbox: + Toggle( + isOn: Binding( + get: { value == StructureRowProvider.booleanOptions[0] }, + set: { isOn in + commit(isOn ? StructureRowProvider.booleanOptions[0] : StructureRowProvider.booleanOptions[1], to: field) + } + ) + ) { + Text(field.label) + .fontWeight(field.isModified ? .semibold : .regular) + } + .toggleStyle(.checkbox) + .accessibilityLabel(field.label) + .accessibilityIdentifier(identifier) + case .popUp(let choices): + popUp(choices, field: field) + .accessibilityLabel(field.label) + .accessibilityIdentifier(identifier) + case .comboBox(let choices): + StructureInspectorSuggestionField( + label: field.label, + value: value, + revision: revision, + identifier: identifier, + groups: choices.groups, + onType: { type($0, into: field) }, + onPick: { commit($0, to: field) }, + onEnd: { editor.endInspectorTyping() } + ) + case .typePicker: + StructureInspectorTypeField( + value: value, + revision: revision, + label: field.label, + identifier: identifier, + databaseType: editor.context.databaseType, + scope: editor.context.scope, + onType: { type($0, into: field) }, + onPick: { commit($0, to: field) }, + onEnd: { editor.endInspectorTyping() } + ) + case .readOnly(let text): + Text(text) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel(field.label) + .accessibilityValue(text) + .accessibilityIdentifier(identifier) + } + } + + private func popUp(_ choices: StructureInspectorChoices, field: StructureInspectorForm.Field) -> some View { + Picker( + selection: Binding( + get: { choices.selectedID ?? Self.noSelection }, + set: { id in + guard let value = choices.value(of: id) else { return } + commit(value, to: field) + } + ) + ) { + ForEach(choices.groups) { group in + if let header = group.header { + Section { + ForEach(group.items) { Text($0.title).tag($0.id) } + } header: { + Text(header) + } + } else { + ForEach(group.items) { Text($0.title).tag($0.id) } + } + } + } label: { + Text(field.label) + } + .pickerStyle(.menu) + .labelsHidden() + .fixedSize(horizontal: false, vertical: true) + } + + // A tag no item carries, so a value the menu does not list shows nothing selected rather than another item. + private static let noSelection = -1 + + private func type(_ value: String, into field: StructureInspectorForm.Field) { + editor.typeInspectorField(section: form.section, id: form.entityID, field: field.id, value: value) + } + + private func commit(_ value: String, to field: StructureInspectorForm.Field) { + guard value != (field.value ?? "") else { return } + editor.commitInspectorField(section: form.section, id: form.entityID, field: field.id, value: value) + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift b/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift new file mode 100644 index 0000000000..f9846b16f5 --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift @@ -0,0 +1,232 @@ +// +// StructureInspectorControls.swift +// TablePro +// + +import SwiftUI + +// A definition field's text. Every keystroke is staged at once, the way the table comment's field is, so Save, Refresh +// and the close prompt always see what is typed; the editor folds the run into one undo step, ended by Return or by +// focus leaving. +struct StructureInspectorTextField: View { + let label: String + let value: String + let revision: Int + var isMultiline = false + // Moved by a pick from a menu beside the field: the field shows the picked value even while it has focus. + var resetToken = 0 + let onType: (String) -> Void + let onEnd: () -> Void + + @State private var draft = "" + // What the field last wrote or was synced from, so only typing writes, never a value the model moved under it. + @State private var base = "" + // The stored value, so leaving a field whose write was refused puts it back. + @State private var latest = "" + @State private var hasSeeded = false + @FocusState private var isFocused: Bool + + private struct Sync: Equatable { + let value: String + let revision: Int + } + + var body: some View { + field + .textFieldStyle(.squareBorder) + .autocorrectionDisabled(true) + .focused($isFocused) + .onAppear(perform: seed) + .onChange(of: Sync(value: value, revision: revision)) { sync in + latest = sync.value + guard !isFocused else { return } + draft = sync.value + base = sync.value + } + .onChange(of: draft) { text in + guard hasSeeded, isFocused, text != base else { return } + base = text + onType(text) + } + .onChange(of: isFocused) { focused in + guard !focused else { return } + onEnd() + draft = latest + base = latest + } + .onChange(of: resetToken) { _ in + draft = value + base = value + latest = value + } + .onSubmit(onEnd) + .onDisappear(perform: onEnd) + } + + // The field's name is above it, so an empty field shows no placeholder. + @ViewBuilder + private var field: some View { + if isMultiline { + TextField(text: $draft, prompt: Text(verbatim: ""), axis: .vertical) { Text(label) } + .lineLimit(2...6) + .labelsHidden() + } else { + TextField(text: $draft, prompt: Text(verbatim: "")) { Text(label) } + .labelsHidden() + } + } + + private func seed() { + guard !hasSeeded else { return } + hasSeeded = true + draft = value + base = value + latest = value + } +} + +// Free text with the engine's types and the database's own types to pick from, grouped as the grid's type picker +// groups them. +struct StructureInspectorTypeField: View { + let value: String + let revision: Int + let label: String + let identifier: String + let databaseType: DatabaseType + let scope: DatabaseScope? + let onType: (String) -> Void + let onPick: (String) -> Void + let onEnd: () -> Void + + var body: some View { + UserDefinedTypeAwarePicker(scope: scope) { userDefinedTypes in + StructureInspectorSuggestionField( + label: label, + value: value, + revision: revision, + identifier: identifier, + groups: Self.groups(userDefinedTypes: userDefinedTypes, databaseType: databaseType), + onType: onType, + onPick: onPick, + onEnd: onEnd + ) + } + } + + static func groups(userDefinedTypes: [String], databaseType: DatabaseType) -> [StructureInspectorChoices.ItemGroup] { + var categories = PluginManager.shared.columnTypesByCategory(for: databaseType) + .sorted { $0.key < $1.key } + .map { (name: $0.key, types: $0.value) } + if !userDefinedTypes.isEmpty { + categories.insert((name: String(localized: "User-Defined"), types: userDefinedTypes), at: 0) + } + var nextID = 0 + var groups: [StructureInspectorChoices.ItemGroup] = [] + for (offset, category) in categories.enumerated() { + var items: [StructureInspectorChoices.Item] = [] + for type in category.types { + items.append(StructureInspectorChoices.Item(id: nextID, title: type, value: type)) + nextID += 1 + } + groups.append(StructureInspectorChoices.ItemGroup(id: offset, header: category.name, items: items)) + } + return groups + } +} + +// A text field with its common values one click away, the way a combo box offers them. A SwiftUI field and a +// pull-down menu, so it draws like every other field in the pane; an AppKit combo box keeps the pre-glass bezel. +struct StructureInspectorSuggestionField: View { + let label: String + let value: String + let revision: Int + let identifier: String + let groups: [StructureInspectorChoices.ItemGroup] + let onType: (String) -> Void + let onPick: (String) -> Void + let onEnd: () -> Void + + @State private var pickRevision = 0 + + var body: some View { + HStack(spacing: 4) { + StructureInspectorTextField( + label: label, value: value, revision: revision, resetToken: pickRevision, onType: onType, onEnd: onEnd + ) + .accessibilityLabel(label) + .accessibilityIdentifier(identifier) + Menu { + ForEach(groups) { group in + if let header = group.header { + Section(header) { items(group) } + } else { + items(group) + } + } + } label: { + Label(String(format: String(localized: "Choose %@"), label), systemImage: "chevron.down") + .labelStyle(.iconOnly) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(String(format: String(localized: "Choose %@"), label)) + .accessibilityIdentifier("\(identifier)-choices") + } + } + + // A pick is its own undo step and replaces whatever is typed in the field. + private func items(_ group: StructureInspectorChoices.ItemGroup) -> some View { + ForEach(group.items) { item in + Button(item.title) { + pickRevision &+= 1 + guard item.value != value else { return } + onPick(item.value) + } + } + } +} + +// One field: its name above, the control the pane's full width below, so every field shares the pane's leading edge +// as the Data view's inspector and the statistics do. Modified settings read bold, the way Xcode marks them. +struct StructureInspectorRow: View { + let label: String + var isModified = false + @ViewBuilder let control: () -> Control + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .fontWeight(isModified ? .semibold : .regular) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityHidden(true) + control() + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// The inspector's section title, the style the table statistics below it use. +struct StructureInspectorSectionHeader: View { + let title: String + + var body: some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .accessibilityAddTraits(.isHeader) + } +} + +struct StructureInspectorFootnote: View { + let text: String + + var body: some View { + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureInspectorForm.swift b/apple/TablePro/Views/Structure/Inspector/StructureInspectorForm.swift new file mode 100644 index 0000000000..6ae7fb2cd5 --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureInspectorForm.swift @@ -0,0 +1,116 @@ +// +// StructureInspectorForm.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +// One definition's fields as the inspector edits them. Built from the same provider and menus the grid uses, so +// the two never disagree, and written back through the grid's own edit path, one undo step per field. +struct StructureInspectorForm: Equatable { + struct Field: Equatable, Identifiable { + enum Kind: Equatable { + case text + case multiline + case toggle + case menu([GridMenuOption]) + case combo([GridMenuOption]) + case typePicker + } + + let id: Int + let label: String + let value: String? + let kind: Kind + let isModified: Bool + let isEditable: Bool + } + + let section: StructureSection + let entityID: UUID + let name: String + let fields: [Field] + let isEditable: Bool + let isDeleted: Bool + let isNew: Bool + + @MainActor + static func build(adapter: StructureSectionAdapter, sourceIndex: Int, entityID: UUID) -> StructureInspectorForm? { + let provider = StructureRowProvider( + changeManager: adapter.manager, + tab: adapter.section, + databaseType: adapter.context.databaseType, + additionalFields: [.primaryKey], + serverSupport: adapter.serverSupport, + picksCharacterSets: ColumnCharacterSetMenus.offersMenus(adapter.characterSetContext.catalog) + ) + guard let values = provider.row(at: sourceIndex) else { return nil } + let names = provider.columns + let state = adapter.manager.deleteInsertState(for: sourceIndex, tab: adapter.section) + let modified = provider.modifiedFieldIndices(atDisplay: sourceIndex) + let locked = adapter.lockedFieldIndices.union(adapter.characterSetLockedFields(sourceIndex: sourceIndex)) + let isEditable = adapter.canEdit && !state.isDeleted + let fields = names.indices.map { index in + Field( + id: index, + label: names[index], + value: index < values.count ? values[index] : nil, + kind: kind( + at: index, + section: adapter.section, + orderedFields: provider.orderedColumnFields, + options: adapter.menuOptions(sourceIndex: sourceIndex, field: index) ?? provider.customDropdownOptions[index], + typePickerColumns: provider.typePickerColumns + ), + isModified: !state.isInserted && modified.contains(index), + isEditable: isEditable && !locked.contains(index) + ) + } + return StructureInspectorForm( + section: adapter.section, + entityID: entityID, + name: (values.first ?? nil) ?? "", + fields: fields, + isEditable: isEditable, + isDeleted: state.isDeleted, + isNew: state.isInserted + ) + } + + @MainActor + static func kind( + at index: Int, + section: StructureSection, + orderedFields: [StructureColumnField], + options: [GridMenuOption]?, + typePickerColumns: Set + ) -> Field.Kind { + if typePickerColumns.contains(index) { + return .typePicker + } + if let options, !options.isEmpty { + if options.compactMap(\.sql) == StructureRowProvider.booleanOptions { + return .toggle + } + if options.contains(where: { if case .custom = $0 { true } else { false } }) { + return .combo(options) + } + return .menu(options) + } + switch section { + case .columns: + guard orderedFields.indices.contains(index) else { return .text } + switch orderedFields[index] { + case .comment, .generationExpression: return .multiline + default: return .text + } + case .checkConstraints: + return index == 1 ? .multiline : .text + case .indexes: + return index == 4 ? .multiline : .text + case .foreignKeys: + return .text + } + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureInspectorPresentation.swift b/apple/TablePro/Views/Structure/Inspector/StructureInspectorPresentation.swift new file mode 100644 index 0000000000..fe896e4a8e --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureInspectorPresentation.swift @@ -0,0 +1,121 @@ +// +// StructureInspectorPresentation.swift +// TablePro +// + +import Foundation + +enum StructureInspectorPresentation { + @MainActor + static func subject( + for target: StructureDefinitionEditor.InspectorTarget, + definitionName: String?, + tableName: String + ) -> InspectorSubject { + let table = tableName.isEmpty ? nil : tableName + switch target { + case .entity(let section, _): + return .definition(name: definitionName ?? "", section: section, table: table) + case .table, .several: + return .tableOnly(table: table ?? String(localized: "New Table")) + } + } + + static func severalTitle(_ section: StructureSection, count: Int) -> String { + switch section { + case .columns: String(format: String(localized: "%d Columns Selected"), count) + case .indexes: String(format: String(localized: "%d Indexes Selected"), count) + case .foreignKeys: String(format: String(localized: "%d Foreign Keys Selected"), count) + case .checkConstraints: String(format: String(localized: "%d Check Constraints Selected"), count) + } + } + + static func controlIdentifier(section: StructureSection, field: Int) -> String { + "structure-inspector-\(section.rawValue)-\(field)" + } +} + +// The control a definition field gets in the inspector. A field the form cannot edit is plain text. +enum StructureInspectorControl: Equatable { + case text + case multiline + case checkbox + case popUp(StructureInspectorChoices) + case comboBox(StructureInspectorChoices) + case typePicker + case readOnly(String) + + static func resolve(_ field: StructureInspectorForm.Field) -> StructureInspectorControl { + let value = field.value ?? "" + switch field.kind { + case .text: + return field.isEditable ? .text : .readOnly(value) + case .multiline: + return field.isEditable ? .multiline : .readOnly(value) + case .toggle: + return field.isEditable ? .checkbox : .readOnly(value) + case .typePicker: + return field.isEditable ? .typePicker : .readOnly(value) + case .menu(let options): + let choices = StructureInspectorChoices(options: options, current: value) + return field.isEditable ? .popUp(choices) : .readOnly(choices.selectedTitle ?? value) + case .combo(let options): + return field.isEditable ? .comboBox(StructureInspectorChoices(options: options, current: value)) : .readOnly(value) + } + } +} + +// A grid menu as a pop-up button's items. The value a field holds is always an item, listed or not, since a pop-up +// whose selection matches no tag shows nothing selected. +struct StructureInspectorChoices: Equatable { + struct Item: Equatable, Identifiable { + let id: Int + let title: String + let value: String + } + + struct ItemGroup: Equatable, Identifiable { + let id: Int + let header: String? + var items: [Item] + } + + let groups: [ItemGroup] + let selectedID: Int? + + var items: [Item] { groups.flatMap(\.items) } + + var selectedTitle: String? { + items.first { $0.id == selectedID }?.title + } + + func value(of id: Int) -> String? { + items.first { $0.id == id }?.value + } + + init(options: [GridMenuOption], current: String) { + var groups = [ItemGroup(id: options.count + 1, header: nil, items: [])] + for (index, option) in options.enumerated() { + switch option { + case .sectionHeader(let title): + groups.append(ItemGroup(id: index, header: title, items: [])) + case .value(let title, let sql): + groups[groups.count - 1].items.append(Item(id: index, title: title, value: sql)) + case .clear(let title): + groups[groups.count - 1].items.append(Item(id: index, title: title, value: "")) + case .custom: + continue + } + } + let listed = groups.flatMap(\.items) + var selected = listed.first { $0.value == current }?.id + ?? listed.first { $0.value.caseInsensitiveCompare(current) == .orderedSame }?.id + if selected == nil, !current.isEmpty { + let kept = Item(id: options.count, title: current, value: current) + groups[0].items.insert(kept, at: 0) + selected = kept.id + } + self.groups = groups.filter { $0.header != nil || !$0.items.isEmpty } + selectedID = selected + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureInspectorView.swift b/apple/TablePro/Views/Structure/Inspector/StructureInspectorView.swift new file mode 100644 index 0000000000..6a71080f21 --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureInspectorView.swift @@ -0,0 +1,89 @@ +// +// StructureInspectorView.swift +// TablePro +// + +import SwiftUI + +// The window inspector while a Structure view or a Create Table tab is on screen: the selected definition's form, +// or the table's own properties when nothing is selected. +struct StructureInspectorView: View { + let source: StructureInspectorSource + let tableMetadata: TableMetadata? + + var body: some View { + switch source { + case .table(let session): + StructureInspectorContent(editor: session.editor, tableName: session.tableName) { + StructureTablePropertiesView( + session: session, + changeManager: session.changeManager, + tableMetadata: tableMetadata + ) + } + case .createTable(let draft): + CreateTableInspectorContent(draft: draft) + } + } +} + +private struct CreateTableInspectorContent: View { + @ObservedObject var draft: CreateTableDraft + + var body: some View { + StructureInspectorContent(editor: draft.editor, tableName: draft.tableName) { + CreateTablePropertiesView(draft: draft) + } + } +} + +private struct StructureInspectorContent: View { + @ObservedObject var editor: StructureDefinitionEditor + let tableName: String + @ViewBuilder let tableProperties: () -> TableProperties + + var body: some View { + let target = editor.inspectorTarget + let form = selectedForm(target) + VStack(spacing: 0) { + InspectorSubjectView( + subject: StructureInspectorPresentation.subject( + for: target, + definitionName: form?.name, + tableName: tableName + ) + ) + content(target: target, form: form) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("structure-inspector") + } + + private func selectedForm(_ target: StructureDefinitionEditor.InspectorTarget) -> StructureInspectorForm? { + guard case .entity(let section, let id) = target else { return nil } + return editor.inspectorForm(for: section, id: id) + } + + @ViewBuilder + private func content( + target: StructureDefinitionEditor.InspectorTarget, + form: StructureInspectorForm? + ) -> some View { + switch target { + case .table: + tableProperties() + case .entity: + if let form { + StructureDefinitionFormView(editor: editor, form: form, revision: editor.revision) + .id(form.entityID) + } + case .several(let section, let count): + UnavailableStateView( + StructureInspectorPresentation.severalTitle(section, count: count), + systemImage: TrailingPaneSurface.inspector.symbolName, + description: Text(String(localized: "Select one to edit it")) + ) + } + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureTablePropertiesView.swift b/apple/TablePro/Views/Structure/Inspector/StructureTablePropertiesView.swift new file mode 100644 index 0000000000..1cfce1caf4 --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureTablePropertiesView.swift @@ -0,0 +1,439 @@ +// +// StructureTablePropertiesView.swift +// TablePro +// + +import SwiftUI + +// What the inspector shows for a table with no definition selected: its comment, its default character set and +// AUTO_INCREMENT counter, and the read-only statistics. +struct StructureTablePropertiesView: View { + @ObservedObject var session: StructureEditingSession + @ObservedObject var changeManager: StructureChangeManager + let tableMetadata: TableMetadata? + + // A session-bound table belongs to one connection's session, so its definition is not this window's to change. + private var isReadOnly: Bool { + session.editor.isReadOnly || session.isSessionBound + } + + var body: some View { + let offersComment = session.offersTableComment + let offersOptions = session.offersTableOptionsTab + if !offersComment, !offersOptions, tableMetadata == nil { + UnavailableStateView( + String(localized: "No Selection"), + systemImage: TrailingPaneSurface.inspector.symbolName, + description: Text(String(localized: "Select a definition to edit it")) + ) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if offersComment { + StructureTableCommentSection( + session: session, changeManager: changeManager, isReadOnly: isReadOnly + ) + } + if offersOptions { + StructureTableOptionsSection( + session: session, changeManager: changeManager, isReadOnly: isReadOnly + ) + } + if let tableMetadata { + TableInfoSections(metadata: tableMetadata, includesComment: false) + } + } + .padding(.horizontal, InspectorMetrics.horizontalInset) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.subheadline) + .controlSize(.small) + } + } +} + +// MARK: - Comment + +private struct StructureTableCommentSection: View { + @ObservedObject var session: StructureEditingSession + @ObservedObject var changeManager: StructureChangeManager + let isReadOnly: Bool + + @FocusState private var isFocused: Bool + + private var field: StructureTableCommentPolicy.Field? { + StructureTableCommentPolicy.resolve( + objectKind: session.objectKind, + support: DatabaseObjectToolEligibility.Support.of(DatabaseManager.shared.driver(for: session.connection.id)), + isReadOnly: isReadOnly, + load: session.tableComment.erased, + isSaving: changeManager.isHeldForSave || session.editor.isAwaitingBaseline + ) + } + + // One model write per keystroke, so a save from the keyboard never misses what is still in the field. + private var text: Binding { + Binding( + get: { changeManager.tableComment.text }, + set: { changeManager.stageTableComment($0) } + ) + } + + var body: some View { + if let field { + VStack(alignment: .leading, spacing: 6) { + StructureInspectorSectionHeader(title: String(localized: "Table")) + VStack(alignment: .leading, spacing: 10) { + StructureInspectorRow( + label: String(localized: "Comment"), + isModified: changeManager.tableComment.hasChanges + ) { + TextField(field.placeholder, text: text, axis: .vertical) + .lineLimit(2...6) + .textFieldStyle(.squareBorder) + .disabled(!field.isEditable) + .focused($isFocused) + .onSubmit { changeManager.endTableCommentRun() } + .help(field.unavailableReason ?? "") + .accessibilityLabel(String(localized: "Comment")) + .accessibilityIdentifier("structure-table-comment") + } + } + } + .onChange(of: isFocused) { focused in + guard !focused else { return } + changeManager.endTableCommentRun() + } + .onDisappear { changeManager.endTableCommentRun() } + } + } +} + +// MARK: - Options + +// Every edit is one whole-value write to the change manager, so the coupled character set and collation pick +// lands as one staged change and one undo step. +private struct StructureTableOptionsSection: View { + @ObservedObject var session: StructureEditingSession + @ObservedObject var changeManager: StructureChangeManager + let isReadOnly: Bool + + @State private var counterText = "" + @FocusState private var isCounterFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + StructureInspectorSectionHeader(title: String(localized: "Options")) + if let options = displayedOptions { + form(options) + } else { + stateView + } + } + .task { await session.readTableOptionsCatalog() } + .onAppear { counterText = Self.text(displayedCounter) } + .onChange(of: displayedCounter) { counter in + guard !isCounterFocused else { return } + counterText = Self.text(counter) + } + // Text that is not a valid counter stays in the field beside its error, since it holds Save until fixed. + .onChange(of: isCounterFocused) { focused in + guard !focused, entry(counterText)?.message == nil else { return } + counterText = Self.text(displayedCounter) + } + } + + // MARK: State + + private var draft: TableOptions? { changeManager.tableOptions.current } + + // A read that landed while edits were staged has not become the baseline yet, so it shows read-only. + private var displayedOptions: TableOptions? { + draft ?? (session.tableOptions.value ?? nil) + } + + private var isCounterConfirmed: Bool { + session.autoIncrementRead == .confirmed && changeManager.tableOptions.isAutoIncrementConfirmed + } + + // An unconfirmed counter may be one the server moved past; a read deferred behind staged edits shows read-only. + private var displayedCounter: UInt64? { + guard session.autoIncrementRead == .confirmed else { return nil } + guard changeManager.tableOptions.isAutoIncrementConfirmed else { return session.loadedTableOptions?.autoIncrement } + return displayedOptions?.autoIncrement + } + + // The draft's column, not the loaded one: the counter is set after the column work in the same save. + private var autoIncrementMaximum: UInt64? { + changeManager.autoIncrementColumn.map { AutoIncrementEntry.maximum(for: $0) } + } + + private var editability: StructureTableOptionsPolicy.Editability { + StructureTableOptionsPolicy.resolve( + hasBaseline: draft != nil, + isReadOnly: isReadOnly, + isSaving: changeManager.isHeldForSave || session.editor.isAwaitingBaseline + ) + } + + private var catalog: TableOptionsCatalog? { + guard case .loaded(let catalog) = session.tableOptionsCatalog else { return nil } + return catalog + } + + private func entry(_ text: String) -> AutoIncrementEntry? { + autoIncrementMaximum.map { + AutoIncrementEntry(text, maximum: $0, original: changeManager.tableOptions.original?.autoIncrement) + } + } + + private static func text(_ counter: UInt64?) -> String { + counter.map { String($0) } ?? "" + } + + // A retried read lands only in the session; the baseline moves unless edits are staged. + private func retryOptions() { + Task { + await session.readTableOptions() + session.rebaselineUnlessEdited() + } + } + + @ViewBuilder + private var stateView: some View { + switch session.tableOptions { + case .idle, .loading: + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, alignment: .leading) + case .failed(let message): + VStack(alignment: .leading, spacing: 6) { + Label(String(localized: "Couldn't read the table options"), systemImage: "exclamationmark.triangle") + RevealedTextView(message) + .foregroundStyle(.secondary) + Button(String(localized: "Try Again"), action: retryOptions) + .accessibilityIdentifier("structure-options-retry") + } + case .loaded: + StructureInspectorFootnote(text: String(localized: "This table has no options to change.")) + } + } + + // MARK: Form + + private func form(_ options: TableOptions) -> some View { + let editability = self.editability + let original = changeManager.tableOptions.original + return VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { + StructureInspectorRow( + label: String(localized: "Character Set"), + isModified: draft != nil && options.characterSet != original?.characterSet + ) { + Picker(String(localized: "Character Set"), selection: characterSetBinding) { + if options.characterSet == nil { + Text(String(localized: "Unknown")).tag(String?.none) + } + ForEach(TableOptionsForm.characterSets(in: catalog, keeping: options.characterSet), id: \.self) { + Text($0).tag(Optional($0)) + } + } + .labelsHidden() + .disabled(!editability.isEditable || catalog == nil) + .accessibilityIdentifier("structure-options-charset") + } + + StructureInspectorRow( + label: String(localized: "Collation"), + isModified: draft != nil && options.collation != original?.collation + ) { + Picker(String(localized: "Collation"), selection: collationBinding) { + if options.collation == nil { + Text(String(localized: "Unknown")).tag(String?.none) + } + ForEach(collationChoices(options)) { choice in + Text(choice.title).tag(Optional(choice.name)) + } + } + .labelsHidden() + .disabled(!editability.isEditable || catalog == nil) + .accessibilityIdentifier("structure-options-collation") + } + + Group { + VStack(alignment: .leading, spacing: 4) { + Toggle(String(localized: "Convert existing columns"), isOn: convertBinding) + .toggleStyle(.checkbox) + .fontWeight(options.convertsColumns ? .semibold : .regular) + .disabled(!editability.isEditable) + .accessibilityIdentifier("structure-options-convert") + StructureInspectorFootnote(text: Self.convertFootnote(options.convertsColumns)) + } + } + + catalogStatusRow + + if let maximum = autoIncrementMaximum { + counterRows(maximum: maximum, isEditable: editability.isEditable, original: original) + } + } + + if let reason = editability.unavailableReason, !changeManager.isHeldForSave { + Label(reason, systemImage: "lock") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("structure-options-unavailable-note") + } + } + } + + private static func convertFootnote(_ converts: Bool) -> String { + guard converts else { + return String(localized: "Applies to columns added later. Existing columns keep their character set.") + } + return String( + localized: "Rewrites every text column to this character set. TEXT can grow to MEDIUMTEXT, and the table is copied while it runs." + ) + } + + private func collationChoices(_ options: TableOptions) -> [TableOptionsForm.Choice] { + TableOptionsForm.collations(in: catalog, characterSet: options.characterSet, keeping: options.collation) + } + + @ViewBuilder + private var catalogStatusRow: some View { + switch session.tableOptionsCatalog { + case .idle, .loading: + Group { + HStack(spacing: 6) { + ProgressView() + .controlSize(.mini) + StructureInspectorFootnote(text: String(localized: "Reading the server's character sets…")) + } + } + case .failed: + Group { + VStack(alignment: .leading, spacing: 4) { + Label(String(localized: "Couldn't read the server's character sets."), systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button(String(localized: "Try Again")) { + Task { await session.readTableOptionsCatalog() } + } + .accessibilityIdentifier("structure-options-catalog-retry") + } + } + case .loaded: + EmptyView() + } + } + + @ViewBuilder + private func counterRows(maximum: UInt64, isEditable: Bool, original: TableOptions?) -> some View { + StructureInspectorRow( + label: String(localized: "Next Auto Increment"), + isModified: draft != nil && draft?.autoIncrement != original?.autoIncrement + ) { + TextField(String(localized: "Next Auto Increment"), text: counterBinding) + .labelsHidden() + .textFieldStyle(.squareBorder) + .focused($isCounterFocused) + .disabled(!isEditable || !isCounterConfirmed) + .accessibilityLabel(String(localized: "Next Auto Increment")) + .accessibilityIdentifier("structure-options-auto-increment") + } + // The server raises a value below the next free one, so a saved counter is read back, never shown as typed. + switch session.autoIncrementRead { + case .rereading: + Group { + HStack(spacing: 6) { + ProgressView() + .controlSize(.mini) + StructureInspectorFootnote(text: String(localized: "Reading the saved value…")) + } + } + case .unconfirmed: + Group { + VStack(alignment: .leading, spacing: 4) { + Label(String(localized: "Couldn't read the saved value."), systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + Button(String(localized: "Try Again"), action: retryOptions) + .accessibilityIdentifier("structure-options-auto-increment-retry") + } + } + case .confirmed: + EmptyView() + } + if let message = AutoIncrementEntry(counterText, maximum: maximum, original: original?.autoIncrement).message { + Group { + Text(message) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("structure-options-auto-increment-error") + } + } + Group { + StructureInspectorFootnote(text: String(localized: "A value at or below the highest existing ID becomes that ID plus one.")) + } + } + + // MARK: Bindings + + private var characterSetBinding: Binding { + Binding( + get: { displayedOptions?.characterSet }, + set: { name in + guard let name, let current = draft, name != current.characterSet else { return } + changeManager.stageTableOptions(TableOptionsForm.selectingCharacterSet(name, in: current, catalog: catalog)) + } + ) + } + + private var collationBinding: Binding { + Binding( + get: { displayedOptions?.collation }, + set: { name in + guard let name, var next = draft, name != next.collation else { return } + next.collation = name + changeManager.stageTableOptions(next) + } + ) + } + + private var convertBinding: Binding { + Binding( + get: { displayedOptions?.convertsColumns ?? false }, + set: { converts in + guard var next = draft, converts != next.convertsColumns else { return } + next.convertsColumns = converts + changeManager.stageTableOptions(next) + } + ) + } + + // Staged per keystroke, as the comment is, so a save from the keyboard never misses a value still in the field. + // Text that is not a valid counter stages the loaded one and holds Save until it is fixed or cleared. + private var counterBinding: Binding { + Binding( + get: { counterText }, + set: { text in + counterText = text + guard var next = draft, let entry = entry(text) else { return } + switch entry { + case .value(let value): + next.autoIncrement = value + case .empty, .invalid: + next.autoIncrement = changeManager.tableOptions.original?.autoIncrement + } + changeManager.noteAutoIncrementEntry(invalid: entry.message) + guard next != draft else { return } + changeManager.stageTableOptions(next) + } + ) + } +} diff --git a/apple/TablePro/Views/Structure/StructureColumnReorderHandler.swift b/apple/TablePro/Views/Structure/StructureColumnReorderHandler.swift index 235acd1457..a2121b2986 100644 --- a/apple/TablePro/Views/Structure/StructureColumnReorderHandler.swift +++ b/apple/TablePro/Views/Structure/StructureColumnReorderHandler.swift @@ -25,7 +25,7 @@ enum StructureColumnReorderHandler { return String( localized: """ Could not build the column reorder for this table. If this engine's driver \ - was installed before column reorder shipped, update it in Settings > Plugins. + was installed before column reorder shipped, update it in Settings > Extensions. """ ) } diff --git a/apple/TablePro/Views/Structure/StructureDDLPane.swift b/apple/TablePro/Views/Structure/StructureDDLPane.swift new file mode 100644 index 0000000000..c6b0e7a544 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureDDLPane.swift @@ -0,0 +1,133 @@ +// +// StructureDDLPane.swift +// TablePro +// + +import AppKit +import os +import SwiftUI +import UniformTypeIdentifiers + +// The table's CREATE statement as the database reports it, read-only, with the ways to take it elsewhere. +struct StructureDDLPane: View { + private static let logger = Logger(subsystem: "com.TablePro", category: "StructureDDLPane") + + let ddl: String + let tableName: String + let databaseType: DatabaseType + let failure: String? + let isLoading: Bool + let coordinator: MainContentCoordinator? + let onRetry: () -> Void + + @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) private var fontSize: Double = 13 + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + ControlGroup { + Button { + fontSize = max(10, fontSize - 1) + } label: { + Label(String(localized: "Decrease Font Size"), systemImage: "textformat.size.smaller") + } + Button { + fontSize = min(24, fontSize + 1) + } label: { + Label(String(localized: "Increase Font Size"), systemImage: "textformat.size.larger") + } + } + .controlGroupStyle(.navigation) + .labelStyle(.iconOnly) + .fixedSize() + + Spacer() + + Button(String(localized: "Open in Editor"), action: openInEditor) + .disabled(ddl.isEmpty) + Button(String(localized: "Copy"), action: copy) + .disabled(ddl.isEmpty) + Button(String(localized: "Export…"), action: export) + .disabled(ddl.isEmpty) + } + .controlSize(.small) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .paneBarBackground() + + Divider() + + content + } + } + + @ViewBuilder + private var content: some View { + if let failure, ddl.isEmpty { + UnavailableStateView { + Label(String(localized: "Couldn't Read the DDL"), systemImage: "exclamationmark.triangle") + } description: { + Text(failure) + } actions: { + Button(String(localized: "Try Again"), action: onRetry) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .valueStandInBackground() + } else if ddl.isEmpty { + StructureDDLStandIn(isLoading: isLoading) + } else { + DDLTextView(ddl: ddl, fontSize: $fontSize, databaseType: databaseType) + } + } + + private func openInEditor() { + guard !ddl.isEmpty else { return } + coordinator?.tabManager.addTab(initialQuery: ddl, title: String(format: String(localized: "%@ DDL"), tableName)) + } + + private func copy() { + ClipboardService.shared.writeText(ddl) + } + + private func export() { + let savePanel = NSSavePanel() + if let sqlType = UTType(filenameExtension: "sql") { + savePanel.allowedContentTypes = [sqlType] + } + savePanel.nameFieldStringValue = "\(tableName).sql" + + guard let window = coordinator?.contentWindow else { return } + let ddl = ddl + savePanel.beginSheetModal(for: window) { response in + guard response == .OK, let url = savePanel.url else { return } + do { + try ddl.write(to: url, atomically: true, encoding: .utf8) + } catch { + Self.logger.error("Failed to export: \(error.publicLogShape, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Could not export the schema"), + message: error.localizedDescription, + window: window + ) + } + } + } +} + +// Where the statement would be, on the editor's background. +struct StructureDDLStandIn: View { + var isLoading = false + + var body: some View { + Group { + if isLoading { + ProgressView().controlSize(.small) + } else { + Text(String(localized: "No DDL Available")) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .valueStandInBackground() + } +} diff --git a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift new file mode 100644 index 0000000000..545560b2fc --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift @@ -0,0 +1,360 @@ +// +// StructureDefinitionEditor.swift +// TablePro +// + +import Combine +import Foundation +import TableProPluginKit + +// The Definition page's model, shared by the Structure tab and Create Table: one grid per section over one change +// manager, one filter, and what the inspector shows. A row selected in one section clears the others, so the +// inspector always names one thing. +@MainActor +final class StructureDefinitionEditor: ObservableObject { + enum InspectorTarget: Equatable { + case table + case entity(StructureSection, UUID) + case several(StructureSection, Int) + } + + let manager: StructureChangeManager + let context: StructureEditingContext + let referenceMenus: ForeignKeyReferenceMenus + private(set) var adapters: [StructureSection: StructureSectionAdapter] = [:] + + @Published var searchText = "" { + didSet { + guard searchText != oldValue else { return } + refresh() + } + } + @Published private(set) var inspectorTarget: InspectorTarget = .table + @Published private(set) var collapsedSections: Set + @Published var serverSupport = StructureServerSupport.unrestricted { + didSet { + guard serverSupport != oldValue else { return } + // A section the server withdraws leaves no selection behind for the inspector or the commands. + for section in StructureSection.allCases where !offers(section) { + adapter(for: section).model.clearSelection() + } + refresh() + } + } + @Published var isReadOnly = false { + didSet { + guard isReadOnly != oldValue else { return } + refresh() + } + } + // After a save, until the table is read again: an edit made now would stage against what the save replaced. + @Published var isAwaitingBaseline = false { + didSet { + guard isAwaitingBaseline != oldValue else { return } + refresh() + } + } + @Published private(set) var revision = 0 + + var readOnlyReason: String? + + weak var coordinator: MainContentCoordinator? + var moveColumnHandler: ((Int, Int) -> Void)? + var characterSetContextProvider: () -> ColumnCharacterSetMenus.Context = { + ColumnCharacterSetMenus.Context(catalog: .idle, tableCharacterSet: nil) + } + var characterSetCatalogReader: () -> Void = {} + + private let defaults: UserDefaults + private var cancellables: Set = [] + private var isSyncingSelection = false + + init( + manager: StructureChangeManager, + context: StructureEditingContext, + defaults: UserDefaults = AppStorageEnvironment.shared.defaults + ) { + self.manager = manager + self.context = context + self.defaults = defaults + let menus = ForeignKeyReferenceMenus(connectionId: context.connection.id, databaseType: context.databaseType) + menus.schemaName = context.schemaName + referenceMenus = menus + let stored = defaults.stringArray(forKey: Self.collapsedSectionsKey) ?? [] + collapsedSections = Set(stored.compactMap(StructureSection.init(rawValue:))) + for section in StructureSection.allCases { + let adapter = StructureSectionAdapter( + section: section, manager: manager, context: context, referenceMenus: menus, defaults: defaults + ) + adapters[section] = adapter + adapter.editor = self + adapter.model.$selectionRevision + .dropFirst() + .sink { [weak self] _ in self?.selectionDidChange(in: section) } + .store(in: &cancellables) + } + menus.onListsChanged = { [weak self] in self?.revision &+= 1 } + manager.$reloadVersion + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.refresh() } + .store(in: &cancellables) + // A save holds every edit while it runs; the grids close on the hold and open again when it ends. + manager.$heldSave + .map { $0 != nil } + .removeDuplicates() + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.refresh() } + .store(in: &cancellables) + } + + static let collapsedSectionsKey = "structure.collapsedSections" + + // MARK: - Sections + + var sections: [StructureSection] { + StructureSection.allCases.filter(offers) + } + + func offers(_ section: StructureSection) -> Bool { + switch section { + case .columns, .indexes: + return true + case .foreignKeys: + return context.databaseType.supportsForeignKeys + case .checkConstraints: + guard serverSupport.offers(.checkConstraints) else { return false } + guard !context.isCreatingTable else { return context.allows(.addCheckConstraint) } + return context.databaseType.supportsCheckConstraints + } + } + + func adapter(for section: StructureSection) -> StructureSectionAdapter { + guard let adapter = adapters[section] else { + preconditionFailure("Every section has an adapter") + } + return adapter + } + + func isExpanded(_ section: StructureSection) -> Bool { + !collapsedSections.contains(section) || !searchText.isEmpty + } + + func setExpanded(_ section: StructureSection, _ isExpanded: Bool) { + if isExpanded { + collapsedSections.remove(section) + } else { + collapsedSections.insert(section) + adapter(for: section).model.clearSelection() + } + defaults.set(collapsedSections.map(\.rawValue).sorted(), forKey: Self.collapsedSectionsKey) + } + + func count(of section: StructureSection) -> Int { + let ids = StructureGridEdits.entityIDs(in: section, manager: manager) + return ids.indices.filter { !manager.deleteInsertState(for: $0, tab: section).isDeleted }.count + } + + var characterSetContext: ColumnCharacterSetMenus.Context { characterSetContextProvider() } + + func readCharacterSetCatalogIfNeeded() { + characterSetCatalogReader() + } + + // MARK: - Refresh + + func refresh() { + for section in StructureSection.allCases { + adapter(for: section).refresh() + } + settleInspectorTarget() + revision &+= 1 + } + + func definitionsDidChange() { + refresh() + } + + // One field of one definition changed, which no other section shows: the cheap refresh a keystroke can afford. + func definitionsDidChange(in section: StructureSection) { + adapter(for: section).refresh() + settleInspectorTarget() + revision &+= 1 + } + + // MARK: - Selection + + var focusedSection: StructureSection? { + switch inspectorTarget { + case .table: nil + case .entity(let section, _), .several(let section, _): section + } + } + + var focusedModel: DataGridModel? { + focusedSection.map { adapter(for: $0).model } + } + + private func selectionDidChange(in section: StructureSection) { + guard !isSyncingSelection else { return } + let model = adapter(for: section).model + if !model.selection.isEmpty { + isSyncingSelection = true + for other in StructureSection.allCases where other != section { + adapter(for: other).model.clearSelection() + } + isSyncingSelection = false + } + settleInspectorTarget() + } + + private func settleInspectorTarget() { + var target = InspectorTarget.table + for section in StructureSection.allCases { + let ids = adapter(for: section).selectedEntityIDs + guard !ids.isEmpty else { continue } + target = ids.count == 1 ? .entity(section, ids[0]) : .several(section, ids.count) + break + } + if target != inspectorTarget { + inspectorTarget = target + } + } + + func clearSelection() { + for section in StructureSection.allCases { + adapter(for: section).model.clearSelection() + } + } + + // A filter that hides the definition is cleared first, or the selection would land on nothing. + func reveal(section: StructureSection, entity id: UUID) { + if collapsedSections.contains(section) { + setExpanded(section, true) + } + let adapter = adapter(for: section) + if adapter.model.displayIndex(of: adapter.rowID(forEntity: id)) == nil { + if !searchText.isEmpty { searchText = "" } + if adapter.store.valueFilter.isActive { adapter.model.setValueFilter(GridValueFilterState()) } + } + adapter.select(entity: id) + // The section may have just expanded, so its grid scrolls the page once it is on screen. + let rowID = adapter.rowID(forEntity: id) + DispatchQueue.main.async { adapter.model.reveal(rowID: rowID) } + } + + // MARK: - Commands + + func add(_ section: StructureSection) { + adapter(for: section).addRow() + } + + func canAdd(_ section: StructureSection) -> Bool { + let adapter = adapter(for: section) + return adapter.canEdit && adapter.canAdd + } + + func canRemoveSelection(in section: StructureSection) -> Bool { + let adapter = adapter(for: section) + return adapter.canEdit && adapter.canRemove && !adapter.model.selection.isEmpty + } + + func removeSelection(in section: StructureSection) { + let adapter = adapter(for: section) + let rowIDs = adapter.model.selection.affectedRows.compactMap { adapter.model.rowID(at: $0) } + adapter.deleteRows(rowIDs) + } + + func refusal(adding section: StructureSection) -> String? { + let adapter = adapter(for: section) + if let reason = adapter.editRefusal { return reason } + return context.refusal(context.addOperation(for: section)) + } + + // The section the menu bar's row commands act on: the one holding the selection, else Columns. + var commandSection: StructureSection { focusedSection ?? .columns } + + func selectedRowIDs(in section: StructureSection) -> [RowID] { + let model = adapter(for: section).model + return model.selection.affectedRows.compactMap { model.rowID(at: $0) } + } + + var canDuplicateSelection: Bool { + guard let section = focusedSection else { return false } + return canAdd(section) && !selectedRowIDs(in: section).isEmpty + } + + func duplicateSelection() { + guard canDuplicateSelection, let section = focusedSection else { return } + adapter(for: section).duplicateRows(selectedRowIDs(in: section)) + } + + func copySelection() { + guard let section = focusedSection else { return } + let rowIDs = selectedRowIDs(in: section) + guard !rowIDs.isEmpty else { return } + adapter(for: section).copyRows(rowIDs) + } + + var canPaste: Bool { + canAdd(commandSection) && StructurePasteboard.canPaste + } + + func paste() { + guard canPaste else { return } + adapter(for: commandSection).pasteRows() + } + + func moveColumn(from sourceIndex: Int, to destination: Int) { + moveColumnHandler?(sourceIndex, destination) + } + + // MARK: - Open edits + + // Commits an edit open in a grid cell, and ends a run of typing in the inspector, before a command acts. + func commitPendingEdits() { + manager.endTypingRun() + for section in StructureSection.allCases { + adapter(for: section).model.commitEditing() + } + } + + // MARK: - Inspector + + func entity(_ section: StructureSection, id: UUID) -> Int? { + StructureGridEdits.sourceIndex(of: id, in: section, manager: manager) + } + + func inspectorForm(for section: StructureSection, id: UUID) -> StructureInspectorForm? { + let adapter = adapter(for: section) + guard let sourceIndex = entity(section, id: id) else { return nil } + return StructureInspectorForm.build(adapter: adapter, sourceIndex: sourceIndex, entityID: id) + } + + // A pick, a checkbox or a menu choice: one change, one undo step. + func commitInspectorField(section: StructureSection, id: UUID, field: Int, value: String) { + manager.endTypingRun() + adapter(for: section).applyEdit(entity: id, field: field, text: value) + } + + // A keystroke in an inspector field, staged at once and undone with the rest of its run. + func typeInspectorField(section: StructureSection, id: UUID, field: Int, value: String) { + manager.stageTyping(in: "\(section.rawValue).\(id.uuidString).\(field)", actionName: Self.editActionName(section)) { + adapter(for: section).applyEdit(entity: id, field: field, text: value) + } + } + + func endInspectorTyping() { + manager.endTypingRun() + } + + private static func editActionName(_ section: StructureSection) -> String { + switch section { + case .columns: String(localized: "Edit Column") + case .indexes: String(localized: "Edit Index") + case .foreignKeys: String(localized: "Edit Foreign Key") + case .checkConstraints: String(localized: "Edit Check Constraint") + } + } +} diff --git a/apple/TablePro/Views/Structure/StructureDefinitionPage.swift b/apple/TablePro/Views/Structure/StructureDefinitionPage.swift new file mode 100644 index 0000000000..542df40dd0 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureDefinitionPage.swift @@ -0,0 +1,242 @@ +// +// StructureDefinitionPage.swift +// TablePro +// + +import AppKit +import SwiftUI + +// Columns, indexes, foreign keys and check constraints on one scrolling page, each section with its own add and +// remove beneath it, the way Xcode's model editor stacks an entity's parts. The selected definition's full form is +// in the inspector. +struct StructureDefinitionPage: View { + @ObservedObject var editor: StructureDefinitionEditor + let loadState: StructureLoadState + var isLoading = false + var notes: [StructureSection: [StructureSectionNote]] = [:] + var header: AnyView? + var onRetry: (StructureSection) -> Void = { _ in } + + var body: some View { + VStack(spacing: 0) { + filterBar + Divider() + if isLoading { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() + } else { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 0) { + if let header { + header + } + ForEach(editor.sections, id: \.self) { section in + StructureSectionView( + section: section, + editor: editor, + model: editor.adapter(for: section).model, + failure: loadState.failure(StructureLoadState.Item(section)), + hasData: editor.context.isCreatingTable || loadState.hasData(StructureLoadState.Item(section)), + notes: notes[section] ?? [], + onRetry: { onRetry(section) } + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .gridStandInBackground() + } + } + } + + private var filterBar: some View { + NativeSearchField( + text: $editor.searchText, + placeholder: String(localized: "Filter"), + accessibilityIdentifier: "structure-filter" + ) + .padding(.horizontal, 8) + .padding(.vertical, 6) + } +} + +struct StructureSectionNote: Equatable, Identifiable { + let systemImage: String + let text: String + let identifier: String + + var id: String { identifier } +} + +struct StructureSectionView: View { + let section: StructureSection + @ObservedObject var editor: StructureDefinitionEditor + @ObservedObject var model: DataGridModel + let failure: String? + let hasData: Bool + let notes: [StructureSectionNote] + let onRetry: () -> Void + + private var adapter: StructureSectionAdapter { editor.adapter(for: section) } + private var isExpanded: Bool { editor.isExpanded(section) } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + sectionHeader + if isExpanded { + content + } + Divider() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("structure-section-\(section.rawValue)") + } + + private var sectionHeader: some View { + HStack(spacing: 6) { + DisclosureTriangle( + isExpanded: Binding( + get: { isExpanded }, + set: { editor.setExpanded(section, $0) } + ), + label: section.displayName + ) + Text(section.displayName) + .font(.headline) + if hasData { + Text(editor.count(of: section), format: .number) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.top, 10) + .padding(.bottom, 6) + .contentShape(Rectangle()) + .onTapGesture { editor.setExpanded(section, !isExpanded) } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + } + + @ViewBuilder + private var content: some View { + if let failure, !hasData { + failureRow(failure) + } else { + if let failure { + failureRow(failure) + } + if adapter.visibleRowCount > 0 { + DataGridView(model: model) + } else { + emptyRow + } + ForEach(notes) { note in + Label(note.text, systemImage: note.systemImage) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 10) + .padding(.top, 6) + .accessibilityIdentifier(note.identifier) + } + footer + } + } + + private var emptyRow: some View { + Text(emptyText) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + + private var emptyText: String { + guard editor.searchText.isEmpty else { return String(localized: "No Matches") } + switch section { + case .columns: return String(localized: "No Columns") + case .indexes: return String(localized: "No Indexes") + case .foreignKeys: return String(localized: "No Foreign Keys") + case .checkConstraints: return String(localized: "No Check Constraints") + } + } + + private func failureRow(_ message: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(message) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + Spacer(minLength: 0) + Button(String(localized: "Try Again"), action: onRetry) + .controlSize(.small) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + + private var footer: some View { + let labels = StructureFooterPolicy.labels(for: section) + let reason = editor.refusal(adding: section) + return AddRemoveControlGroup( + addLabel: labels.add, + removeLabel: labels.remove, + canAdd: editor.canAdd(section), + canRemove: editor.canRemoveSelection(in: section), + addHelp: reason, + removeHelp: reason, + addIdentifier: "structure-\(section.rawValue)-add", + removeIdentifier: "structure-\(section.rawValue)-remove", + onAdd: { editor.add(section) }, + onRemove: { editor.removeSelection(in: section) } + ) + .padding(.horizontal, 10) + .padding(.top, 6) + .padding(.bottom, 10) + } +} + +// The stock disclosure triangle, the control the HIG names for showing and hiding a section's details. +struct DisclosureTriangle: NSViewRepresentable { + @Binding var isExpanded: Bool + let label: String + + func makeNSView(context: Context) -> NSButton { + let button = NSButton(title: "", target: context.coordinator, action: #selector(Coordinator.toggle(_:))) + button.bezelStyle = .disclosure + button.setButtonType(.pushOnPushOff) + button.imagePosition = .imageOnly + button.setContentHuggingPriority(.required, for: .horizontal) + return button + } + + func updateNSView(_ button: NSButton, context: Context) { + context.coordinator.parent = self + button.state = isExpanded ? .on : .off + button.setAccessibilityLabel(label) + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + @MainActor + final class Coordinator: NSObject { + var parent: DisclosureTriangle + + init(parent: DisclosureTriangle) { + self.parent = parent + } + + @objc func toggle(_ sender: NSButton) { + parent.isExpanded = sender.state == .on + } + } +} diff --git a/apple/TablePro/Views/Structure/StructureEditGate.swift b/apple/TablePro/Views/Structure/StructureEditGate.swift index 07a706f33d..c8ac61e248 100644 --- a/apple/TablePro/Views/Structure/StructureEditGate.swift +++ b/apple/TablePro/Views/Structure/StructureEditGate.swift @@ -117,21 +117,18 @@ struct StructureEditGate { && TriggerEditEligibility.kindAcceptsTriggers(objectKind) } - func locksField(at index: Int, on tab: StructureTab, orderedFields: [StructureColumnField]) -> Bool { + func locksField(at index: Int, on tab: StructureSection, orderedFields: [StructureColumnField]) -> Bool { switch tab { case .columns: guard orderedFields.indices.contains(index) else { return false } return !editableColumnFields.contains(orderedFields[index]) case .indexes, .foreignKeys, .checkConstraints: - guard let adding = StructureFooterPolicy.operation(forAdding: tab) else { return false } - return !allows(adding) - case .ddl, .parts, .triggers: - return false + return !allows(StructureFooterPolicy.operation(forAdding: tab)) } } func lockedFieldIndices( - on tab: StructureTab, + on tab: StructureSection, orderedFields: [StructureColumnField], fieldCount: Int ) -> Set { diff --git a/apple/TablePro/Views/Structure/StructureEditingContext.swift b/apple/TablePro/Views/Structure/StructureEditingContext.swift new file mode 100644 index 0000000000..34aa6bf230 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureEditingContext.swift @@ -0,0 +1,97 @@ +// +// StructureEditingContext.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +// The table a structure editor works on: an existing one, or the one Create Table is drafting. +@MainActor +struct StructureEditingContext { + let connection: DatabaseConnection + let databaseName: String + let schemaName: String? + let tableName: String + let objectKind: TableInfo.TableType + let isSessionBound: Bool + let isCreatingTable: Bool + + static func existingTable( + connection: DatabaseConnection, + databaseName: String, + schemaName: String?, + tableName: String, + objectKind: TableInfo.TableType, + isSessionBound: Bool + ) -> StructureEditingContext { + StructureEditingContext( + connection: connection, + databaseName: databaseName, + schemaName: schemaName, + tableName: tableName, + objectKind: objectKind, + isSessionBound: isSessionBound, + isCreatingTable: false + ) + } + + static func newTable(connection: DatabaseConnection, databaseName: String, schemaName: String?) -> StructureEditingContext { + StructureEditingContext( + connection: connection, + databaseName: databaseName, + schemaName: schemaName, + tableName: "", + objectKind: .table, + isSessionBound: false, + isCreatingTable: true + ) + } + + var databaseType: DatabaseType { connection.type } + + var scope: DatabaseScope { + DatabaseScope(connectionId: connection.id, database: databaseName, schema: schemaName, isSessionBound: isSessionBound) + } + + var editGate: StructureEditGate { + StructureEditGate(databaseType: connection.type, objectKind: objectKind, schema: schemaName, isSessionBound: isSessionBound) + } + + // A drafted table is created in one run, so every definition it can hold is open; an existing one asks the gate. + // Its checks are added after the CREATE, so they need an engine that adds one to an existing table. + func allows(_ operation: StructureEditOperation) -> Bool { + guard isCreatingTable else { return editGate.allows(operation) } + switch operation { + case .addForeignKey, .dropForeignKey: + return connection.type.supportsForeignKeys + case .addCheckConstraint, .dropCheckConstraint: + return connection.type.supportsCheckConstraintEditing + default: + return true + } + } + + func refusal(_ operation: StructureEditOperation) -> String? { + guard !isCreatingTable else { return nil } + return editGate.resolve(operation).unavailableReason + } + + func addOperation(for section: StructureSection) -> StructureEditOperation { + switch section { + case .columns: .addColumn + case .indexes: .addIndex + case .foreignKeys: .addForeignKey + case .checkConstraints: .addCheckConstraint + } + } + + func removeOperation(for section: StructureSection) -> StructureEditOperation { + switch section { + case .columns: .dropColumn + case .indexes: .dropIndex + case .foreignKeys: .dropForeignKey + case .checkConstraints: .dropCheckConstraint + } + } +} diff --git a/apple/TablePro/Views/Structure/StructureEditingSession+Apply.swift b/apple/TablePro/Views/Structure/StructureEditingSession+Apply.swift index af23e7a589..c2f9c83325 100644 --- a/apple/TablePro/Views/Structure/StructureEditingSession+Apply.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSession+Apply.swift @@ -88,6 +88,7 @@ internal extension StructureEditingSession { /// mounted structure view ever fills. Answering Save from anywhere else ran nothing, reported /// success, and closed the tab over the work. func applyStagedChanges(coordinator: MainContentCoordinator?) async -> StructureSaveOutcome { + editor.commitPendingEdits() /// Asked before Safe Mode, so a tab with nothing staged is never refused. `.refused` stands /// the close down, and refusing a save that had no work to do would leave the user unable to /// close the tab through Save at all. @@ -145,6 +146,7 @@ internal extension StructureEditingSession { return .refused } adoptWrittenTableComment(from: hold.changes) + adoptWrittenTableOptions(from: hold.changes) markEveryTabStale() hasLoaded = false lastAppliedAt = Date() diff --git a/apple/TablePro/Views/Structure/StructureEditingSession+Baseline.swift b/apple/TablePro/Views/Structure/StructureEditingSession+Baseline.swift deleted file mode 100644 index 4e215e22ff..0000000000 --- a/apple/TablePro/Views/Structure/StructureEditingSession+Baseline.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// StructureEditingSession+Baseline.swift -// TablePro -// - -import Foundation -import TableProPluginKit - -internal extension StructureEditingSession { - // PostgreSQL leaves `isPrimaryKey` false on every column, so the primary index decides first. - func adoptLoadedBaseline() { - serverSupport = StructureServerSupport.forConnection(connection.id) - let pkFromIndexes = indexes.first(where: { $0.isPrimary })?.columns ?? [] - let pkFromColumns = columns.filter { $0.isPrimaryKey }.map { $0.name } - let primaryKey = pkFromIndexes.isEmpty ? pkFromColumns : pkFromIndexes - - changeManager.loadSchema( - tableName: tableName, - columns: columns, - indexes: indexes, - foreignKeys: foreignKeys, - checkConstraints: checkConstraints, - primaryKey: primaryKey, - tableComment: loadedTableComment - ) - } - - // Reads what the Structure tab's first mount reads, so an edit staged without the tab composes - // the statements the tab would. - func loadEditingBaseline(provider: any ScopedMetadataProviding = DatabaseManager.shared) async throws { - let loader = TableStructureLoader(scope: scope, tableName: tableName, provider: provider) - serverSupport = StructureServerSupport.forConnection(connection.id) - columns = try await loader.columns() - indexes = try await loader.indexes() - foreignKeys = try await loader.foreignKeys() - if availableTabs.contains(.checkConstraints) { - checkConstraints = try await loader.checkConstraints() - } - adoptLoadedBaseline() - hasLoaded = true - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift b/apple/TablePro/Views/Structure/StructureEditingSession+ColumnReorder.swift similarity index 93% rename from apple/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift rename to apple/TablePro/Views/Structure/StructureEditingSession+ColumnReorder.swift index 8ae123566d..30c1ff93cc 100644 --- a/apple/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSession+ColumnReorder.swift @@ -1,17 +1,16 @@ // -// TableStructureView+ColumnReorder.swift +// StructureEditingSession+ColumnReorder.swift // TablePro // -import Combine import Foundation -import SwiftUI import TableProPluginKit import TableProSQLGrammar -extension TableStructureView { +// Applies now, not staged: a reorder is offered only while nothing else is staged. +internal extension StructureEditingSession { func beginColumnReorder(fromIndex: Int, toIndex: Int) { - let columnsSnapshot = structureChangeManager.workingColumns + let columnsSnapshot = changeManager.workingColumns let clearTarget = coordinator?.selectedColumnLayoutClearTarget() let reorderScope = scope @@ -59,7 +58,7 @@ extension TableStructureView { action: TableRebuildReviewRequest.Action( title: String(localized: "Rebuild Table"), operationDescription: StructureColumnReorderHandler.operationDescription - ) { + ) { [self] in do { try await StructureColumnReorderHandler.execute( prepared, tableName: tableName, databaseType: connection.type, isConfirmationPreCleared: true @@ -88,7 +87,7 @@ extension TableStructureView { _ prepared: StructureColumnReorderHandler.PreparedReorder, clearTarget: ColumnLayoutClearTarget? ) async { - await services.queryHistoryManager.record( + await QueryHistoryManager.shared.record( QueryHistoryRecordRequest( query: prepared.plan.scriptStatements .map { $0.hasSuffix(";") ? $0 : $0 + ";" } @@ -103,7 +102,7 @@ extension TableStructureView { ) ) isReloadingAfterSave = true - await loadColumns() + await fetch(.columns) adoptFetchedBaseline() isReloadingAfterSave = false if let clearTarget { diff --git a/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift b/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift new file mode 100644 index 0000000000..66f98a8b10 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift @@ -0,0 +1,132 @@ +// +// StructureEditingSession+Commands.swift +// TablePro +// + +import AppKit +import Foundation + +// What the menu bar, the toolbar and the keyboard ask of a structure tab. The coordinator calls these on the +// selected tab's session, so they work whether or not a view is mounted. +internal extension StructureEditingSession { + var hasChanges: Bool { changeManager.hasChanges } + + var pendingChangeCount: Int { changeManager.getChangesArray().count } + + // MARK: - Undo + + var canUndo: Bool { changeManager.canUndo } + var canRedo: Bool { changeManager.canRedo } + var undoMenuItemTitle: String { changeManager.undoMenuItemTitle } + var redoMenuItemTitle: String { changeManager.redoMenuItemTitle } + + func undo() { + guard canUndo else { return } + commitOpenEdits() + changeManager.undo() + editor.refresh() + } + + func redo() { + guard canRedo else { return } + commitOpenEdits() + changeManager.redo() + editor.refresh() + } + + private func commitOpenEdits() { + editor.commitPendingEdits() + } + + // MARK: - Rows + + private var takesRowCommands: Bool { pane == .definition && hasBaseline } + + var canAddRow: Bool { takesRowCommands && editor.canAdd(editor.commandSection) } + var canRemoveRows: Bool { takesRowCommands && editor.canRemoveSelection(in: editor.commandSection) } + var canPasteRows: Bool { takesRowCommands && editor.canPaste } + + func addRow() { + guard canAddRow else { return } + editor.add(editor.commandSection) + } + + func removeSelectedRows() { + guard canRemoveRows else { return } + editor.removeSelection(in: editor.commandSection) + } + + func duplicateSelectedRows() { + guard takesRowCommands else { return } + editor.duplicateSelection() + } + + func copySelectedRows() { + guard pane == .definition else { return } + editor.copySelection() + } + + func pasteRows() { + guard canPasteRows else { return } + editor.paste() + } + + // MARK: - Discard + + func discardChanges() { + for section in StructureSection.allCases { + editor.adapter(for: section).model.discardEditing() + } + changeManager.discardChanges() + editor.refresh() + if settleOwedRefetch() { + Task { await load() } + } + } + + func discardChangesAfterConfirming() { + guard hasChanges else { return } + let count = pendingChangeCount + Task { + let message = count == 1 + ? String(localized: "The change to this table's structure will be lost.") + : String(format: String(localized: "%d changes to this table's structure will be lost."), count) + guard await confirmDiscard(message: message) else { return } + discardChanges() + } + } + + func confirmDiscard(message: String) async -> Bool { + await AlertHelper.confirmDestructive( + title: String(localized: "Discard Changes?"), + message: message, + confirmButton: String(localized: "Discard Changes"), + cancelButton: String(localized: "Cancel"), + window: coordinator?.contentWindow + ) + } + + // MARK: - Preview and save + + // Preview checks what Save checks, so the statements it shows are the ones Save would run. + func previewChanges() { + editor.commitPendingEdits() + guard hasChanges, let coordinator else { + coordinator?.toolbarState.previewStatements = [] + return + } + guard changeManager.canCommit else { + AlertHelper.showErrorSheet( + title: String(localized: "Some Changes Are Incomplete"), + message: changeManager.validationSummary, + window: coordinator.contentWindow + ) + return + } + Task { await previewStagedChanges(coordinator: coordinator) } + } + + func save() { + Task { _ = await applyStagedChanges(coordinator: coordinator) } + } +} diff --git a/apple/TablePro/Views/Structure/StructureEditingSession+Loading.swift b/apple/TablePro/Views/Structure/StructureEditingSession+Loading.swift new file mode 100644 index 0000000000..7b08238b0c --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureEditingSession+Loading.swift @@ -0,0 +1,298 @@ +// +// StructureEditingSession+Loading.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +internal extension StructureEditingSession { + var loader: TableStructureLoader { + TableStructureLoader(scope: scope, tableName: tableName) + } + + var offersTableComment: Bool { + DatabaseObjectToolEligibility.Support.of(DatabaseManager.shared.driver(for: connection.id)) + .commentableTypes.contains(objectKind) + } + + // A failed read ends the wait, so its section can show the error and Try Again; edits stay closed until a baseline. + var isLoadingFirstTime: Bool { + !hasBaseline && !editor.sections.contains { loadState.failure(StructureLoadState.Item($0)) != nil } + } + + // Idempotent: a pane re-added after a connection switch restarts this, and only what is missing is read. + func load() async { + settleOwedRefetch() + guard !hasLoaded else { + await reloadConcurrentRefreshAvailability() + await readTableOptionsCatalogIfNeeded() + await loadPaneIfNeeded(pane) + return + } + serverSupport = StructureServerSupport.forConnection(connection.id) + await fetchDefinitionsNeedingFetch() + guard !Task.isCancelled else { return } + await loadTableComment() + guard !Task.isCancelled else { return } + await readTableOptions() + guard !Task.isCancelled else { return } + adoptIfCurrent() + await reloadConcurrentRefreshAvailability() + await readTableOptionsCatalogIfNeeded() + await loadPaneIfNeeded(pane) + } + + // Every offered section read since the last change; a baseline built from less would stage against a guess. + var definitionsAreCurrent: Bool { + editor.sections.allSatisfy { !loadState.needsFetch(StructureLoadState.Item($0)) } + } + + func fetchDefinitionsNeedingFetch() async { + for section in editor.sections where loadState.needsFetch(StructureLoadState.Item(section)) { + guard !Task.isCancelled else { return } + await fetch(StructureLoadState.Item(section)) + } + } + + // A partial read still shows what it got, read-only, so a failed section never hides the ones that loaded. + func adoptIfCurrent() { + guard definitionsAreCurrent else { + if !changeManager.hasChanges { loadDefinitionsIntoManager() } + return + } + hasLoaded = true + adoptFetchedBaseline() + } + + func loadPaneIfNeeded(_ pane: StructurePane) async { + switch pane { + case .triggers: + guard loadState.needsFetch(.triggers) else { return } + await fetch(.triggers) + case .ddl: + guard loadState.needsFetch(.ddl) else { return } + await fetch(.ddl) + case .definition, .parts: + return + } + } + + func fetch(_ item: StructureLoadState.Item) async { + do { + switch item { + case .columns: + columns = try await loader.columns() + case .indexes: + indexes = try await loader.indexes() + case .foreignKeys: + foreignKeys = try await loader.foreignKeys() + case .checkConstraints: + checkConstraints = try await loader.checkConstraints() + case .triggers: + triggers = try await loader.triggers() + case .ddl: + let table = tableName + let schema = scope.schema + ddlStatement = try await loader.perform { driver in + try await TableDDLComposer.fetchDDL( + for: table, using: driver, includesDependencies: true, schema: schema + ) + } + } + loadState.markFetched(item) + } catch { + guard let message = StructureFetchFailure(error, taskIsCancelled: Task.isCancelled).message else { return } + Self.logger.error("Failed to load \(String(describing: item), privacy: .public): \(error.publicLogShape, privacy: .public)") + loadState.markFailed(item, message: message) + } + } + + func retry(_ item: StructureLoadState.Item) { + Task { + switch item { + case .columns, .indexes, .foreignKeys, .checkConstraints: + await fetchDefinitionsNeedingFetch() + adoptIfCurrent() + case .triggers, .ddl: + await fetch(item) + } + } + } + + func loadTableComment() async { + guard offersTableComment else { return } + beginTableCommentLoad() + let outcome: MetadataFetchOutcome + do { + outcome = .fetched(try await loader.tableComment()) + } catch { + switch StructureFetchFailure(error, taskIsCancelled: Task.isCancelled) { + case .cancelled: + outcome = .cancelled + case .failed(let message): + Self.logger.error("Failed to load the table comment: \(error.publicLogShape, privacy: .public)") + outcome = .failed(message) + } + } + settleTableComment(outcome) + } + + func readTableOptionsCatalogIfNeeded() async { + guard needsTableOptionsCatalog else { return } + await readTableOptionsCatalog() + } + + // MARK: - Baseline + + func adoptLoadedBaseline() { + loadDefinitionsIntoManager() + hasBaseline = true + editor.isAwaitingBaseline = false + } + + // PostgreSQL leaves `isPrimaryKey` false on every column, so the primary index decides first. + private func loadDefinitionsIntoManager() { + let pkFromIndexes = indexes.first(where: { $0.isPrimary })?.columns ?? [] + let pkFromColumns = columns.filter { $0.isPrimaryKey }.map { $0.name } + let primaryKey = pkFromIndexes.isEmpty ? pkFromColumns : pkFromIndexes + + changeManager.loadSchema( + tableName: tableName, + columns: columns, + indexes: indexes, + foreignKeys: foreignKeys, + checkConstraints: checkConstraints, + primaryKey: primaryKey, + tableComment: loadedTableComment, + tableOptions: loadedTableOptions, + autoIncrementConfirmed: autoIncrementRead == .confirmed + ) + editor.refresh() + } + + func adoptFetchedBaseline() { + guard !changeManager.hasChanges else { + markStructureStale() + return + } + adoptLoadedBaseline() + } + + func rebaselineUnlessEdited() { + guard !isReloadingAfterSave, hasLoaded else { return } + adoptFetchedBaseline() + } + + // Reads what the Structure tab's first mount reads, so an edit staged without the tab composes the statements + // the tab would. + func loadEditingBaseline(provider: any ScopedMetadataProviding = DatabaseManager.shared) async throws { + let loader = TableStructureLoader(scope: scope, tableName: tableName, provider: provider) + serverSupport = StructureServerSupport.forConnection(connection.id) + columns = try await loader.columns() + indexes = try await loader.indexes() + foreignKeys = try await loader.foreignKeys() + if editor.offers(.checkConstraints) { + checkConstraints = try await loader.checkConstraints() + } + adoptLoadedBaseline() + hasLoaded = true + } + + // MARK: - Reload + + func reloadDefinitions() async { + let includesForeignKeys = editor.offers(.foreignKeys) + let offersTableOptions = offersTableOptionsTab + do { + let reloaded = try await loader.coreTabs( + includingForeignKeys: includesForeignKeys, + includingTableComment: offersTableComment, + includingTableOptions: offersTableOptions + ) + if let comment = reloaded.tableComment { + settleTableComment(comment) + } + // A save never adopts the counter it wrote: the server raises a low value, so this read is what shows. + if let options = reloaded.tableOptions { + settleTableOptions(options) + } + columns = reloaded.columns + indexes = reloaded.indexes + loadState.markFetched(.columns) + loadState.markFetched(.indexes) + if includesForeignKeys { + foreignKeys = reloaded.foreignKeys + loadState.markFetched(.foreignKeys) + } + } catch { + if offersTableOptions { + settleTableOptions(.cancelled) + } + if let message = StructureFetchFailure(error, taskIsCancelled: Task.isCancelled).message { + Self.logger.error("Failed to reload structure: \(error.publicLogShape, privacy: .public)") + loadState.markFailed(.columns, message: message) + } + } + if editor.offers(.checkConstraints) { + await fetch(.checkConstraints) + } + await reloadConcurrentRefreshAvailability() + } + + // A failed read keeps the save's tables closed to edits until Try Again reads them. + func refreshAfterApply() async { + isReloadingAfterSave = true + await reloadDefinitions() + isReloadingAfterSave = false + adoptIfCurrent() + await loadPaneIfNeeded(pane) + } + + // Refresh reads everything again; staged edits are discarded first, after asking. + func refresh() { + guard !isReloadingAfterSave else { return } + let justSaved = lastAppliedAt.map { Date().timeIntervalSince($0) < 2.0 } ?? false + guard changeManager.hasChanges, !justSaved else { + Task { await reloadEverything() } + return + } + Task { + guard await confirmDiscard( + message: String(localized: "Refreshing reads the table structure again and discards the changes you have not saved.") + ) else { return } + discardChanges() + await reloadEverything() + } + } + + private func reloadEverything() async { + markEveryTabStale() + editor.referenceMenus.invalidateTableLists() + partsReloadToken &+= 1 + await reloadDefinitions() + adoptIfCurrent() + await loadPaneIfNeeded(pane) + } +} + +internal enum StructureFetchFailure: Equatable { + case cancelled + case failed(String) + + internal init(_ error: any Error, taskIsCancelled: Bool) { + if error is CancellationError || taskIsCancelled { + self = .cancelled + } else { + self = .failed(error.localizedDescription) + } + } + + internal var message: String? { + switch self { + case .cancelled: nil + case .failed(let message): message + } + } +} diff --git a/apple/TablePro/Views/Structure/StructureEditingSession.swift b/apple/TablePro/Views/Structure/StructureEditingSession.swift index 4cddaf324f..5e4cd04c59 100644 --- a/apple/TablePro/Views/Structure/StructureEditingSession.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSession.swift @@ -8,6 +8,8 @@ import Foundation import os import TableProPluginKit +// One table tab's structure: what was read, what is staged, and the commands that act on it. The coordinator +// holds it by tab id, so staged edits, the close prompt and Save work with no view mounted. @MainActor internal final class StructureEditingSession: ObservableObject { static let logger = Logger(subsystem: "com.TablePro", category: "StructureEditingSession") @@ -23,12 +25,7 @@ internal final class StructureEditingSession: ObservableObject { internal let objectKind: TableInfo.TableType internal let changeManager = StructureChangeManager() - - // Owned here, not by the view, so the grid the user types into and the edits the close prompt - // reports are one object, and its model outlives every remount. - internal let gridAdapter: StructureGridAdapter - - internal var gridModel: DataGridModel { gridAdapter.model } + internal let editor: StructureDefinitionEditor internal var scope: DatabaseScope { DatabaseScope( @@ -42,7 +39,7 @@ internal final class StructureEditingSession: ObservableObject { @Published internal var checkConstraints: [CheckConstraintInfo] = [] @Published internal var triggers: [TriggerInfo] = [] @Published internal var ddlStatement: String = "" - @Published internal var tabData = StructureTabDataState() + @Published internal var loadState = StructureLoadState() @Published internal var concurrentRefresh: MetadataLoadState = .idle private var concurrentRefreshRequest = 0 @@ -51,46 +48,55 @@ internal final class StructureEditingSession: ObservableObject { internal var loadedTableComment: String? { tableComment.value ?? nil } - @Published internal var selectedTab: StructureTab = .columns - @Published internal var searchText = "" - @Published internal var sortState = SortState() - @Published internal var sortDescriptor: StructureSortDescriptor? - @Published internal var columnLayouts: [StructureTab: ColumnLayoutState] = [:] + @Published internal private(set) var tableOptions: MetadataLoadState = .idle + + internal var loadedTableOptions: TableOptions? { tableOptions.value ?? nil } + + // Leaves `.confirmed` when a save writes the counter and returns only on a read that lands, since the server may raise it. + @Published internal private(set) var autoIncrementRead: AutoIncrementReadState = .confirmed + + @Published internal private(set) var tableOptionsCatalog: MetadataLoadState = .idle + internal var tableOptionsCatalogRead: Task? + + @Published internal var pane: StructurePane = .definition + @Published internal var partsReloadToken = 0 @Published internal var serverSupport = StructureServerSupport.unrestricted { didSet { - guard !availableTabs.contains(selectedTab) else { return } - selectedTab = .columns + editor.serverSupport = serverSupport + if !availablePanes.contains(pane) { + pane = .definition + } } } - internal var availableTabs: [StructureTab] { - StructureTabAvailability.tabs(for: connection.type, serverSupport: serverSupport) - } - - internal var tabsFetchedOnMount: [StructureTab] { - var tabs: [StructureTab] = [.columns, .indexes, .foreignKeys] - if availableTabs.contains(.checkConstraints) { - tabs.append(.checkConstraints) - } - if !tabs.contains(selectedTab) { - tabs.append(selectedTab) + internal var availablePanes: [StructurePane] { + StructurePane.allCases.filter { pane in + switch pane { + case .definition, .ddl: true + case .triggers: connection.type.supportsTriggers + case .parts: connection.type == .clickhouse + } } - return tabs } - @Published internal var footer = StructureFooterCapability() - @Published internal var hasLoaded = false - + @Published internal var hasBaseline = false + // The ER diagram's sessions watch this to learn a save landed. @Published internal private(set) var appliedVersion = 0 - @Published internal var isApplying = false - @Published internal var lastAppliedAt: Date? + internal var isReloadingAfterSave = false + + weak var coordinator: MainContentCoordinator? { + didSet { editor.coordinator = coordinator } + } + internal var executionGate: any ExecutionGate = ExecutionGateProvider.shared + private var cancellables: Set = [] + internal init( identity: String, connection: DatabaseConnection, @@ -108,24 +114,59 @@ internal final class StructureEditingSession: ObservableObject { self.isSessionBound = isSessionBound self.tableName = tableName self.objectKind = objectKind - self.serverSupport = serverSupport - gridAdapter = StructureGridAdapter( - structureChangeManager: changeManager, - connection: connection, - tableName: tableName, - databaseName: databaseName, - schemaName: schemaName, - objectKind: objectKind, - isSessionBound: isSessionBound + editor = StructureDefinitionEditor( + manager: changeManager, + context: .existingTable( + connection: connection, + databaseName: databaseName, + schemaName: schemaName, + tableName: tableName, + objectKind: objectKind, + isSessionBound: isSessionBound + ) ) - gridAdapter.session = self - gridAdapter.store.session = self + self.serverSupport = serverSupport + editor.serverSupport = serverSupport + // Nothing is editable before the table's definitions are read. + editor.isAwaitingBaseline = true + editor.characterSetContextProvider = { [weak self] in + ColumnCharacterSetMenus.Context( + catalog: self?.tableOptionsCatalog ?? .idle, + tableCharacterSet: self?.changeManager.tableOptions.current?.characterSet + ) + } + editor.characterSetCatalogReader = { [weak self] in + guard let self else { return } + switch tableOptionsCatalog { + case .idle, .failed: + Task { await self.readTableOptionsCatalog() } + case .loading, .loaded: + break + } + } + editor.moveColumnHandler = { [weak self] from, to in + self?.beginColumnReorder(fromIndex: from, toIndex: to) + } + changeManager.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + $tableOptionsCatalog + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.editor.refresh() } + .store(in: &cancellables) } + // A tab's session reads the table again at once; a diagram's has no view to show it and is dropped instead. internal func markApplied() { appliedVersion += 1 + guard coordinator != nil else { return } + editor.isAwaitingBaseline = true + Task { await refreshAfterApply() } } + // MARK: - Staleness + internal private(set) var owesRefetch = false internal func markStructureStale() { @@ -145,10 +186,12 @@ internal final class StructureEditingSession: ObservableObject { } internal func markEveryTabStale() { - tabData.markAllStale() + loadState.markAllStale() owesRefetch = false } + // MARK: - Table comment and options + internal func beginTableCommentLoad() { tableComment = tableComment.enteringLoad } @@ -163,6 +206,44 @@ internal final class StructureEditingSession: ObservableObject { tableComment = tableComment.settled(by: outcome, discardingValue: false) } + internal func beginTableOptionsLoad() { + tableOptions = tableOptions.enteringLoad + if autoIncrementRead == .unconfirmed { + autoIncrementRead = .rereading + } + } + + internal func adoptWrittenTableOptions(from changes: [SchemaChange]) { + for change in changes { + switch change { + case .modifyTableCharacterSet(_, let written): + guard let loaded = loadedTableOptions else { continue } + tableOptions = .loaded(loaded.adoptingCharacterSet(of: written)) + case .modifyAutoIncrement: + autoIncrementRead = .rereading + default: + continue + } + } + } + + internal func settleTableOptions(_ outcome: MetadataFetchOutcome) { + tableOptions = tableOptions.settled(by: outcome, discardingValue: false) + if outcome.didFetch { + autoIncrementRead = .confirmed + } else if autoIncrementRead == .rereading { + autoIncrementRead = .unconfirmed + } + } + + internal func beginTableOptionsCatalogLoad() { + tableOptionsCatalog = tableOptionsCatalog.enteringLoad + } + + internal func settleTableOptionsCatalog(_ outcome: MetadataFetchOutcome) { + tableOptionsCatalog = tableOptionsCatalog.settled(by: outcome, discardingValue: false) + } + internal func reloadConcurrentRefreshAvailability( provider: any ScopedMetadataProviding = DatabaseManager.shared ) async { @@ -186,34 +267,23 @@ internal final class StructureEditingSession: ObservableObject { concurrentRefresh = concurrentRefresh.settled(by: outcome, discardingValue: true) } - // The mounted view installs closures that capture it, and it holds this session; called wherever a - // session is dropped, never from `onDisappear`. + // Called wherever a session is dropped, never from `onDisappear`. internal func releaseViewWiring() { - gridAdapter.releaseViewWiring() + coordinator = nil } } -extension StructureEditingSession: ActiveGridModelProviding { - // The schema grid while a sub-tab shows one; Parts and Triggers draw lists of their own. - var activeGridModel: DataGridModel? { - switch selectedTab { - case .columns, .indexes, .foreignKeys, .checkConstraints: - return gridModel - case .ddl, .parts, .triggers: - return nil - } - } +internal enum AutoIncrementReadState: Equatable { + case confirmed + case rereading + // The re-read failed or was cancelled, so the loaded counter may be one the server has moved past. + case unconfirmed } -internal struct StructureFooterCapability: Equatable { - internal var canAdd = false - internal var canRemove = false - internal var addLabel = "" - internal var removeLabel = "" - - internal var unavailableReason: String? - - internal var isActive: Bool { - !addLabel.isEmpty +extension StructureEditingSession: ActiveGridModelProviding { + // The section that holds the selection while the Definition page is on screen; the other panes draw no grid. + var activeGridModel: DataGridModel? { + guard pane == .definition else { return nil } + return editor.focusedModel ?? editor.adapter(for: .columns).model } } diff --git a/apple/TablePro/Views/Structure/StructureEditingSupport.swift b/apple/TablePro/Views/Structure/StructureEditingSupport.swift index c6c189ae41..b4ba2bcf2b 100644 --- a/apple/TablePro/Views/Structure/StructureEditingSupport.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSupport.swift @@ -23,12 +23,19 @@ enum StructureEditingSupport { _ column: inout EditableColumnDefinition, at index: Int, with value: String, - orderedFields: [StructureColumnField] + orderedFields: [StructureColumnField], + catalog: TableOptionsCatalog? = nil ) { guard index >= 0, index < orderedFields.count else { return } switch orderedFields[index] { case .name: column.name = value - case .type: column.dataType = value + case .type: + // Only engines with per-column character sets list the field; elsewhere the driver decides a retype's collation. + if orderedFields.contains(.charset) { + column.setDataType(value) + } else { + column.dataType = value + } case .nullable: column.setNullable(parseBool(value) && !column.isPrimaryKey) case .defaultValue: column.defaultValue = value.isEmpty ? nil : value case .onUpdate: @@ -38,8 +45,14 @@ enum StructureEditingSupport { if column.isPrimaryKey { column.setNullable(false) } case .autoIncrement: column.autoIncrement = parseBool(value) case .comment: column.comment = value.isEmpty ? nil : value - case .charset: column.charset = value.isEmpty ? nil : value - case .collation: column.collation = value.isEmpty ? nil : value + case .charset: + let name = value.isEmpty ? nil : value + guard name != column.charset else { break } + column.setCharacterSet(name, collation: name.flatMap { catalog?.defaultCollation(for: $0) }) + case .collation: + let collation = value.isEmpty ? nil : value + let owner = collation.flatMap { catalog?.characterSet(owning: $0)?.name } + column.setCharacterSet(owner ?? column.charset, collation: collation) case .generated: column.generationKind = GenerationKind(rawValue: value.uppercased()) if column.generationKind == nil { column.generationExpression = nil } diff --git a/apple/TablePro/Views/Structure/StructureFooterPolicy.swift b/apple/TablePro/Views/Structure/StructureFooterPolicy.swift index 3155d98e00..b42c3ffd7e 100644 --- a/apple/TablePro/Views/Structure/StructureFooterPolicy.swift +++ b/apple/TablePro/Views/Structure/StructureFooterPolicy.swift @@ -2,104 +2,45 @@ // StructureFooterPolicy.swift // TablePro // -// What the add/remove pair under a structure list offers, and why it is dimmed when it is. -// import Foundation import TableProPluginKit -/// The single decision behind the "+" and "-" under the Structure tab's list. -/// -/// Pure and SwiftUI-free so the rule is testable, and one function so the label, the enabled state -/// and the tooltip can never disagree. They did: the labels came from one switch, the enabled state -/// from a second that read engine capability flags alone, and the tooltip from a third that returned -/// nil for every tab but Foreign Keys. So a view offered an enabled "Add Column" over an -/// `ALTER TABLE … ADD COLUMN` PostgreSQL always refuses, with nothing to explain it. (#2726) +// The add and remove pair beneath each section's table: what it is called and which edit it stages. enum StructureFooterPolicy { - /// Nil for a tab with nothing to add: DDL is text, Parts is read-only, and a trigger is created - /// through its own editor rather than by typing a row. - static func operation(forAdding tab: StructureTab) -> StructureEditOperation? { - switch tab { - case .columns: return .addColumn - case .indexes: return .addIndex - case .foreignKeys: return .addForeignKey - case .checkConstraints: return .addCheckConstraint - case .ddl, .parts, .triggers: return nil + static func operation(forAdding section: StructureSection) -> StructureEditOperation { + switch section { + case .columns: .addColumn + case .indexes: .addIndex + case .foreignKeys: .addForeignKey + case .checkConstraints: .addCheckConstraint } } - static func operation(forRemoving tab: StructureTab) -> StructureEditOperation? { - switch tab { - case .columns: return .dropColumn - case .indexes: return .dropIndex - case .foreignKeys: return .dropForeignKey - case .checkConstraints: return .dropCheckConstraint - case .ddl, .parts, .triggers: return nil + static func operation(forRemoving section: StructureSection) -> StructureEditOperation { + switch section { + case .columns: .dropColumn + case .indexes: .dropIndex + case .foreignKeys: .dropForeignKey + case .checkConstraints: .dropCheckConstraint } } - /// Why the grid and the pair under it take no edits while a save runs. + // Why the tables and the pairs under them take no edits while a save runs. static var savingReason: String { String(localized: "The staged changes are being saved.") } - static func labels(for tab: StructureTab) -> (add: String, remove: String)? { - switch tab { + static func labels(for section: StructureSection) -> (add: String, remove: String) { + switch section { case .columns: - return (String(localized: "Add Column"), String(localized: "Remove Column")) + (String(localized: "Add Column"), String(localized: "Remove Column")) case .indexes: - return (String(localized: "Add Index"), String(localized: "Remove Index")) + (String(localized: "Add Index"), String(localized: "Remove Index")) case .foreignKeys: - return (String(localized: "Add Foreign Key"), String(localized: "Remove Foreign Key")) + (String(localized: "Add Foreign Key"), String(localized: "Remove Foreign Key")) case .checkConstraints: - return (String(localized: "Add Check Constraint"), String(localized: "Remove Check Constraint")) - case .ddl, .parts, .triggers: - return nil - } - } - - /// - Parameter resolve: The full availability of one operation, which the caller supplies because - /// it owns the connection the engine half of the answer comes from. - /// - /// A refusing object kind keeps the pair on screen and dims it with the reason, the way the - /// Foreign Keys tab already did. An engine that cannot edit structure at all hides it instead: - /// there is nothing to explain per object when the whole tab is read-only. - static func resolve( - tab: StructureTab, - canEditSchema: Bool, - hasSelection: Bool, - isSaving: Bool, - resolve: (StructureEditOperation) -> StructureEditAvailability - ) -> StructureFooterCapability { - guard canEditSchema, - let labels = labels(for: tab), - let adding = operation(forAdding: tab), - let removing = operation(forRemoving: tab) - else { - return StructureFooterCapability() + (String(localized: "Add Check Constraint"), String(localized: "Remove Check Constraint")) } - - /// A save holds what is staged until it ends, so nothing can be added or removed meanwhile. - guard !isSaving else { - return StructureFooterCapability( - addLabel: labels.add, - removeLabel: labels.remove, - unavailableReason: savingReason - ) - } - - let addAvailability = resolve(adding) - let removeAvailability = resolve(removing) - - return StructureFooterCapability( - canAdd: addAvailability.isAvailable, - canRemove: hasSelection && removeAvailability.isAvailable, - addLabel: labels.add, - removeLabel: labels.remove, - /// Never the absence of a selection. The pair dims while nothing is selected on every - /// tab and on every engine, which needs no sentence; only a refusal the user could not - /// have predicted does. - unavailableReason: addAvailability.unavailableReason ?? removeAvailability.unavailableReason - ) } } diff --git a/apple/TablePro/Views/Structure/StructureGridAdapter+Inspector.swift b/apple/TablePro/Views/Structure/StructureGridAdapter+Inspector.swift deleted file mode 100644 index e2cf87bc85..0000000000 --- a/apple/TablePro/Views/Structure/StructureGridAdapter+Inspector.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// StructureGridAdapter+Inspector.swift -// TablePro -// - -import Foundation - -// The inspector names a row by its position in the grid, the same position the grid's selection uses. -extension StructureGridAdapter: InspectorRowSource { - func inspectorRow(atDisplayRow displayRow: Int) -> InspectorRow? { - guard let rowID = model.rowID(at: displayRow), - let sourceIndex = StructureRowProvider.sourceIndex(of: rowID), - let providerRow = provider.displayRow(ofSourceIndex: sourceIndex) else { return nil } - return StructureInspectorRowBuilder.row( - atDisplayRow: providerRow, - tab: selectedTab, - provider: provider, - canEditSchema: canEdit, - lockedFieldIndices: lockedFieldIndices, - rowOptions: { menuOptions(sourceIndex: sourceIndex, field: $0) } - ) - } - - func commitInspectorField(displayRow: Int, fieldIndex: Int, value: String?) { - guard let rowID = model.rowID(at: displayRow) else { return } - applyEdit(rowID: rowID, field: fieldIndex, text: value ?? "") - } -} diff --git a/apple/TablePro/Views/Structure/StructureGridAdapter.swift b/apple/TablePro/Views/Structure/StructureGridAdapter.swift deleted file mode 100644 index 4363434788..0000000000 --- a/apple/TablePro/Views/Structure/StructureGridAdapter.swift +++ /dev/null @@ -1,482 +0,0 @@ -// -// StructureGridAdapter.swift -// TablePro -// - -import AppKit -import TableProPluginKit - -// Feeds the Structure tab's grid from the session's working definitions and writes its edits back, -// always by the definition a row names, never by where the row sits on screen. -@MainActor -final class StructureGridAdapter { - let model: DataGridModel - let store: StructureGridStateStore - let structureChangeManager: StructureChangeManager - let connection: DatabaseConnection - let tableName: String - let databaseName: String - let schemaName: String? - let objectKind: TableInfo.TableType - let isSessionBound: Bool - let referenceMenus: ForeignKeyReferenceMenus - - weak var session: StructureEditingSession? - weak var coordinator: MainContentCoordinator? - // Installed by the mounted view while a column may be moved; it owns the reorder flow and its sheets. - var moveRowHandler: ((Int, Int) -> Void)? - - private(set) var provider: StructureRowProvider - private var snapshot = StructureGridSnapshot() - private let changeTracking: StructureGridChangeState - private let changeState: AnyChangeManager - - init( - structureChangeManager: StructureChangeManager, - connection: DatabaseConnection, - tableName: String, - databaseName: String, - schemaName: String?, - objectKind: TableInfo.TableType, - isSessionBound: Bool - ) { - self.structureChangeManager = structureChangeManager - self.connection = connection - self.tableName = tableName - self.databaseName = databaseName - self.schemaName = schemaName - self.objectKind = objectKind - self.isSessionBound = isSessionBound - let store = StructureGridStateStore() - self.store = store - self.model = DataGridModel(store: store) - self.referenceMenus = ForeignKeyReferenceMenus(connectionId: connection.id, databaseType: connection.type) - self.changeTracking = StructureGridChangeState(manager: structureChangeManager) - self.changeState = AnyChangeManager(changeTracking) - self.provider = StructureRowProvider( - changeManager: structureChangeManager, - tab: .columns, - databaseType: connection.type, - additionalFields: [.primaryKey], - serverSupport: .unrestricted - ) - referenceMenus.schemaName = schemaName - // Read when a list lands, not captured: the view hands the coordinator over on every appearance. - referenceMenus.onListsChanged = { [weak self] in - self?.coordinator?.inspectorRowSourceRevision += 1 - } - model.dataSource = self - model.actions = self - } - - // MARK: - State - - // The sub-tab whose rows the grid shows. The session's selection moves first and refresh() follows, so every - // write, menu and command reads this, never the incoming sub-tab (an open edit commits into the rows it was on). - var selectedTab: StructureTab { store.subTab } - private var serverSupport: StructureServerSupport { session?.serverSupport ?? .unrestricted } - private var searchText: String { session?.searchText ?? "" } - private var sortDescriptor: StructureSortDescriptor? { session?.sortDescriptor } - - var editGate: StructureEditGate { - StructureEditGate( - databaseType: connection.type, objectKind: objectKind, schema: schemaName, isSessionBound: isSessionBound - ) - } - - var canEditForeignKeys: Bool { - editGate.allows(.addForeignKey) - } - - var canStageAddForSelectedTab: Bool { - guard let adding = StructureFooterPolicy.operation(forAdding: selectedTab) else { return false } - return editGate.allows(adding) - } - - var canStageRemoveForSelectedTab: Bool { - guard let removing = StructureFooterPolicy.operation(forRemoving: selectedTab) else { return false } - return editGate.allows(removing) - } - - var canEdit: Bool { - editGate.allowsAnyEdit && !structureChangeManager.isHeldForSave - } - - var lockedFieldIndices: Set { - editGate.lockedFieldIndices( - on: selectedTab, - orderedFields: provider.orderedColumnFields, - fieldCount: provider.columns.count - ) - } - - var editRefusal: String? { - if structureChangeManager.isHeldForSave { return StructureFooterPolicy.savingReason } - guard !editGate.allowsAnyEdit else { return nil } - return editGate.resolve(.renameColumn).unavailableReason - } - - var columnReorderAvailability: ColumnReorderAvailability { - ColumnReorderPolicy.resolve( - support: PluginManager.shared.columnReorderSupport(for: connection.type), - engineName: connection.type.displayName, - isColumnsTab: selectedTab == .columns, - kindRefusal: editGate.kindRefusal(.reorderColumns), - canEditSchema: editGate.canEditSchema, - hasStagedChanges: structureChangeManager.hasChanges, - isRearranged: !searchText.isEmpty || sortDescriptor != nil || store.valueFilter.isActive - ) - } - - var showsGrid: Bool { - selectedTab != .ddl && selectedTab != .triggers && selectedTab != .parts - } - - // MARK: - Refresh - - // Called whenever the working definitions, the sub-tab, the search, the sort or the edit gate moved. - func refresh() { - let tab = session?.selectedTab ?? store.subTab - if store.subTab != tab { - model.commitEditing() - store.switchSubTab(to: tab) - } - changeTracking.tab = tab - changeTracking.rowCount = StructureGridEdits.rowCount(on: tab, manager: structureChangeManager) - - let provider = StructureRowProvider( - changeManager: structureChangeManager, - tab: tab, - databaseType: connection.type, - additionalFields: [.primaryKey], - serverSupport: serverSupport, - filterText: searchText.isEmpty ? nil : searchText, - sortDescriptor: sortDescriptor - ) - self.provider = provider - let rows = provider.asTableRows() - let states = rows.rows.map { row in - StructureGridEdits.visualState( - sourceIndex: StructureRowProvider.sourceIndex(of: row.id) ?? -1, - tab: tab, - manager: structureChangeManager, - orderedFields: provider.orderedColumnFields, - tintsModifiedFields: true - ) - } - let wasEditable = model.configuration.isEditable - model.configuration = makeConfiguration(provider: provider) - let previous = snapshot - snapshot = StructureGridSnapshot(rows: rows, states: states) - model.applyHeldSnapshot(from: previous, to: snapshot) - // A save hold changes only the editability, and the inspector's fields have to follow it. - if previous.rows.rows != rows.rows || previous.states != states || previous.rows.columns != rows.columns - || wasEditable != model.configuration.isEditable { - coordinator?.inspectorRowSourceRevision += 1 - } - } - - private func makeConfiguration(provider: StructureRowProvider) -> DataGridConfiguration { - var configuration = DataGridConfiguration() - let options = provider.customDropdownOptions - configuration.dropdownColumns = provider.dropdownColumns - configuration.typePickerColumns = provider.typePickerColumns - configuration.customDropdownOptions = options.isEmpty ? nil : options - configuration.lockedColumns = lockedColumns(provider: provider) - configuration.editRefusalMessage = editRefusal - configuration.isEditable = canEdit - configuration.traits = DataGridEngineTraits( - databaseType: connection.type, - connectionScope: DatabaseScope(connectionId: connection.id, database: databaseName, schema: schemaName), - looksUpUserDefinedTypes: true - ) - let reorder = columnReorderAvailability - configuration.rowReorder = DataGridRowReorder( - isEnabled: reorder.isAvailable, - unavailableReason: reorder.unavailableReason - ) - configuration.commands = commands(reorderAvailable: reorder.isAvailable) - return configuration - } - - private func commands(reorderAvailable: Bool) -> Set { - var commands: Set = [.valueFilter, .freezeColumns] - guard showsGrid, !structureChangeManager.isHeldForSave else { return commands } - if canStageAddForSelectedTab { - commands.formUnion([.addRow, .duplicateRows, .pasteRows]) - } - if canStageRemoveForSelectedTab { - commands.formUnion([.deleteRows, .undoDelete]) - } - if reorderAvailable { - commands.insert(.moveRows) - } - return commands - } - - private func lockedColumns(provider: StructureRowProvider) -> Set { - let headings = provider.columns - let locked = editGate.lockedFieldIndices( - on: selectedTab, - orderedFields: provider.orderedColumnFields, - fieldCount: headings.count - ) - return Set(locked.compactMap { headings.indices.contains($0) ? headings[$0] : nil }) - } - - // MARK: - Rows - - func sourceIndices(of rowIDs: [RowID]) -> [Int] { - rowIDs.compactMap(StructureRowProvider.sourceIndex(of:)) - } - - func menuOptions(sourceIndex: Int, field: Int) -> [GridMenuOption]? { - switch selectedTab { - case .indexes: - guard structureChangeManager.workingIndexes.indices.contains(sourceIndex) else { return nil } - return StructureRowProvider.indexMenuOptions( - columnIndex: field, - index: structureChangeManager.workingIndexes[sourceIndex], - serverSupport: serverSupport - ) - case .foreignKeys: - guard canEditForeignKeys, - structureChangeManager.workingForeignKeys.indices.contains(sourceIndex) else { return nil } - referenceMenus.origin = coordinator?.selectedTabScope - return referenceMenus.options( - columnIndex: field, - foreignKey: structureChangeManager.workingForeignKeys[sourceIndex], - tableColumns: structureChangeManager.workingColumns.map(\.name) - ) - case .columns, .checkConstraints, .ddl, .parts, .triggers: - return nil - } - } - - // MARK: - Commands - - @discardableResult - func applyEdit(rowID: RowID, field: Int, text: String) -> Bool { - guard canEdit, let sourceIndex = StructureRowProvider.sourceIndex(of: rowID) else { return false } - let applied = write(text, field: field, sourceIndex: sourceIndex) - refresh() - return applied - } - - private func write(_ text: String, field: Int, sourceIndex: Int) -> Bool { - let tab = selectedTab - guard field >= 0, - !editGate.locksField(at: field, on: tab, orderedFields: provider.orderedColumnFields), - !structureChangeManager.deleteInsertState(for: sourceIndex, tab: tab).isDeleted else { return false } - let changedReference = StructureGridEdits.apply( - text, - field: field, - sourceIndex: sourceIndex, - tab: tab, - manager: structureChangeManager, - orderedFields: provider.orderedColumnFields, - connection: connection - ) - if let changedReference { - referenceMenus.origin = coordinator?.selectedTabScope - referenceMenus.prefetchReferencedColumns( - of: changedReference.referencedTable, schema: changedReference.referencedSchema - ) - } - return true - } - - func deleteRows(_ rowIDs: [RowID]) { - guard !rowIDs.isEmpty, showsGrid else { - model.clearSelection() - return - } - let allowed: Bool - switch selectedTab { - case .columns: allowed = editGate.allows(.dropColumn) - case .indexes: allowed = editGate.allows(.dropIndex) - case .foreignKeys: allowed = canEditForeignKeys - case .checkConstraints: allowed = editGate.allows(.dropCheckConstraint) - case .ddl, .parts, .triggers: allowed = false - } - guard allowed else { return } - let displayRows = IndexSet(rowIDs.compactMap { model.displayIndex(of: $0) }) - let countBefore = model.rowCount - StructureGridEdits.delete( - sourceIndices: sourceIndices(of: rowIDs), tab: selectedTab, manager: structureChangeManager - ) - refresh() - selectAfterDeleting(displayRows, countBefore: countBefore) - } - - private func selectAfterDeleting(_ displayRows: IndexSet, countBefore: Int) { - let remaining = countBefore - displayRows.count - guard remaining > 0, let minRow = displayRows.first, let maxRow = displayRows.last else { - model.clearSelection() - return - } - let target = maxRow < remaining ? maxRow : (minRow > 0 ? minRow - 1 : 0) - guard let rowID = model.rowID(at: target) else { - model.clearSelection() - return - } - model.selectRows([rowID]) - } - - func addRow() { - guard canStageAddForSelectedTab else { return } - StructureGridEdits.addRow(on: selectedTab, manager: structureChangeManager) - refresh() - } - - func duplicateRows(_ rowIDs: [RowID]) { - guard canStageAddForSelectedTab else { return } - StructureGridEdits.duplicate( - sourceIndices: sourceIndices(of: rowIDs), tab: selectedTab, manager: structureChangeManager - ) - refresh() - } - - func undoDelete(_ rowIDs: [RowID]) { - for index in sourceIndices(of: rowIDs) { - structureChangeManager.undoDelete(for: selectedTab, at: index) - } - refresh() - } - - private var undoesStructureEdits: Bool { - selectedTab != .ddl && selectedTab != .triggers - } - - // The comment bar shows on every sub-tab, so its step undoes even where no grid is on screen. - func undo() { - guard undoesStructureEdits || structureChangeManager.undoesTableCommentNext else { return } - model.commitEditing() - structureChangeManager.undo() - refresh() - } - - func redo() { - guard undoesStructureEdits || structureChangeManager.redoesTableCommentNext else { return } - model.commitEditing() - structureChangeManager.redo() - refresh() - } - - func navigateToForeignKey(sourceIndex: Int) { - guard structureChangeManager.workingForeignKeys.indices.contains(sourceIndex), let coordinator else { return } - let foreignKey = structureChangeManager.workingForeignKeys[sourceIndex] - guard let origin = coordinator.selectedTabScope else { - coordinator.openTableTab(foreignKey.referencedTable, schema: foreignKey.referencedSchema) - return - } - let target = ForeignKeyTargetScope.resolve( - origin: origin, referencedSchema: foreignKey.referencedSchema, databaseType: connection.type - ) - coordinator.openTableTab(foreignKey.referencedTable, schema: target.schema, database: target.database) - } - - func releaseViewWiring() { - moveRowHandler = nil - coordinator = nil - } -} - -// MARK: - DataGridDataSource - -extension StructureGridAdapter: DataGridDataSource { - var gridRows: TableRows { snapshot.rows } - var gridChangeState: AnyChangeManager { changeState } - - func gridVisualState(for rowID: RowID) -> RowVisualState? { - snapshot.state(for: rowID) - } - - func gridMenuOptions(for rowID: RowID, column: DataGridColumn) -> [GridMenuOption]? { - guard let sourceIndex = StructureRowProvider.sourceIndex(of: rowID) else { return nil } - return menuOptions(sourceIndex: sourceIndex, field: column.dataIndex) - } -} - -// MARK: - DataGridActions - -extension StructureGridAdapter: DataGridActions { - func gridCommit(_ edits: [DataGridResolvedEdit], actionName: String) -> Bool { - guard canEdit, !edits.isEmpty else { return false } - var applied = false - let writeAll = { - for edit in edits { - guard let sourceIndex = StructureRowProvider.sourceIndex(of: edit.rowID) else { continue } - if self.write(edit.newValue.asText ?? "", field: edit.dataIndex, sourceIndex: sourceIndex) { - applied = true - } - } - } - if edits.count > 1 { - structureChangeManager.performAsOneUndoStep(writeAll) - } else { - writeAll() - } - refresh() - return applied - } - - func gridDeleteRows(_ rowIDs: [RowID]) { - deleteRows(rowIDs) - } - - func gridUndoDelete(_ rowIDs: [RowID]) { - undoDelete(rowIDs) - } - - func gridAddRow() { - addRow() - } - - func gridDuplicateRows(_ rowIDs: [RowID]) { - duplicateRows(rowIDs) - } - - func gridCanPasteRows() -> Bool { - canStageAddForSelectedTab && TableStructureView.canPasteStructureRows - } - - func gridPasteRows() { - pasteRows() - } - - // A move is offered only while nothing filters or sorts the list, so the drop index is a working index. - func gridMoveRow(from source: RowID, toDisplayIndex destination: Int) { - guard let sourceIndex = StructureRowProvider.sourceIndex(of: source) else { return } - moveRowHandler?(sourceIndex, destination) - } - - // The provider sorts by one column, so a secondary key the header appended is dropped. - func gridSortDidChange(_ sort: SortState) { - guard let session else { return } - if let primary = sort.columns.first { - session.sortDescriptor = StructureSortDescriptor( - column: primary.columnIndex, ascending: primary.direction == .ascending - ) - store.sortState = SortState(columns: [primary], source: .user) - } else { - session.sortDescriptor = nil - store.sortState = SortState(columns: [], source: .user) - } - refresh() - } - - // A value filter rearranges the list, which withdraws the column move. - func gridConfirmDisplayOrderChange(then apply: @escaping @MainActor () -> Void) { - apply() - model.configuration = makeConfiguration(provider: provider) - } - - func gridRowMenuItems(for rowIDs: [RowID], clickedRow: RowID) -> [NSMenuItem] { - structureRowMenuItems(for: rowIDs, clickedRow: clickedRow) - } - - func gridEmptySpaceMenu() -> NSMenu? { - makeEmptySpaceMenu() - } -} diff --git a/apple/TablePro/Views/Structure/StructureGridStateStore.swift b/apple/TablePro/Views/Structure/StructureGridStateStore.swift index 26cc6933b1..2a07bf9734 100644 --- a/apple/TablePro/Views/Structure/StructureGridStateStore.swift +++ b/apple/TablePro/Views/Structure/StructureGridStateStore.swift @@ -4,54 +4,55 @@ // import Foundation +import TableProPluginKit -// The Structure grid's view state lives on the tab's session, one column layout per sub-tab, and never -// reaches a table's saved layout. +// One section's grid state. It never reaches a table's saved layout; only the columns a person hides are kept, +// per section, for every table. @MainActor final class StructureGridStateStore: DataGridStateStore { - weak var session: StructureEditingSession? - private(set) var subTab: StructureTab = .columns + let section: StructureSection + private let defaults: UserDefaults + var sortState = SortState() var valueFilter = GridValueFilterState() var displayFormats: [ValueDisplayFormat?] = [] var selectionSnapshot = GridSelectionSnapshot.empty var viewportAnchor: GridViewportPlacement? let layoutScope: DataGridLayoutScope = .ephemeral - private var detachedLayouts: [StructureTab: ColumnLayoutState] = [:] - private var detachedSort = SortState() - - var sortState: SortState { - get { session?.sortState ?? detachedSort } - set { - guard let session else { - detachedSort = newValue - return - } - guard session.sortState != newValue else { return } - session.sortState = newValue + var columnLayout: ColumnLayoutState { + didSet { + guard columnLayout.hiddenColumns != oldValue.hiddenColumns else { return } + defaults.set(Array(columnLayout.hiddenColumns).sorted(), forKey: Self.hiddenColumnsKey(section)) } } - var columnLayout: ColumnLayoutState { - get { session?.columnLayouts[subTab] ?? detachedLayouts[subTab] ?? ColumnLayoutState() } - set { - guard let session else { - detachedLayouts[subTab] = newValue - return - } - guard session.columnLayouts[subTab] != newValue else { return } - session.columnLayouts[subTab] = newValue + init(section: StructureSection, defaults: UserDefaults = AppStorageEnvironment.shared.defaults) { + self.section = section + self.defaults = defaults + var layout = ColumnLayoutState() + if let stored = defaults.stringArray(forKey: Self.hiddenColumnsKey(section)) { + layout.hiddenColumns = Set(stored) + } else { + layout.hiddenColumns = Self.defaultHiddenColumns(section) } + columnLayout = layout } - // Search, sort, filter and selection start over on another sub-tab; the column layout is kept per sub-tab. - func switchSubTab(to tab: StructureTab) { - guard tab != subTab else { return } - subTab = tab - valueFilter = GridValueFilterState() - displayFormats = [] - selectionSnapshot = .empty - viewportAnchor = nil + static func hiddenColumnsKey(_ section: StructureSection) -> String { + "structure.hiddenColumns.\(section.rawValue)" + } + + // The inspector edits every field; the table keeps the ones read at a glance. + static func defaultHiddenColumns(_ section: StructureSection) -> Set { + switch section { + case .columns: + let hidden: [StructureColumnField] = [.onUpdate, .autoIncrement, .charset, .collation, .generated, .generationExpression] + return Set(hidden.map(\.displayName)) + case .foreignKeys: + return [String(localized: "Ref Schema")] + case .indexes, .checkConstraints: + return [] + } } } diff --git a/apple/TablePro/Views/Structure/StructureGridSupport.swift b/apple/TablePro/Views/Structure/StructureGridSupport.swift index 51f46a0ce6..bc603c1a01 100644 --- a/apple/TablePro/Views/Structure/StructureGridSupport.swift +++ b/apple/TablePro/Views/Structure/StructureGridSupport.swift @@ -17,16 +17,29 @@ enum StructureGridEdits { _ text: String, field: Int, sourceIndex: Int, - tab: StructureTab, + tab: StructureSection, manager: StructureChangeManager, orderedFields: [StructureColumnField], - connection: DatabaseConnection + connection: DatabaseConnection, + characterSets: ColumnCharacterSetMenus.Context? = nil ) -> EditableForeignKeyDefinition? { switch tab { case .columns: guard manager.workingColumns.indices.contains(sourceIndex) else { return nil } var column = manager.workingColumns[sourceIndex] - StructureEditingSupport.updateColumn(&column, at: field, with: text, orderedFields: orderedFields) + let value: String + switch characterSetEntry(text, field: field, column: column, orderedFields: orderedFields, characterSets) { + case .unchecked: + value = text + case .unchanged, .refused: + return nil + case .set(let characterSet, let collation): + // The catalog's own spelling; the routing below pairs it with its default or owning set. + value = (orderedFields[field] == .charset ? characterSet : collation) ?? "" + } + StructureEditingSupport.updateColumn( + &column, at: field, with: value, orderedFields: orderedFields, catalog: characterSets?.loadedCatalog + ) manager.updateColumn(id: column.id, with: column) case .indexes: guard manager.workingIndexes.indices.contains(sourceIndex) else { return nil } @@ -49,14 +62,38 @@ enum StructureGridEdits { var constraint = manager.workingCheckConstraints[sourceIndex] StructureEditingSupport.updateCheckConstraint(&constraint, at: field, with: text) manager.updateCheckConstraint(id: constraint.id, with: constraint) - case .ddl, .parts, .triggers: - break } return nil } + // A Charset or Collation name the catalog does not hold, or one typed while it cannot be checked. + static func refuses( + _ text: String, + field: Int, + sourceIndex: Int, + tab: StructureSection, + manager: StructureChangeManager, + orderedFields: [StructureColumnField], + characterSets: ColumnCharacterSetMenus.Context? + ) -> Bool { + guard tab == .columns, manager.workingColumns.indices.contains(sourceIndex) else { return false } + let column = manager.workingColumns[sourceIndex] + return characterSetEntry(text, field: field, column: column, orderedFields: orderedFields, characterSets) == .refused + } + + private static func characterSetEntry( + _ text: String, + field: Int, + column: EditableColumnDefinition, + orderedFields: [StructureColumnField], + _ characterSets: ColumnCharacterSetMenus.Context? + ) -> ColumnCharacterSetMenus.Entry { + guard let characterSets, orderedFields.indices.contains(field) else { return .unchecked } + return ColumnCharacterSetMenus.entry(text, for: orderedFields[field], column: column, context: characterSets) + } + // One Cmd+Z brings the whole selection back: the manager registers an undo per row. - static func delete(sourceIndices: [Int], tab: StructureTab, manager: StructureChangeManager) { + static func delete(sourceIndices: [Int], tab: StructureSection, manager: StructureChangeManager) { manager.performAsOneUndoStep { for index in sourceIndices.sorted(by: >) { switch tab { @@ -72,59 +109,73 @@ enum StructureGridEdits { case .checkConstraints: guard manager.workingCheckConstraints.indices.contains(index) else { continue } manager.deleteCheckConstraint(id: manager.workingCheckConstraints[index].id) - case .ddl, .parts, .triggers: - break } } } } - static func duplicate(sourceIndices: [Int], tab: StructureTab, manager: StructureChangeManager) { - for index in sourceIndices.sorted() { - switch tab { - case .columns: - guard manager.workingColumns.indices.contains(index) else { continue } - manager.addColumn(manager.workingColumns[index].withNewIdentity()) - case .indexes: - guard manager.workingIndexes.indices.contains(index) else { continue } - var copy = manager.workingIndexes[index].withNewIdentity() - copy.isPrimary = false - manager.addIndex(copy) - case .foreignKeys: - guard manager.workingForeignKeys.indices.contains(index) else { continue } - manager.addForeignKey(manager.workingForeignKeys[index].withNewIdentity()) - case .checkConstraints: - guard manager.workingCheckConstraints.indices.contains(index) else { continue } - manager.addCheckConstraint(manager.workingCheckConstraints[index].withNewIdentity()) - case .ddl, .parts, .triggers: - break + static func duplicate(sourceIndices: [Int], tab: StructureSection, manager: StructureChangeManager) { + manager.performAsOneUndoStep { + for index in sourceIndices.sorted() { + switch tab { + case .columns: + guard manager.workingColumns.indices.contains(index) else { continue } + manager.addColumn(manager.workingColumns[index].withNewIdentity()) + case .indexes: + guard manager.workingIndexes.indices.contains(index) else { continue } + var copy = manager.workingIndexes[index].withNewIdentity() + copy.isPrimary = false + manager.addIndex(copy) + case .foreignKeys: + guard manager.workingForeignKeys.indices.contains(index) else { continue } + manager.addForeignKey(manager.workingForeignKeys[index].withNewIdentity()) + case .checkConstraints: + guard manager.workingCheckConstraints.indices.contains(index) else { continue } + manager.addCheckConstraint(manager.workingCheckConstraints[index].withNewIdentity()) + } } } } - static func addRow(on tab: StructureTab, manager: StructureChangeManager) { + static func addRow(on tab: StructureSection, manager: StructureChangeManager) { switch tab { case .columns: manager.addNewColumn() case .indexes: manager.addNewIndex() case .foreignKeys: manager.addNewForeignKey() case .checkConstraints: manager.addNewCheckConstraint() - case .ddl, .parts, .triggers: break } } - static func rowCount(on tab: StructureTab, manager: StructureChangeManager) -> Int { + static func entityIDs(in section: StructureSection, manager: StructureChangeManager) -> [UUID] { + switch section { + case .columns: manager.workingColumns.map(\.id) + case .indexes: manager.workingIndexes.map(\.id) + case .foreignKeys: manager.workingForeignKeys.map(\.id) + case .checkConstraints: manager.workingCheckConstraints.map(\.id) + } + } + + static func sourceIndex(of id: UUID, in section: StructureSection, manager: StructureChangeManager) -> Int? { + switch section { + case .columns: manager.workingColumns.firstIndex { $0.id == id } + case .indexes: manager.workingIndexes.firstIndex { $0.id == id } + case .foreignKeys: manager.workingForeignKeys.firstIndex { $0.id == id } + case .checkConstraints: manager.workingCheckConstraints.firstIndex { $0.id == id } + } + } + + static func rowCount(on tab: StructureSection, manager: StructureChangeManager) -> Int { switch tab { case .columns: manager.workingColumns.count case .indexes: manager.workingIndexes.count case .foreignKeys: manager.workingForeignKeys.count case .checkConstraints: manager.workingCheckConstraints.count - case .ddl, .parts, .triggers: 0 } } static func visualState( sourceIndex: Int, - tab: StructureTab, + tab: StructureSection, manager: StructureChangeManager, orderedFields: [StructureColumnField], tintsModifiedFields: Bool @@ -143,7 +194,7 @@ enum StructureGridEdits { private static func modifiedFields( sourceIndex: Int, - tab: StructureTab, + tab: StructureSection, manager: StructureChangeManager, orderedFields: [StructureColumnField] ) -> Set { @@ -168,23 +219,24 @@ enum StructureGridEdits { let working = manager.workingCheckConstraints[sourceIndex] guard let original = manager.currentCheckConstraints.first(where: { $0.id == working.id }) else { return [] } return StructureEditingSupport.checkConstraintModifiedIndices(old: original, new: working) - case .ddl, .parts, .triggers: - return [] } } } -// The schema manager has no row identities of its own; this answers the grid's row questions by the -// source-index RowIDs `StructureRowProvider` hands out. +// The schema manager has no row identities of its own; this answers the grid's row questions through the RowIDs +// the section's adapter minted, one per working definition in order. @MainActor final class StructureGridChangeState: ChangeManaging { let manager: StructureChangeManager - var tab: StructureTab - var rowCount: Int = 0 + let section: StructureSection + var rowIDs: [RowID] = [] { + didSet { sourceIndexByRowID = Dictionary(uniqueKeysWithValues: rowIDs.enumerated().map { ($1, $0) }) } + } + private var sourceIndexByRowID: [RowID: Int] = [:] - init(manager: StructureChangeManager, tab: StructureTab = .columns) { + init(manager: StructureChangeManager, section: StructureSection) { self.manager = manager - self.tab = tab + self.section = section } var hasChanges: Bool { manager.hasChanges } @@ -193,18 +245,16 @@ final class StructureGridChangeState: ChangeManaging { var rowChanges: [RowChange] { [] } var insertedRowIDs: Set { - Set((0.. { - Set((0.. Bool { - guard let index = StructureRowProvider.sourceIndex(of: rowID), index >= 0 else { return false } - return manager.deleteInsertState(for: index, tab: tab).isDeleted + guard let index = sourceIndexByRowID[rowID] else { return false } + return manager.deleteInsertState(for: index, tab: section).isDeleted } func recordCellChange( @@ -217,8 +267,10 @@ final class StructureGridChangeState: ChangeManaging { ) {} func undoRowDeletion(rowID: RowID) { - guard let index = StructureRowProvider.sourceIndex(of: rowID) else { return } - manager.undoDelete(for: tab, at: index) + guard let index = sourceIndexByRowID[rowID] else { return } + let ids = StructureGridEdits.entityIDs(in: section, manager: manager) + guard ids.indices.contains(index) else { return } + manager.undoDelete(section: section, id: ids[index]) } } diff --git a/apple/TablePro/Views/Structure/StructureInspectorRowBuilder.swift b/apple/TablePro/Views/Structure/StructureInspectorRowBuilder.swift deleted file mode 100644 index 11cdbd450e..0000000000 --- a/apple/TablePro/Views/Structure/StructureInspectorRowBuilder.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// StructureInspectorRowBuilder.swift -// TablePro -// -// Builds the inspector payload for a structure grid row. Field names, values, -// and editors all come from the same provider the grid renders, so the panel -// and the grid can never disagree about what a row holds. -// - -import Foundation - -@MainActor -internal enum StructureInspectorRowBuilder { - static func row( - atDisplayRow displayRow: Int, - tab: StructureTab, - provider: StructureRowProvider, - canEditSchema: Bool, - lockedFieldIndices: Set = [], - rowOptions: (Int) -> [GridMenuOption]? = { _ in nil } - ) -> InspectorRow? { - switch tab { - case .columns, .indexes, .foreignKeys, .checkConstraints: - break - case .ddl, .parts, .triggers: - return nil - } - - guard let values = provider.row(at: displayRow) else { return nil } - - let names = provider.columns - let dropdownOptions = provider.customDropdownOptions - let typePickerColumns = provider.typePickerColumns - let modified = provider.modifiedFieldIndices(atDisplay: displayRow) - - let fields = names.indices.map { index -> InspectorRowField in - InspectorRowField( - name: names[index], - value: index < values.count ? values[index] : nil, - editor: editor( - at: index, - options: rowOptions(index) ?? dropdownOptions[index], - typePickerColumns: typePickerColumns - ), - isModified: modified.contains(index), - isEditable: !lockedFieldIndices.contains(index) - ) - } - - return InspectorRow( - fields: fields, - isEditable: canEditSchema && !provider.isPendingDelete(atDisplay: displayRow) - ) - } - - /// An option list that carries a `Custom…` entry is an open vocabulary, so the field keeps its - /// text editor and puts the list beside it. A closed list becomes the picker it already was; - /// giving the Default field one would take away the only way to type an expression. - private static func editor( - at index: Int, - options: [GridMenuOption]?, - typePickerColumns: Set - ) -> FieldEditorKind { - if let options, !options.isEmpty { - if options.contains(where: { if case .custom = $0 { return true } else { return false } }) { - return .valuePicker(options: options) - } - return .enumPicker(values: options.compactMap(\.sql)) - } - if typePickerColumns.contains(index) { - return .typePicker - } - return .schemaText - } -} diff --git a/apple/TablePro/Views/Structure/StructureLoadState.swift b/apple/TablePro/Views/Structure/StructureLoadState.swift new file mode 100644 index 0000000000..d63d7700ee --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureLoadState.swift @@ -0,0 +1,64 @@ +// +// StructureLoadState.swift +// TablePro +// + +import Foundation + +// What a structure tab has read, what a refresh made stale, and which read failed, per part. A failed read keeps +// what was loaded before it and is reported where that part shows, never over the whole tab. +struct StructureLoadState: Equatable { + enum Item: Hashable, CaseIterable { + case columns + case indexes + case foreignKeys + case checkConstraints + case triggers + case ddl + + init(_ section: StructureSection) { + switch section { + case .columns: self = .columns + case .indexes: self = .indexes + case .foreignKeys: self = .foreignKeys + case .checkConstraints: self = .checkConstraints + } + } + } + + private var loaded: Set = [] + private var stale: Set = [] + private(set) var failures: [Item: String] = [:] + + func hasData(_ item: Item) -> Bool { + loaded.contains(item) + } + + func needsFetch(_ item: Item) -> Bool { + !loaded.contains(item) || stale.contains(item) || failures[item] != nil + } + + func failure(_ item: Item) -> String? { + failures[item] + } + + mutating func markAllStale() { + stale = loaded + } + + mutating func markFetched(_ item: Item) { + loaded.insert(item) + stale.remove(item) + failures.removeValue(forKey: item) + } + + mutating func markFailed(_ item: Item, message: String) { + failures[item] = message + } + + mutating func reset() { + loaded.removeAll() + stale.removeAll() + failures.removeAll() + } +} diff --git a/apple/TablePro/Views/Structure/StructureRowProvider.swift b/apple/TablePro/Views/Structure/StructureRowProvider.swift index 9f06531a6f..784b0f3da2 100644 --- a/apple/TablePro/Views/Structure/StructureRowProvider.swift +++ b/apple/TablePro/Views/Structure/StructureRowProvider.swift @@ -18,10 +18,11 @@ final class StructureRowProvider { ] private let changeManager: StructureChangeManager - private let tab: StructureTab + private let tab: StructureSection private let databaseType: DatabaseType private let additionalFields: Set private let serverSupport: StructureServerSupport + private let picksCharacterSets: Bool let orderedColumnFields: [StructureColumnField] private let filterText: String? private let sortDescriptor: StructureSortDescriptor? @@ -41,13 +42,13 @@ final class StructureRowProvider { case .columns: return orderedColumnFields.map { $0.displayName } case .indexes: - return [ + let headings = [ String(localized: "Name"), String(localized: "Columns"), String(localized: "Type"), - String(localized: "Unique"), - String(localized: "Condition") + String(localized: "Unique") ] + return Self.offersIndexCondition(databaseType) ? headings + [String(localized: "Condition")] : headings case .foreignKeys: return [ String(localized: "Name"), @@ -64,8 +65,6 @@ final class StructureRowProvider { String(localized: "Expression"), String(localized: "Columns") ] - case .ddl, .parts, .triggers: - return [] } } @@ -81,11 +80,26 @@ final class StructureRowProvider { switch tab { case .foreignKeys: ForeignKeyReferenceMenus.rowDependentColumns case .indexes: [Self.indexTypeColumn] - case .columns, .checkConstraints, .ddl, .parts, .triggers: [] + case .columns: + // A collation list follows the row's character set, and both cells close on a non-text type. + picksCharacterSets + ? Set(orderedColumnFields.indices.filter { ColumnCharacterSetMenus.isCharacterSetField(orderedColumnFields[$0]) }) + : [] + case .checkConstraints: [] } } static let indexTypeColumn = 2 + static let indexUniqueColumn = 3 + + // Yes-or-no fields, drawn as checkboxes in the grid and the inspector. + var booleanColumns: Set { + switch tab { + case .columns: Set(Self.booleanFields.compactMap { orderedColumnFields.firstIndex(of: $0) }) + case .indexes: [Self.indexUniqueColumn] + case .foreignKeys, .checkConstraints: [] + } + } static func indexMenuOptions( columnIndex: Int, @@ -121,12 +135,11 @@ final class StructureRowProvider { result[index] = ColumnDefaultVocabulary.options(for: databaseType) } return result - case .checkConstraints, .ddl, .parts, .triggers: + case .checkConstraints: return [:] } } - static let booleanOptions = ["YES", "NO"] static let notGeneratedOption = String(localized: "Not generated") @@ -138,7 +151,7 @@ final class StructureRowProvider { case .columns: if let i = orderedColumnFields.firstIndex(of: .type) { return [i] } return [] - case .indexes, .foreignKeys, .checkConstraints, .ddl, .parts, .triggers: + case .indexes, .foreignKeys, .checkConstraints: return [] } } @@ -149,18 +162,21 @@ final class StructureRowProvider { init( changeManager: StructureChangeManager, - tab: StructureTab, + tab: StructureSection, databaseType: DatabaseType = .mysql, additionalFields: Set = [], serverSupport: StructureServerSupport, filterText: String? = nil, - sortDescriptor: StructureSortDescriptor? = nil + sortDescriptor: StructureSortDescriptor? = nil, + picksCharacterSets: Bool = false, + keepingSourceIndices: Set = [] ) { self.changeManager = changeManager self.tab = tab self.databaseType = databaseType self.additionalFields = additionalFields self.serverSupport = serverSupport + self.picksCharacterSets = picksCharacterSets self.filterText = filterText self.sortDescriptor = sortDescriptor self.orderedColumnFields = Self.orderedFields( @@ -170,10 +186,10 @@ final class StructureRowProvider { ) let allRows = Self.buildAllRows( - tab: tab, changeManager: changeManager, orderedColumnFields: self.orderedColumnFields + tab: tab, changeManager: changeManager, orderedColumnFields: self.orderedColumnFields, databaseType: databaseType ) self.cachedRows = Self.applyFilterAndSort( - allRows, filterText: filterText, sortDescriptor: sortDescriptor + allRows, filterText: filterText, sortDescriptor: sortDescriptor, keeping: keepingSourceIndices ) } @@ -225,7 +241,7 @@ final class StructureRowProvider { guard sourceIndex < changeManager.workingIndexes.count else { return nil } let id = changeManager.workingIndexes[sourceIndex].id guard let original = changeManager.currentIndexes.first(where: { $0.id == id }) else { return nil } - return Self.row(for: original) + return Self.row(for: original, offersCondition: Self.offersIndexCondition(databaseType)) case .foreignKeys: guard sourceIndex < changeManager.workingForeignKeys.count else { return nil } let id = changeManager.workingForeignKeys[sourceIndex].id @@ -238,8 +254,6 @@ final class StructureRowProvider { return nil } return Self.row(for: original) - case .ddl, .parts, .triggers: - return nil } } @@ -251,9 +265,10 @@ final class StructureRowProvider { } private static func buildAllRows( - tab: StructureTab, + tab: StructureSection, changeManager: StructureChangeManager, - orderedColumnFields: [StructureColumnField] + orderedColumnFields: [StructureColumnField], + databaseType: DatabaseType ) -> [IndexedRow] { switch tab { case .columns: @@ -262,7 +277,7 @@ final class StructureRowProvider { } case .indexes: return changeManager.workingIndexes.enumerated().map { index, indexInfo in - IndexedRow(sourceIndex: index, row: row(for: indexInfo)) + IndexedRow(sourceIndex: index, row: row(for: indexInfo, offersCondition: offersIndexCondition(databaseType))) } case .foreignKeys: return changeManager.workingForeignKeys.enumerated().map { index, fk in @@ -272,8 +287,6 @@ final class StructureRowProvider { return changeManager.workingCheckConstraints.enumerated().map { index, constraint in IndexedRow(sourceIndex: index, row: row(for: constraint)) } - case .ddl, .parts, .triggers: - return [] } } @@ -308,20 +321,25 @@ final class StructureRowProvider { } } - private static func row(for index: EditableIndexDefinition) -> [String?] { + // MySQL and MariaDB have no partial indexes and their DDL drops a WHERE clause, so none is offered. + static func offersIndexCondition(_ type: DatabaseType) -> Bool { + SQLTypeFamily.of(type) != .mysql + } + + private static func row(for index: EditableIndexDefinition, offersCondition: Bool) -> [String?] { let columnsStr = index.columns.map { col in if let prefix = index.columnPrefixes[col] { return "\(col)(\(prefix))" } return col }.joined(separator: ", ") - return [ + let values: [String?] = [ index.name, columnsStr, index.type.rawValue, - index.isUnique ? "YES" : "NO", - index.whereClause ?? "" + index.isUnique ? "YES" : "NO" ] + return offersCondition ? values + [index.whereClause ?? ""] : values } private static func row(for fk: EditableForeignKeyDefinition) -> [String?] { @@ -336,15 +354,18 @@ final class StructureRowProvider { ] } + // `keeping` stays listed whatever the filter says: a definition being edited never vanishes under the editor. private static func applyFilterAndSort( _ rows: [IndexedRow], filterText: String?, - sortDescriptor: StructureSortDescriptor? + sortDescriptor: StructureSortDescriptor?, + keeping: Set = [] ) -> [IndexedRow] { var result = rows if let filterText, !filterText.isEmpty { result = result.filter { indexed in + if keeping.contains(indexed.sourceIndex) { return true } guard let name = indexed.row.first ?? nil else { return false } return name.localizedCaseInsensitiveContains(filterText) } @@ -364,27 +385,16 @@ final class StructureRowProvider { } extension StructureRowProvider { - // A row is named by its index in the working list, so a search, a sort or the grid's value filter - // never changes which definition an edit lands on. - static func rowID(forSourceIndex index: Int) -> RowID { - .existing(index) - } - - static func sourceIndex(of rowID: RowID) -> Int? { - guard case .existing(let index) = rowID, index >= 0 else { return nil } - return index - } - func displayRow(ofSourceIndex sourceIndex: Int) -> Int? { cachedRows.firstIndex { $0.sourceIndex == sourceIndex } } - func asTableRows() -> TableRows { + func asTableRows(rowID: (Int) -> RowID) -> TableRows { var rows = ContiguousArray() rows.reserveCapacity(cachedRows.count) for indexed in cachedRows { rows.append(Row( - id: Self.rowID(forSourceIndex: indexed.sourceIndex), + id: rowID(indexed.sourceIndex), values: ContiguousArray(indexed.row.map(PluginCellValue.fromOptional)) )) } diff --git a/apple/TablePro/Views/Structure/StructureSavePlan.swift b/apple/TablePro/Views/Structure/StructureSavePlan.swift index 1141f328d4..6f2f4950bc 100644 --- a/apple/TablePro/Views/Structure/StructureSavePlan.swift +++ b/apple/TablePro/Views/Structure/StructureSavePlan.swift @@ -47,7 +47,9 @@ internal extension StructureEditingSession { plan = try await stagedSavePlan(for: changes) } catch { guard mayPresentPreview(on: coordinator) else { return } - coordinator.toolbarState.previewStatements = ["-- Error generating SQL: \(error.localizedDescription)"] + coordinator.toolbarState.previewStatements = [ + String(format: String(localized: "-- Error generating SQL: %@"), error.localizedDescription) + ] coordinator.activeSheet = .sqlPreview return } diff --git a/apple/TablePro/Views/Structure/StructureGridAdapter+Menus.swift b/apple/TablePro/Views/Structure/StructureSectionAdapter+Menus.swift similarity index 69% rename from apple/TablePro/Views/Structure/StructureGridAdapter+Menus.swift rename to apple/TablePro/Views/Structure/StructureSectionAdapter+Menus.swift index 2e2db334ba..553d944eb5 100644 --- a/apple/TablePro/Views/Structure/StructureGridAdapter+Menus.swift +++ b/apple/TablePro/Views/Structure/StructureSectionAdapter+Menus.swift @@ -1,20 +1,20 @@ // -// StructureGridAdapter+Menus.swift +// StructureSectionAdapter+Menus.swift // TablePro // import AppKit import TableProPluginKit -extension StructureGridAdapter { +extension StructureSectionAdapter { // The grid adds Copy, Duplicate, Delete and Undo Delete from its commands; these are the schema's own. // The copies act on every targeted row, Open and the moves on the clicked one; nil when that is not known. - func structureRowMenuItems(for rowIDs: [RowID], clickedRow: RowID?) -> [NSMenuItem] { - guard showsGrid, !rowIDs.isEmpty else { return [] } + func rowMenuItems(for rowIDs: [RowID], clickedRow: RowID?) -> [NSMenuItem] { + guard !rowIDs.isEmpty else { return [] } let indices = sourceIndices(of: rowIDs) - let tab = selectedTab - let clicked = clickedRow.flatMap(StructureRowProvider.sourceIndex(of:)) - if let clicked, structureChangeManager.deleteInsertState(for: clicked, tab: tab).isDeleted { + let tab = section + let clicked = clickedRow.flatMap(sourceIndex(of:)) + if let clicked, manager.deleteInsertState(for: clicked, tab: tab).isDeleted { return [] } @@ -24,8 +24,8 @@ extension StructureGridAdapter { copyAsItem(indices), ] - if tab == .foreignKeys, let clicked, structureChangeManager.workingForeignKeys.indices.contains(clicked) { - let referenced = structureChangeManager.workingForeignKeys[clicked].referencedTable + if tab == .foreignKeys, let clicked, manager.workingForeignKeys.indices.contains(clicked) { + let referenced = manager.workingForeignKeys[clicked].referencedTable if !referenced.isEmpty { items.append(.separator()) items.append(menuItem(String(format: String(localized: "Open %@"), referenced)) { [weak self] in @@ -44,10 +44,10 @@ extension StructureGridAdapter { // Moving by menu gives the keyboard and VoiceOver the reorder a drag gives the pointer. private func columnMoveItems(movingRow row: Int?) -> [NSMenuItem] { - guard selectedTab == .columns else { return [] } + guard section == .columns, !context.isCreatingTable else { return [] } let reorder = model.configuration.rowReorder guard reorder.isEnabled || reorder.unavailableReason != nil else { return [] } - let count = structureChangeManager.workingColumns.count + let count = manager.workingColumns.count var items = [ columnMoveItem(String(localized: "Move Column Up"), row: row, .up, count: count, isEnabled: reorder.isEnabled), columnMoveItem(String(localized: "Move Column Down"), row: row, .down, count: count, isEnabled: reorder.isEnabled), @@ -70,7 +70,7 @@ extension StructureGridAdapter { return NSMenuItem(title: title, action: nil, keyEquivalent: "") } return menuItem(title) { [weak self] in - self?.moveRowHandler?(row, ColumnMove.dropIndex(movingRow: row, direction)) + self?.editor?.moveColumn(from: row, to: ColumnMove.dropIndex(movingRow: row, direction)) } } @@ -84,30 +84,6 @@ extension StructureGridAdapter { return item } - func makeEmptySpaceMenu() -> NSMenu? { - guard showsGrid, editGate.canEditSchema else { return nil } - let label: String - switch selectedTab { - case .columns: - guard editGate.allows(.addColumn) else { return nil } - label = String(localized: "Add Column") - case .indexes: - guard editGate.allows(.addIndex) else { return nil } - label = String(localized: "Add Index") - case .foreignKeys: - guard canEditForeignKeys else { return nil } - label = String(localized: "Add Foreign Key") - case .checkConstraints: - guard editGate.allows(.addCheckConstraint) else { return nil } - label = String(localized: "Add Check Constraint") - case .ddl, .parts, .triggers: - return nil - } - let menu = NSMenu() - menu.addItem(menuItem(label) { [weak self] in self?.addRow() }) - return menu - } - private func menuItem(_ title: String, action: @escaping () -> Void) -> NSMenuItem { let target = StructureMenuTarget(action: action) let item = NSMenuItem(title: title, action: #selector(StructureMenuTarget.runAction), keyEquivalent: "") @@ -120,11 +96,11 @@ extension StructureGridAdapter { private func unsortedProvider(includingPrimaryKey: Bool) -> StructureRowProvider { StructureRowProvider( - changeManager: structureChangeManager, - tab: selectedTab, - databaseType: connection.type, + changeManager: manager, + tab: section, + databaseType: context.databaseType, additionalFields: includingPrimaryKey ? [.primaryKey] : [], - serverSupport: session?.serverSupport ?? .unrestricted + serverSupport: serverSupport ) } @@ -136,33 +112,31 @@ extension StructureGridAdapter { } func copyDefinitions(_ indices: [Int]) { - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } + guard let driver = DatabaseManager.shared.driver(for: context.connection.id) else { return } var definitions: [String] = [] for index in indices.sorted() { - switch selectedTab { + switch section { case .columns: - guard structureChangeManager.workingColumns.indices.contains(index), - let sql = driver.generateColumnDefinitionSQL(column: structureChangeManager.workingColumns[index].toPlugin()) + guard manager.workingColumns.indices.contains(index), + let sql = driver.generateColumnDefinitionSQL(column: manager.workingColumns[index].toPlugin()) else { continue } definitions.append(sql) case .indexes: - guard structureChangeManager.workingIndexes.indices.contains(index), + guard manager.workingIndexes.indices.contains(index), let sql = driver.generateIndexDefinitionSQL( - index: structureChangeManager.workingIndexes[index].toPlugin(), tableName: tableName + index: manager.workingIndexes[index].toPlugin(), tableName: context.tableName ) else { continue } definitions.append(sql) case .foreignKeys: - guard structureChangeManager.workingForeignKeys.indices.contains(index), - let sql = driver.generateForeignKeyDefinitionSQL(fk: structureChangeManager.workingForeignKeys[index].toPlugin()) + guard manager.workingForeignKeys.indices.contains(index), + let sql = driver.generateForeignKeyDefinitionSQL(fk: manager.workingForeignKeys[index].toPlugin()) else { continue } definitions.append(sql) case .checkConstraints: - guard structureChangeManager.workingCheckConstraints.indices.contains(index) else { continue } - let constraint = structureChangeManager.workingCheckConstraints[index] + guard manager.workingCheckConstraints.indices.contains(index) else { continue } + let constraint = manager.workingCheckConstraints[index] definitions.append("CONSTRAINT \(driver.quoteIdentifier(constraint.name)) CHECK (\(constraint.expression))") - case .ddl, .parts, .triggers: - break } } guard !definitions.isEmpty else { return } @@ -199,22 +173,14 @@ extension StructureGridAdapter { ClipboardService.shared.writeText(json) } - // The grid's own Copy reaches the definitions too, so a paste in this tab stages them again. - func gridCopyRows(_ rowIDs: [RowID]) -> Bool { - guard showsGrid, !rowIDs.isEmpty else { return false } - copyRows(rowIDs) - return true - } - // The definitions ride on the structure pasteboard type, so Paste can stage them again. - func copyRows(_ rowIDs: [RowID]) { - guard showsGrid, !rowIDs.isEmpty else { return } + func copyRows(_ rowIDs: [RowID], to pasteboard: NSPasteboard = .general) { + guard !rowIDs.isEmpty else { return } let ordered = rowIDs.sorted { (model.displayIndex(of: $0) ?? .max) < (model.displayIndex(of: $1) ?? .max) } let indices = sourceIndices(of: ordered) - let manager = structureChangeManager let encoder = JSONEncoder() let encoded: Data? - switch selectedTab { + switch section { case .columns: encoded = try? encoder.encode(indices.sorted().compactMap { manager.workingColumns.indices.contains($0) ? manager.workingColumns[$0] : nil }) case .indexes: @@ -227,8 +193,6 @@ extension StructureGridAdapter { encoded = try? encoder.encode(indices.sorted().compactMap { manager.workingCheckConstraints.indices.contains($0) ? manager.workingCheckConstraints[$0] : nil }) - case .ddl, .parts, .triggers: - encoded = nil } let rows = model.rows @@ -238,23 +202,30 @@ extension StructureGridAdapter { let item = NSPasteboardItem() if let encoded, let json = String(data: encoded, encoding: .utf8) { - item.setString(json, forType: TableStructureView.structurePasteboardType) - item.setString(connection.type.rawValue, forType: TableStructureView.structureSourceTypePasteboardType) + item.setString(json, forType: StructurePasteboard.definitionsType) + item.setString(context.databaseType.rawValue, forType: StructurePasteboard.sourceDatabaseType) } if !lines.isEmpty { item.setString(lines.joined(separator: "\n"), forType: .string) } guard !item.types.isEmpty else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.writeObjects([item]) + pasteboard.clearContents() + pasteboard.writeObjects([item]) + } + + // Pasting several definitions is one undo step. + func pasteRows(from pasteboard: NSPasteboard = .general) { + guard canEdit, canAdd, let data = pasteboard.data(forType: StructurePasteboard.definitionsType) else { return } + let source = pasteboard.string(forType: StructurePasteboard.sourceDatabaseType).map(DatabaseType.init(rawValue:)) + manager.performAsOneUndoStep { + stagePasted(data, from: source) + } + editor?.definitionsDidChange() } - func pasteRows() { - guard canStageAddForSelectedTab, - let data = NSPasteboard.general.data(forType: TableStructureView.structurePasteboardType) else { return } + private func stagePasted(_ data: Data, from source: DatabaseType?) { let decoder = JSONDecoder() - let manager = structureChangeManager - switch selectedTab { + switch section { case .columns: guard let columns = try? decoder.decode([EditableColumnDefinition].self, from: data) else { return } for column in columns { @@ -262,11 +233,8 @@ extension StructureGridAdapter { } case .indexes: guard let indexes = try? decoder.decode([EditableIndexDefinition].self, from: data) else { return } - let source = NSPasteboard.general - .string(forType: TableStructureView.structureSourceTypePasteboardType) - .map(DatabaseType.init(rawValue:)) for index in indexes { - manager.addIndex(index.pasted(from: source, into: connection.type)) + manager.addIndex(index.pasted(from: source, into: context.databaseType)) } case .foreignKeys: guard let foreignKeys = try? decoder.decode([EditableForeignKeyDefinition].self, from: data) else { return } @@ -278,10 +246,16 @@ extension StructureGridAdapter { for constraint in constraints { manager.addCheckConstraint(constraint.withNewIdentity()) } - case .ddl, .parts, .triggers: - return } - refresh() + } +} + +enum StructurePasteboard { + static let definitionsType = NSPasteboard.PasteboardType("com.TablePro.structure") + static let sourceDatabaseType = NSPasteboard.PasteboardType("com.TablePro.structure.database-type") + + static var canPaste: Bool { + NSPasteboard.general.data(forType: definitionsType) != nil } } diff --git a/apple/TablePro/Views/Structure/StructureSectionAdapter.swift b/apple/TablePro/Views/Structure/StructureSectionAdapter.swift new file mode 100644 index 0000000000..fb6f4082a3 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureSectionAdapter.swift @@ -0,0 +1,517 @@ +// +// StructureSectionAdapter.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +// Feeds one section's grid from the working definitions and writes its edits back. A row is named by its +// definition's id, so a reload, a sort, a filter or a removed row above it never moves the selection. +@MainActor +final class StructureSectionAdapter { + let section: StructureSection + let model: DataGridModel + let store: StructureGridStateStore + let manager: StructureChangeManager + let context: StructureEditingContext + let referenceMenus: ForeignKeyReferenceMenus + + weak var editor: StructureDefinitionEditor? + + private(set) var provider: StructureRowProvider + private var snapshot = StructureGridSnapshot() + private var identities = StructureRowIdentities() + private var sortDescriptor: StructureSortDescriptor? + private let changeTracking: StructureGridChangeState + private let changeState: AnyChangeManager + + init( + section: StructureSection, + manager: StructureChangeManager, + context: StructureEditingContext, + referenceMenus: ForeignKeyReferenceMenus, + defaults: UserDefaults = AppStorageEnvironment.shared.defaults + ) { + self.section = section + self.manager = manager + self.context = context + self.referenceMenus = referenceMenus + let store = StructureGridStateStore(section: section, defaults: defaults) + self.store = store + self.model = DataGridModel(store: store) + self.changeTracking = StructureGridChangeState(manager: manager, section: section) + self.changeState = AnyChangeManager(changeTracking) + self.provider = StructureRowProvider( + changeManager: manager, + tab: section, + databaseType: context.databaseType, + additionalFields: [.primaryKey], + serverSupport: .unrestricted + ) + model.dataSource = self + model.actions = self + } + + // MARK: - State + + var serverSupport: StructureServerSupport { editor?.serverSupport ?? .unrestricted } + private var searchText: String { editor?.searchText ?? "" } + private var coordinator: MainContentCoordinator? { editor?.coordinator } + + var characterSetContext: ColumnCharacterSetMenus.Context { + editor?.characterSetContext ?? ColumnCharacterSetMenus.Context(catalog: .idle, tableCharacterSet: nil) + } + + var canAdd: Bool { context.allows(context.addOperation(for: section)) } + var canRemove: Bool { context.allows(context.removeOperation(for: section)) } + + var canEdit: Bool { + guard !manager.isHeldForSave, editor?.isReadOnly != true, editor?.isAwaitingBaseline != true else { return false } + return context.isCreatingTable || context.editGate.allowsAnyEdit + } + + var editRefusal: String? { + if manager.isHeldForSave { return StructureFooterPolicy.savingReason } + if editor?.isAwaitingBaseline == true { return String(localized: "The table is being read again after the save.") } + if editor?.isReadOnly == true { return editor?.readOnlyReason } + guard !context.isCreatingTable, !context.editGate.allowsAnyEdit else { return nil } + return context.editGate.resolve(.renameColumn).unavailableReason + } + + var lockedFieldIndices: Set { + guard !context.isCreatingTable else { return [] } + return context.editGate.lockedFieldIndices( + on: section, + orderedFields: provider.orderedColumnFields, + fieldCount: provider.columns.count + ) + } + + var columnReorderAvailability: ColumnReorderAvailability { + ColumnReorderPolicy.resolve( + support: PluginManager.shared.columnReorderSupport(for: context.databaseType), + engineName: context.databaseType.displayName, + isColumnsTab: section == .columns && !context.isCreatingTable, + kindRefusal: context.editGate.kindRefusal(.reorderColumns), + canEditSchema: context.editGate.canEditSchema && editor?.isReadOnly != true, + hasStagedChanges: manager.hasChanges, + isRearranged: !searchText.isEmpty || sortDescriptor != nil || store.valueFilter.isActive + ) + } + + var rowCount: Int { StructureGridEdits.rowCount(on: section, manager: manager) } + + var visibleRowCount: Int { snapshot.rows.rows.count } + + // MARK: - Identity + + func rowID(forEntity id: UUID) -> RowID { + identities.rowID(for: id) + } + + func entityID(of rowID: RowID) -> UUID? { + identities.entityID(of: rowID) + } + + func sourceIndex(of rowID: RowID) -> Int? { + guard let id = identities.entityID(of: rowID) else { return nil } + return StructureGridEdits.sourceIndex(of: id, in: section, manager: manager) + } + + func sourceIndices(of rowIDs: [RowID]) -> [Int] { + rowIDs.compactMap(sourceIndex(of:)) + } + + var selectedEntityIDs: [UUID] { + model.selection.affectedRows.compactMap { model.rowID(at: $0) }.compactMap(entityID(of:)) + } + + func select(entity id: UUID) { + model.selectRows([rowID(forEntity: id)]) + } + + // MARK: - Refresh + + func refresh() { + let entityIDs = StructureGridEdits.entityIDs(in: section, manager: manager) + let rowIDs = entityIDs.map { identities.rowID(for: $0) } + changeTracking.rowIDs = rowIDs + let selected = Set(selectedEntityIDs) + let keptIndices = Set(entityIDs.indices.filter { selected.contains(entityIDs[$0]) }) + + let provider = StructureRowProvider( + changeManager: manager, + tab: section, + databaseType: context.databaseType, + additionalFields: [.primaryKey], + serverSupport: serverSupport, + filterText: searchText.isEmpty ? nil : searchText, + sortDescriptor: sortDescriptor, + picksCharacterSets: ColumnCharacterSetMenus.offersMenus(characterSetContext.catalog), + keepingSourceIndices: keptIndices + ) + self.provider = provider + let rows = provider.asTableRows { rowIDs.indices.contains($0) ? rowIDs[$0] : .existing(-1) } + let states = provider.filteredToSourceMap.map { sourceIndex in + StructureGridEdits.visualState( + sourceIndex: sourceIndex, + tab: section, + manager: manager, + orderedFields: provider.orderedColumnFields, + tintsModifiedFields: !context.isCreatingTable + ) + } + model.configuration = makeConfiguration(provider: provider) + let previous = snapshot + snapshot = StructureGridSnapshot(rows: rows, states: states) + model.applyHeldSnapshot(from: previous, to: snapshot) + } + + private func makeConfiguration(provider: StructureRowProvider) -> DataGridConfiguration { + var configuration = DataGridConfiguration() + let checkboxes = provider.booleanColumns + let options = provider.customDropdownOptions.filter { !checkboxes.contains($0.key) } + configuration.checkboxColumns = checkboxes + configuration.dropdownColumns = provider.dropdownColumns.subtracting(checkboxes) + configuration.typePickerColumns = provider.typePickerColumns + configuration.customDropdownOptions = options.isEmpty ? nil : options + configuration.marksEmptyText = false + configuration.lockedColumns = lockedColumns(provider: provider) + configuration.editRefusalMessage = editRefusal + configuration.isEditable = canEdit + configuration.fitsRows = true + configuration.traits = DataGridEngineTraits( + databaseType: context.databaseType, + connectionScope: DatabaseScope( + connectionId: context.connection.id, database: context.databaseName, schema: context.schemaName + ), + looksUpUserDefinedTypes: true + ) + let reorder = columnReorderAvailability + configuration.rowReorder = DataGridRowReorder( + isEnabled: reorder.isAvailable, + unavailableReason: section == .columns && !context.isCreatingTable ? reorder.unavailableReason : nil + ) + configuration.commands = commands(reorderAvailable: reorder.isAvailable) + return configuration + } + + private func commands(reorderAvailable: Bool) -> Set { + // Hide Column and Show All Columns bring back the fields a section starts with hidden. + var commands: Set = [.valueFilter, .hideColumns] + guard canEdit else { return commands } + if canAdd { + commands.formUnion([.addRow, .duplicateRows, .pasteRows]) + } + if canRemove { + commands.formUnion([.deleteRows, .undoDelete]) + } + if reorderAvailable { + commands.insert(.moveRows) + } + return commands + } + + private func lockedColumns(provider: StructureRowProvider) -> Set { + let headings = provider.columns + return Set(lockedFieldIndices.compactMap { headings.indices.contains($0) ? headings[$0] : nil }) + } + + // MARK: - Menus + + func menuOptions(sourceIndex: Int, field: Int) -> [GridMenuOption]? { + switch section { + case .indexes: + guard manager.workingIndexes.indices.contains(sourceIndex) else { return nil } + return StructureRowProvider.indexMenuOptions( + columnIndex: field, + index: manager.workingIndexes[sourceIndex], + serverSupport: serverSupport + ) + case .foreignKeys: + guard context.allows(.addForeignKey), manager.workingForeignKeys.indices.contains(sourceIndex) else { return nil } + referenceMenus.origin = context.scope + return referenceMenus.options( + columnIndex: field, + foreignKey: manager.workingForeignKeys[sourceIndex], + tableColumns: manager.workingColumns.map(\.name) + ) + case .columns: + guard manager.workingColumns.indices.contains(sourceIndex), + provider.orderedColumnFields.indices.contains(field) else { return nil } + return ColumnCharacterSetMenus.options( + for: provider.orderedColumnFields[field], + column: manager.workingColumns[sourceIndex], + context: characterSetContext + ) + case .checkConstraints: + return nil + } + } + + // Read-only per row, which the column-wide lock cannot say: a type with no character set, or a catalog not read. + func characterSetLockedFields(sourceIndex: Int) -> Set { + guard section == .columns, manager.workingColumns.indices.contains(sourceIndex) else { return [] } + return ColumnCharacterSetMenus.lockedFieldIndices( + orderedFields: provider.orderedColumnFields, + column: manager.workingColumns[sourceIndex], + catalog: characterSetContext.catalog + ) + } + + // Opening the menu is the retry, as it is for the reference menus. + func readCharacterSetCatalogIfNeeded(field: Int) { + guard section == .columns, provider.orderedColumnFields.indices.contains(field), + ColumnCharacterSetMenus.isCharacterSetField(provider.orderedColumnFields[field]) else { return } + editor?.readCharacterSetCatalogIfNeeded() + } + + // MARK: - Edits + + @discardableResult + func applyEdit(rowID: RowID, field: Int, text: String) -> Bool { + guard canEdit, let sourceIndex = sourceIndex(of: rowID) else { return false } + let applied = write(text, field: field, sourceIndex: sourceIndex) + editor?.definitionsDidChange(in: section) + return applied + } + + @discardableResult + func applyEdit(entity id: UUID, field: Int, text: String) -> Bool { + applyEdit(rowID: rowID(forEntity: id), field: field, text: text) + } + + private func write(_ text: String, field: Int, sourceIndex: Int) -> Bool { + let characterSets = characterSetContext + guard field >= 0, + !lockedFieldIndices.contains(field), + !manager.deleteInsertState(for: sourceIndex, tab: section).isDeleted, + !StructureGridEdits.refuses( + text, + field: field, + sourceIndex: sourceIndex, + tab: section, + manager: manager, + orderedFields: provider.orderedColumnFields, + characterSets: characterSets + ) else { return false } + let changedReference = StructureGridEdits.apply( + text, + field: field, + sourceIndex: sourceIndex, + tab: section, + manager: manager, + orderedFields: provider.orderedColumnFields, + connection: context.connection, + characterSets: characterSets + ) + if let changedReference { + referenceMenus.origin = context.scope + referenceMenus.prefetchReferencedColumns( + of: changedReference.referencedTable, schema: changedReference.referencedSchema + ) + } + return true + } + + func addRow() { + guard canEdit, canAdd else { return } + StructureGridEdits.addRow(on: section, manager: manager) + editor?.definitionsDidChange() + if let id = StructureGridEdits.entityIDs(in: section, manager: manager).last { + editor?.reveal(section: section, entity: id) + } + } + + func deleteRows(_ rowIDs: [RowID]) { + guard canEdit, canRemove, !rowIDs.isEmpty else { return } + let displayRows = IndexSet(rowIDs.compactMap { model.displayIndex(of: $0) }) + let countBefore = model.rowCount + StructureGridEdits.delete(sourceIndices: sourceIndices(of: rowIDs), tab: section, manager: manager) + editor?.definitionsDidChange() + selectAfterDeleting(displayRows, countBefore: countBefore) + } + + private func selectAfterDeleting(_ displayRows: IndexSet, countBefore: Int) { + let remaining = model.rowCount + guard remaining > 0, let minRow = displayRows.first, let maxRow = displayRows.last else { + model.clearSelection() + return + } + // Deleting an existing definition keeps its row (struck through); the selection stays on it. + guard remaining < countBefore else { return } + let removed = countBefore - remaining + let target = maxRow - removed + 1 < remaining ? maxRow - removed + 1 : (minRow > 0 ? minRow - 1 : 0) + guard let rowID = model.rowID(at: min(max(target, 0), remaining - 1)) else { + model.clearSelection() + return + } + model.selectRows([rowID]) + } + + func duplicateRows(_ rowIDs: [RowID]) { + guard canEdit, canAdd else { return } + StructureGridEdits.duplicate(sourceIndices: sourceIndices(of: rowIDs), tab: section, manager: manager) + editor?.definitionsDidChange() + } + + func undoDelete(_ rowIDs: [RowID]) { + let ids = rowIDs.compactMap(entityID(of:)) + manager.performAsOneUndoStep { + for id in ids { + manager.undoDelete(section: section, id: id) + } + } + editor?.definitionsDidChange() + } + + func navigateToForeignKey(sourceIndex: Int) { + guard manager.workingForeignKeys.indices.contains(sourceIndex), let coordinator else { return } + let foreignKey = manager.workingForeignKeys[sourceIndex] + let target = ForeignKeyTargetScope.resolve( + origin: context.scope, referencedSchema: foreignKey.referencedSchema, databaseType: context.databaseType + ) + coordinator.openTableTab(foreignKey.referencedTable, schema: target.schema, database: target.database) + } + + func sortDidChange(_ sort: SortState) { + if let primary = sort.columns.first { + sortDescriptor = StructureSortDescriptor(column: primary.columnIndex, ascending: primary.direction == .ascending) + store.sortState = SortState(columns: [primary], source: .user) + } else { + sortDescriptor = nil + store.sortState = SortState(columns: [], source: .user) + } + refresh() + } +} + +// MARK: - DataGridDataSource + +extension StructureSectionAdapter: DataGridDataSource { + var gridRows: TableRows { snapshot.rows } + var gridChangeState: AnyChangeManager { changeState } + + func gridVisualState(for rowID: RowID) -> RowVisualState? { + snapshot.state(for: rowID) + } + + func gridCheckboxState(for rowID: RowID, column: DataGridColumn) -> Bool? { + guard provider.booleanColumns.contains(column.dataIndex), + let row = snapshot.rows.row(withID: rowID), column.dataIndex < row.values.count else { return nil } + return row.values[column.dataIndex].asText == "YES" + } + + func gridMenuOptions(for rowID: RowID, column: DataGridColumn) -> [GridMenuOption]? { + guard let sourceIndex = sourceIndex(of: rowID) else { return nil } + let options = menuOptions(sourceIndex: sourceIndex, field: column.dataIndex) + readCharacterSetCatalogIfNeeded(field: column.dataIndex) + return options + } +} + +// MARK: - DataGridActions + +extension StructureSectionAdapter: DataGridActions { + func gridCommit(_ edits: [DataGridResolvedEdit], actionName: String) -> Bool { + guard canEdit, !edits.isEmpty else { return false } + var applied = false + manager.performAsOneUndoStep { + for edit in edits { + guard let sourceIndex = sourceIndex(of: edit.rowID) else { continue } + if write(edit.newValue.asText ?? "", field: edit.dataIndex, sourceIndex: sourceIndex) { + applied = true + } + } + } + editor?.definitionsDidChange() + return applied + } + + func gridSetCheckbox(_ isOn: Bool, rowIDs: [RowID], column: DataGridColumn) { + guard canEdit, provider.booleanColumns.contains(column.dataIndex) else { return } + manager.performAsOneUndoStep { + for rowID in rowIDs { + guard let sourceIndex = sourceIndex(of: rowID) else { continue } + _ = write(isOn ? "YES" : "NO", field: column.dataIndex, sourceIndex: sourceIndex) + } + } + editor?.definitionsDidChange() + } + + func gridDeleteRows(_ rowIDs: [RowID]) { + deleteRows(rowIDs) + } + + func gridUndoDelete(_ rowIDs: [RowID]) { + undoDelete(rowIDs) + } + + func gridAddRow() { + addRow() + } + + func gridDuplicateRows(_ rowIDs: [RowID]) { + duplicateRows(rowIDs) + } + + func gridCanPasteRows() -> Bool { + canEdit && canAdd && StructurePasteboard.canPaste + } + + func gridPasteRows() { + pasteRows() + } + + func gridCopyRows(_ rowIDs: [RowID]) -> Bool { + guard !rowIDs.isEmpty else { return false } + copyRows(rowIDs) + return true + } + + // A move is offered only while nothing filters or sorts the list, so the drop index is a working index. + func gridMoveRow(from source: RowID, toDisplayIndex destination: Int) { + guard let sourceIndex = sourceIndex(of: source) else { return } + editor?.moveColumn(from: sourceIndex, to: destination) + } + + // The provider sorts by one column, so a secondary key the header appended is dropped. + func gridSortDidChange(_ sort: SortState) { + sortDidChange(sort) + } + + // A value filter rearranges the list, which withdraws the column move. + func gridConfirmDisplayOrderChange(then apply: @escaping @MainActor () -> Void) { + apply() + model.configuration = makeConfiguration(provider: provider) + } + + func gridRowMenuItems(for rowIDs: [RowID], clickedRow: RowID) -> [NSMenuItem] { + rowMenuItems(for: rowIDs, clickedRow: clickedRow) + } + + func gridEmptySpaceMenu() -> NSMenu? { + nil + } +} + +// Mints one RowID per definition id and keeps it for the adapter's lifetime. +struct StructureRowIdentities { + private var rowIDs: [UUID: RowID] = [:] + private var entityIDs: [RowID: UUID] = [:] + private var next = 0 + + mutating func rowID(for id: UUID) -> RowID { + if let existing = rowIDs[id] { return existing } + let rowID = RowID.existing(next) + next += 1 + rowIDs[id] = rowID + entityIDs[rowID] = id + return rowID + } + + func entityID(of rowID: RowID) -> UUID? { + entityIDs[rowID] + } +} diff --git a/apple/TablePro/Views/Structure/StructureServerSupport.swift b/apple/TablePro/Views/Structure/StructureServerSupport.swift index 46eecc1195..7029f3f3d5 100644 --- a/apple/TablePro/Views/Structure/StructureServerSupport.swift +++ b/apple/TablePro/Views/Structure/StructureServerSupport.swift @@ -49,11 +49,11 @@ struct StructureServerSupport: Equatable, Sendable { } /// Exhaustive on purpose: a new tab has to state whether this server can refuse it. - func offers(_ tab: StructureTab) -> Bool { + func offers(_ tab: StructureSection) -> Bool { switch tab { case .checkConstraints: return checkConstraintRefusal == nil - case .columns, .indexes, .foreignKeys, .triggers, .ddl, .parts: + case .columns, .indexes, .foreignKeys: return true } } diff --git a/apple/TablePro/Views/Structure/StructureTabAvailability.swift b/apple/TablePro/Views/Structure/StructureTabAvailability.swift deleted file mode 100644 index 6e113c755e..0000000000 --- a/apple/TablePro/Views/Structure/StructureTabAvailability.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// StructureTabAvailability.swift -// TablePro -// - -import Foundation - -/// Which tabs the Structure editor offers, from what the engine can do and what the connected -/// server answers for. -/// -/// Both halves matter. `supportsCheckConstraints` is the engine at its newest release; the server -/// in front of the user may be older than the release that gained them, and MySQL before 8.0.16 -/// and MariaDB before 10.2.1 answer `Query OK` to a `CHECK` clause and throw it away. Offering the -/// tab there is offering an edit that reports success and changes nothing. -enum StructureTabAvailability { - static func tabs(for type: DatabaseType, serverSupport: StructureServerSupport) -> [StructureTab] { - StructureTab.allCases.filter { tab in - engineOffers(tab, on: type) && serverSupport.offers(tab) - } - } - - private static func engineOffers(_ tab: StructureTab, on type: DatabaseType) -> Bool { - switch tab { - case .foreignKeys: - return type.supportsForeignKeys - case .parts: - return type == .clickhouse - case .triggers: - return type.supportsTriggers - case .checkConstraints: - return type.supportsCheckConstraints - case .columns, .indexes, .ddl: - return true - } - } -} diff --git a/apple/TablePro/Views/Structure/StructureTabDataState.swift b/apple/TablePro/Views/Structure/StructureTabDataState.swift deleted file mode 100644 index e1b436835b..0000000000 --- a/apple/TablePro/Views/Structure/StructureTabDataState.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// StructureTabDataState.swift -// TablePro -// - -import Foundation - -/// Tracks which structure tabs hold data and which of them need re-fetching. -/// Keeping the two apart lets a refresh mark every tab stale without dropping the -/// counts already on screen, so the segmented picker never reflows mid-reload. -struct StructureTabDataState: Equatable { - private var loaded: Set = [] - private var stale: Set = [] - - func hasData(_ tab: StructureTab) -> Bool { - loaded.contains(tab) - } - - func needsFetch(_ tab: StructureTab) -> Bool { - !loaded.contains(tab) || stale.contains(tab) - } - - mutating func markAllStale() { - stale = loaded - } - - mutating func markFetched(_ tab: StructureTab) { - loaded.insert(tab) - stale.remove(tab) - } - - mutating func reset() { - loaded.removeAll() - stale.removeAll() - } - - static func label(for tab: StructureTab, count: Int?) -> String { - guard let count else { return tab.displayName } - return "\(tab.displayName) (\(count))" - } -} diff --git a/apple/TablePro/Views/Structure/StructureTableOptionsLoading.swift b/apple/TablePro/Views/Structure/StructureTableOptionsLoading.swift new file mode 100644 index 0000000000..ee9a1a629f --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureTableOptionsLoading.swift @@ -0,0 +1,74 @@ +// +// StructureTableOptionsLoading.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +internal extension StructureEditingSession { + // The driver's statement probe decides, never the engine's type. A temporary table is left out: its + // options are not in the shared catalog the read goes to, and the tab is read-only there anyway. + var offersTableOptionsTab: Bool { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return false } + let gate = StructureEditGate( + databaseType: connection.type, objectKind: objectKind, schema: schemaName, isSessionBound: isSessionBound + ) + return gate.canEditSchema && DatabaseObjectToolEligibility.offersTableOptions(driver: driver, objectKind: objectKind) + } + + var needsTableOptionsCatalog: Bool { + if offersTableOptionsTab { return true } + return StructureRowProvider + .orderedFields(for: connection.type, additionalFields: [.primaryKey], serverSupport: serverSupport) + .contains(where: ColumnCharacterSetMenus.isCharacterSetField) + } + + func readTableOptions(provider: any ScopedMetadataProviding = DatabaseManager.shared) async { + guard offersTableOptionsTab else { return } + beginTableOptionsLoad() + let loader = TableStructureLoader(scope: scope, tableName: tableName, provider: provider) + let outcome: MetadataFetchOutcome + do { + outcome = .fetched(try await loader.tableOptions()) + } catch { + outcome = Self.failedRead(error, of: "table options") + } + settleTableOptions(outcome) + } + + // Server-wide, so it is read once for the tab and again only after a failure. The session owns the read, so a + // view task cancelled by a tab switch neither cancels it nor leaves the returning view waiting on nothing. + func readTableOptionsCatalog(provider: any ScopedMetadataProviding = DatabaseManager.shared) async { + if case .loaded = tableOptionsCatalog { return } + if let tableOptionsCatalogRead { + await tableOptionsCatalogRead.value + return + } + beginTableOptionsCatalogLoad() + let loader = TableStructureLoader(scope: scope, tableName: tableName, provider: provider) + let read = Task { [weak self] in + let outcome: MetadataFetchOutcome + do { + outcome = .fetched(try await loader.tableOptionsCatalog()) + } catch { + outcome = Self.failedRead(error, of: "character set catalog") + } + self?.settleTableOptionsCatalog(outcome) + self?.tableOptionsCatalogRead = nil + } + tableOptionsCatalogRead = read + await read.value + } + + private static func failedRead(_ error: any Error, of what: String) -> MetadataFetchOutcome { + switch StructureFetchFailure(error, taskIsCancelled: Task.isCancelled) { + case .cancelled: + return .cancelled + case .failed(let message): + logger.error("Failed to load the \(what, privacy: .public): \(error.publicLogShape, privacy: .public)") + return .failed(message) + } + } +} diff --git a/apple/TablePro/Views/Structure/StructureTableOptionsPolicy.swift b/apple/TablePro/Views/Structure/StructureTableOptionsPolicy.swift new file mode 100644 index 0000000000..836499f7f2 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureTableOptionsPolicy.swift @@ -0,0 +1,32 @@ +// +// StructureTableOptionsPolicy.swift +// TablePro +// + +import Foundation + +internal enum StructureTableOptionsPolicy { + internal struct Editability: Equatable { + internal var isEditable: Bool + internal var unavailableReason: String? + } + + // Without a baseline nothing can be staged: the read is still running or failed, and the tab says which. + internal static func resolve(hasBaseline: Bool, isReadOnly: Bool, isSaving: Bool) -> Editability { + if isSaving { + return Editability(isEditable: false, unavailableReason: StructureFooterPolicy.savingReason) + } + guard hasBaseline else { + return Editability(isEditable: false, unavailableReason: nil) + } + guard !isReadOnly else { + return Editability( + isEditable: false, + unavailableReason: String( + localized: "Cannot save schema changes: TablePro's Safe Mode is set to read-only for this connection." + ) + ) + } + return Editability(isEditable: true, unavailableReason: nil) + } +} diff --git a/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift b/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift index 9ad23ae13b..e9909116b1 100644 --- a/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift +++ b/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift @@ -67,7 +67,7 @@ enum StructureTableRebuildHandler { localized: """ Could not build the table rebuild for this change. If this engine's driver \ was installed before foreign key editing shipped, update it in \ - Settings > Plugins. + Settings > Extensions. """ ) } @@ -101,7 +101,7 @@ enum StructureTableRebuildHandler { ) async throws -> StructureRebuildPlanRunner.Prepared { let partition = partition(changes) guard partition.unsupported.isEmpty else { - throw RebuildError.cannotExpress(partition.unsupported.map(\.description)) + throw RebuildError.cannotExpress(partition.unsupported.map(\.localizedDescription)) } let respecification = respecification(from: partition.rebuilt) @@ -180,7 +180,8 @@ enum StructureTableRebuildHandler { partition.rebuilt.append(change) } case .addIndex, .modifyIndex, .deleteIndex, - .addCheckConstraint, .modifyCheckConstraint, .deleteCheckConstraint, .modifyTableComment: + .addCheckConstraint, .modifyCheckConstraint, .deleteCheckConstraint, .modifyTableComment, + .modifyTableCharacterSet, .modifyAutoIncrement: partition.trailing.append(change) case .modifyPrimaryKey: partition.unsupported.append(change) diff --git a/apple/TablePro/Views/Structure/StructureViewActionHandler.swift b/apple/TablePro/Views/Structure/StructureViewActionHandler.swift deleted file mode 100644 index eadc44ff64..0000000000 --- a/apple/TablePro/Views/Structure/StructureViewActionHandler.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// StructureViewActionHandler.swift -// TablePro -// - -import Foundation - -/// The structure actions that need the grid that is on screen. -/// -/// Saving is deliberately absent. The staged edits belong to the tab's `StructureEditingSession`, -/// and the close prompt offers to save them from tabs that have no mounted structure view at all, -/// so `applyStagedChanges` lives on the session and every caller goes there instead. -@MainActor -final class StructureViewActionHandler { - var previewSQL: (() -> Void)? - var copyRows: (() -> Void)? - var pasteRows: (() -> Void)? - var undo: (() -> Void)? - var redo: (() -> Void)? - var addRow: (() -> Void)? - var removeRow: (() -> Void)? - var refresh: (() -> Void)? -} diff --git a/apple/TablePro/Views/Structure/TableOptionsForm.swift b/apple/TablePro/Views/Structure/TableOptionsForm.swift new file mode 100644 index 0000000000..1b7d62eae4 --- /dev/null +++ b/apple/TablePro/Views/Structure/TableOptionsForm.swift @@ -0,0 +1,102 @@ +// +// TableOptionsForm.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +// The lists the table options pickers offer. A Picker whose selection matches no tag shows nothing selected, so +// the value the table holds is always an item, whether the catalog lists it or not (MySQL 8.0 before .30 reports +// `utf8`, which the catalog spells `utf8mb3`). +enum TableOptionsForm { + struct Choice: Hashable, Identifiable { + let name: String + let isDefault: Bool + + var id: String { name } + var title: String { TableOptionsForm.collationTitle(name, isDefault: isDefault) } + } + + static func collationTitle(_ name: String, isDefault: Bool) -> String { + isDefault ? String(format: String(localized: "%@ (default)"), name) : name + } + + static func characterSets(in catalog: TableOptionsCatalog?, keeping current: String?) -> [String] { + keeping(current, in: catalog?.characterSets.map(\.name) ?? []) + } + + static func collations( + in catalog: TableOptionsCatalog?, + characterSet: String?, + keeping current: String? + ) -> [Choice] { + let set = characterSet.flatMap { catalog?.characterSet(named: $0) } + let listed = (set?.collations ?? []).map { Choice(name: $0, isDefault: $0 == set?.defaultCollation) } + guard let current, !current.isEmpty, !listed.contains(where: { matches($0.name, current) }) else { return listed } + return [Choice(name: current, isDefault: false)] + listed + } + + static func engines(in catalog: TableOptionsCatalog?, keeping current: String?) -> [Choice] { + let names = keeping(current, in: catalog?.engines ?? []) + return names.map { Choice(name: $0, isDefault: matches($0, catalog?.defaultEngine)) } + } + + // A character set without a collation takes its default, and one from the previous set is an error (1253). + static func selectingCharacterSet(_ name: String, in options: TableOptions, catalog: TableOptionsCatalog?) -> TableOptions { + var next = options + next.characterSet = name + next.collation = catalog?.defaultCollation(for: name)?.nilIfEmpty + return next + } + + static func defaultCollation(of name: String?, in catalog: TableOptionsCatalog?) -> String? { + name.flatMap { catalog?.defaultCollation(for: $0) }?.nilIfEmpty + } + + private static func keeping(_ current: String?, in names: [String]) -> [String] { + guard let current, !current.isEmpty, !names.contains(where: { matches($0, current) }) else { return names } + return [current] + names + } + + private static func matches(_ lhs: String, _ rhs: String?) -> Bool { + guard let rhs else { return false } + return lhs.caseInsensitiveCompare(rhs) == .orderedSame + } +} + +// What the Next Auto Increment field holds. Past the AUTO_INCREMENT column's type the next INSERT fails with 1467, +// so the range is the draft column's, which a type change in the same save moves. +enum AutoIncrementEntry: Equatable { + case empty + case value(UInt64) + case invalid(maximum: UInt64) + + init(_ text: String, maximum: UInt64, original: UInt64? = nil) { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { + self = .empty + return + } + guard trimmed.unicodeScalars.allSatisfy({ ("0"..."9").contains($0) }), let value = UInt64(trimmed) else { + self = .invalid(maximum: maximum) + return + } + // An exhausted counter sits one past the column's type; the server's own value is never the user's error. + guard value == original || (1...maximum).contains(value) else { + self = .invalid(maximum: maximum) + return + } + self = .value(value) + } + + // MySQL reads `INT UNSIGNED` back as INT with the column's own flag set. + static func maximum(for column: EditableColumnDefinition) -> UInt64 { + AutoIncrementRange.maximum(forColumnType: column.dataType, unsigned: column.unsigned) ?? .max + } + + var message: String? { + guard case .invalid(let maximum) = self else { return nil } + return String(format: String(localized: "Enter a whole number from 1 to %@."), String(maximum)) + } +} diff --git a/apple/TablePro/Views/Structure/TableStructureLoader.swift b/apple/TablePro/Views/Structure/TableStructureLoader.swift index 6dd58668e9..4d7a04bcd5 100644 --- a/apple/TablePro/Views/Structure/TableStructureLoader.swift +++ b/apple/TablePro/Views/Structure/TableStructureLoader.swift @@ -34,6 +34,8 @@ struct TableStructureLoader { let foreignKeys: [ForeignKeyInfo] /// Nil when the comment was not asked for. let tableComment: MetadataFetchOutcome? + /// Nil when the options were not asked for. + let tableOptions: MetadataFetchOutcome? } func columns() async throws -> [ColumnInfo] { @@ -71,6 +73,21 @@ struct TableStructureLoader { return try await perform { try await $0.fetchObjectComment(tableName: table) } } + func tableOptions() async throws -> TableOptions? { + let table = tableName + return try await perform { try await $0.fetchTableOptions(tableName: table) } + } + + func tableOptionsCatalog() async throws -> TableOptionsCatalog? { + try await perform { try await $0.fetchTableOptionsCatalog() } + } + + /// What a new table in this scope's schema takes when it names no character set. + func defaultTableOptions() async throws -> TableOptions? { + let schema = scope.schema + return try await perform { try await $0.fetchDefaultTableOptions(schema: schema) } + } + func concurrentRefreshAvailability() async throws -> PluginConcurrentRefreshAvailability? { try await MaterializedViewRefreshing.concurrentRefreshAvailability( of: DatabaseObjectTarget(name: tableName, type: .materializedView, schema: scope.schema, scope: scope), @@ -78,25 +95,39 @@ struct TableStructureLoader { ) } - /// A comment that fails to load is reported in the result, so the columns still land. - func coreTabs(includingForeignKeys: Bool, includingTableComment: Bool) async throws -> CoreTabs { + /// A comment or options read that fails is reported in the result, so the columns still land. + func coreTabs( + includingForeignKeys: Bool, + includingTableComment: Bool, + includingTableOptions: Bool = false + ) async throws -> CoreTabs { let table = tableName let schema = scope.schema return try await perform { driver in let columns = try await driver.fetchColumns(table: table, schema: schema) let indexes = try await driver.fetchIndexes(table: table, schema: schema) let foreignKeys = includingForeignKeys ? try await driver.fetchForeignKeys(table: table, schema: schema) : [] - var comment: MetadataFetchOutcome? - if includingTableComment { - do { - comment = .fetched(try await driver.fetchObjectComment(tableName: table)) - } catch is CancellationError { - comment = .cancelled - } catch { - comment = .failed(error.localizedDescription) - } - } - return CoreTabs(columns: columns, indexes: indexes, foreignKeys: foreignKeys, tableComment: comment) + let comment: MetadataFetchOutcome? = includingTableComment + ? await Self.outcome { try await driver.fetchObjectComment(tableName: table) } + : nil + let options: MetadataFetchOutcome? = includingTableOptions + ? await Self.outcome { try await driver.fetchTableOptions(tableName: table) } + : nil + return CoreTabs( + columns: columns, indexes: indexes, foreignKeys: foreignKeys, tableComment: comment, tableOptions: options + ) + } + } + + nonisolated private static func outcome( + of read: () async throws -> Value + ) async -> MetadataFetchOutcome { + do { + return .fetched(try await read()) + } catch is CancellationError { + return .cancelled + } catch { + return .failed(error.localizedDescription) } } diff --git a/apple/TablePro/Views/Structure/TableStructureView+DataLoading.swift b/apple/TablePro/Views/Structure/TableStructureView+DataLoading.swift deleted file mode 100644 index aa00fed716..0000000000 --- a/apple/TablePro/Views/Structure/TableStructureView+DataLoading.swift +++ /dev/null @@ -1,242 +0,0 @@ -// -// TableStructureView+DataLoading.swift -// TablePro -// - -import AppKit -import Combine -import os -import SwiftUI -import TableProPluginKit -import UniformTypeIdentifiers - -// MARK: - Data Loading - -extension TableStructureView { - @Sendable - func loadInitialData() async { - session.settleOwedRefetch() - guard !session.hasLoaded else { - isInitialLoading = false - isLoading = false - await session.reloadConcurrentRefreshAvailability() - return - } - await loadColumns() - for tab in session.tabsFetchedOnMount where tab != .columns { - guard !Task.isCancelled else { break } - await loadTabDataIfNeeded(tab) - } - // A workspace switch cancels this task and restarts it on return, so a cut-short load must not count. - guard !Task.isCancelled else { return } - await loadTableComment() - guard !Task.isCancelled else { return } - adoptFetchedBaseline() - session.hasLoaded = true - isInitialLoading = false - } - - func loadColumns() async { - isLoading = true - errorMessage = nil - - do { - columns = try await structureLoader.columns() - tabData.markFetched(.columns) - } catch { - if let message = fetchFailure(error).message { - errorMessage = message - } - } - - isLoading = false - } - - func loadTabDataIfNeeded(_ tab: StructureTab) async { - guard tabData.needsFetch(tab) else { return } - await fetchTabData(tab) - } - - func fetchTabData(_ tab: StructureTab) async { - do { - switch tab { - case .columns: - columns = try await structureLoader.columns() - case .indexes: - indexes = try await structureLoader.indexes() - await session.reloadConcurrentRefreshAvailability() - case .foreignKeys: - foreignKeys = try await structureLoader.foreignKeys() - case .checkConstraints: - checkConstraints = try await structureLoader.checkConstraints() - case .ddl: - let table = tableName - let schema = structureLoader.scope.schema - ddlStatement = try await structureLoader.perform { driver in - try await TableDDLComposer.fetchDDL( - for: table, using: driver, includesDependencies: true, schema: schema - ) - } - case .triggers: - do { - triggers = try await structureLoader.triggers() - } catch { - guard fetchFailure(error) != .cancelled else { return } - Self.logger.error("Failed to load triggers: \(error.publicLogShape, privacy: .public)") - triggers = [] - } - case .parts: - return - } - tabData.markFetched(tab) - } catch { - guard let message = fetchFailure(error).message else { return } - Self.logger.error("Failed to load \(tab.rawValue, privacy: .public): \(error.publicLogShape, privacy: .public)") - errorMessage = message - } - } - - private func fetchFailure(_ error: any Error) -> StructureFetchFailure { - StructureFetchFailure(error, taskIsCancelled: Task.isCancelled) - } - - func loadSchemaForEditing() { - session.adoptLoadedBaseline() - } - - // MARK: - Lifecycle Callbacks - - func onSelectedTabChanged(_ new: StructureTab) { - searchText = "" - structureSortDescriptor = nil - sortState = SortState() - gridAdapter.refresh() - Task { - await loadTabDataIfNeeded(new) - } - } - - func adoptFetchedBaseline() { - guard !structureChangeManager.hasChanges else { - session.markStructureStale() - return - } - loadSchemaForEditing() - } - - func rebaselineUnlessEdited() { - guard !isReloadingAfterSave, !isInitialLoading else { return } - adoptFetchedBaseline() - } - - func onColumnsChanged() { - rebaselineUnlessEdited() - } - - func onIndexesChanged() { - rebaselineUnlessEdited() - } - - func onCheckConstraintsChanged() { - rebaselineUnlessEdited() - } - - func onForeignKeysChanged() { - rebaselineUnlessEdited() - } - - func onRefreshData() { - guard !isReloadingAfterSave else { - Self.logger.debug("Ignoring refresh notification - currently reloading after save") - return - } - - let justSaved = lastSaveTime.map { Date().timeIntervalSince($0) < 2.0 } ?? false - - if structureChangeManager.hasChanges && !justSaved { - Task { @MainActor in - let window = coordinator?.contentWindow - let confirmed = await AlertHelper.confirmDestructive( - title: String(localized: "Discard Changes?"), - message: String(localized: "You have unsaved changes to the table structure. Refreshing will discard these changes."), - confirmButton: String(localized: "Discard"), - cancelButton: String(localized: "Cancel"), - window: window - ) - - if confirmed { - discardChanges() - await reloadAllTabs() - } - } - } else { - Task { @MainActor in - await reloadAllTabs() - } - } - } - - private func reloadAllTabs() async { - session.markEveryTabStale() - gridAdapter.referenceMenus.invalidateTableLists() - partsReloadToken += 1 - await reloadCoreTabs() - if selectedTab == .ddl { - await fetchTabData(.ddl) - } - if selectedTab == .triggers, connection.type.supportsTriggers { - await fetchTabData(.triggers) - } - } - - func reloadCoreTabs() async { - isLoading = true - errorMessage = nil - defer { isLoading = false } - - let includesForeignKeys = connection.type.supportsForeignKeys - do { - let reloaded = try await structureLoader.coreTabs( - includingForeignKeys: includesForeignKeys, - includingTableComment: offersTableComment - ) - - if let comment = reloaded.tableComment { - session.settleTableComment(comment) - } - columns = reloaded.columns - indexes = reloaded.indexes - tabData.markFetched(.columns) - tabData.markFetched(.indexes) - if includesForeignKeys { - foreignKeys = reloaded.foreignKeys - tabData.markFetched(.foreignKeys) - } - await session.reloadConcurrentRefreshAvailability() - } catch { - guard let message = fetchFailure(error).message else { return } - Self.logger.error("Failed to reload structure: \(error.publicLogShape, privacy: .public)") - errorMessage = message - } - } -} - -internal enum StructureFetchFailure: Equatable { - case cancelled - case failed(String) - - internal init(_ error: any Error, taskIsCancelled: Bool) { - if error is CancellationError || taskIsCancelled { - self = .cancelled - } else { - self = .failed(error.localizedDescription) - } - } - - internal var message: String? { - switch self { - case .cancelled: nil - case .failed(let message): message - } - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView+EditGate.swift b/apple/TablePro/Views/Structure/TableStructureView+EditGate.swift deleted file mode 100644 index 92fdacfe69..0000000000 --- a/apple/TablePro/Views/Structure/TableStructureView+EditGate.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// TableStructureView+EditGate.swift -// TablePro -// - -import Foundation -import TableProPluginKit - -extension TableStructureView { - var editGate: StructureEditGate { - StructureEditGate( - databaseType: connection.type, objectKind: objectKind, schema: schemaName, isSessionBound: isSessionBound - ) - } - - var foreignKeyEditAvailability: ForeignKeyEditAvailability { - ForeignKeyEditPolicy.resolve( - support: PluginManager.shared.foreignKeyEditSupport(for: connection.type), - engineName: connection.type.displayName, - kindRefusal: editGate.kindRefusal(.addForeignKey), - canEditSchema: editGate.canEditSchema - ) - } - - func publishFooterCapability() { - let gate = editGate - let footer = StructureFooterPolicy.resolve( - tab: selectedTab, - canEditSchema: gate.canEditSchema, - hasSelection: !session.gridModel.selection.isEmpty, - isSaving: structureChangeManager.isHeldForSave, - resolve: { gate.resolve($0) } - ) - // Published on every write, and this runs on every selection change. - guard session.footer != footer else { return } - session.footer = footer - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView+Schema.swift b/apple/TablePro/Views/Structure/TableStructureView+Schema.swift deleted file mode 100644 index e76b921e7e..0000000000 --- a/apple/TablePro/Views/Structure/TableStructureView+Schema.swift +++ /dev/null @@ -1,187 +0,0 @@ -// -// TableStructureView+Schema.swift -// TablePro -// - -import AppKit -import Combine -import os -import SwiftUI -import TableProPluginKit -import UniformTypeIdentifiers - -// MARK: - Schema Operations - -extension TableStructureView { - func generateStructurePreviewSQL() { - let changes = structureChangeManager.getChangesArray() - guard !changes.isEmpty else { - // An undo back to a clean state would otherwise leave the last preview's SQL behind. - toolbarState.previewStatements = [] - return - } - - if skipSchemaPreview { - Task { _ = await session.applyStagedChanges(coordinator: coordinator) } - return - } - - guard let coordinator else { return } - Task { await session.previewStagedChanges(coordinator: coordinator) } - } - - func refreshAfterApply() async { - isReloadingAfterSave = true - await reloadCoreTabs() - adoptFetchedBaseline() - await loadTabDataIfNeeded(selectedTab) - - gridAdapter.refresh() - - // Left false, the next mount re-baselines the change manager and drops anything staged since. - session.hasLoaded = true - isReloadingAfterSave = false - } - - func discardChanges() { - session.gridModel.discardEditing() - structureChangeManager.discardChanges() - gridAdapter.refresh() - } - - // MARK: - DDL View - - var ddlView: some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - HStack(spacing: 4) { - Button(action: { ddlFontSize = max(10, ddlFontSize - 1) }) { - Image(systemName: "textformat.size.smaller") - .frame(width: 24, height: 24) - } - .accessibilityLabel(String(localized: "Decrease font size")) - Text("\(Int(ddlFontSize))") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 24) - Button(action: { ddlFontSize = min(24, ddlFontSize + 1) }) { - Image(systemName: "textformat.size.larger") - .frame(width: 24, height: 24) - } - .accessibilityLabel(String(localized: "Increase font size")) - } - .buttonStyle(.borderless) - - Spacer() - - if showCopyConfirmation { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("Copied!") - } - .transition(.opacity) - } - - Button(action: openInEditor) { - Label("Open in Editor", systemImage: "square.and.pencil") - } - .buttonStyle(.bordered) - .disabled(ddlStatement.isEmpty) - - Button(action: copyDDL) { - Label("Copy", systemImage: "doc.on.doc") - } - .buttonStyle(.bordered) - - Button(action: exportDDL) { - Label("Export", systemImage: "square.and.arrow.up") - } - .buttonStyle(.bordered) - } - .padding() - .paneBarBackground() - - Divider() - - if ddlStatement.isEmpty { - StructureDDLUnavailableView() - } else { - DDLTextView(ddl: ddlStatement, fontSize: $ddlFontSize, databaseType: connection.type) - } - } - } - - // MARK: - DDL Actions - - private func openInEditor() { - guard !ddlStatement.isEmpty else { return } - coordinator?.tabManager.addTab( - initialQuery: ddlStatement, - title: "\(tableName) DDL" - ) - } - - func openTriggerInEditor(_ trigger: TriggerInfo) { - guard !trigger.statement.isEmpty else { return } - coordinator?.tabManager.addTab( - initialQuery: trigger.statement, - title: trigger.name - ) - } - - private func copyDDL() { - ClipboardService.shared.writeText(ddlStatement) - - withMotion { - showCopyConfirmation = true - } - - copyResetTask?.cancel() - copyResetTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(1_500)) - guard !Task.isCancelled else { return } - withMotion { - showCopyConfirmation = false - } - } - } - - private func exportDDL() { - let savePanel = NSSavePanel() - if let sqlType = UTType(filenameExtension: "sql") { - savePanel.allowedContentTypes = [sqlType] - } - savePanel.nameFieldStringValue = "\(tableName).sql" - - guard let window = coordinator?.contentWindow else { return } - savePanel.beginSheetModal(for: window) { response in - guard response == .OK, let url = savePanel.url else { return } - do { - try ddlStatement.write(to: url, atomically: true, encoding: .utf8) - } catch { - Self.logger.error("Failed to export: \(error.publicLogShape, privacy: .public)") - AlertHelper.showErrorSheet( - title: String(localized: "Could not export the schema"), - message: error.localizedDescription, - window: window - ) - } - } - } -} - -internal struct StructureDDLUnavailableView: View { - internal var body: some View { - VStack(spacing: 8) { - Image(systemName: "tray") - .font(.largeTitle) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - Text(String(localized: "No DDL available")) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .valueStandInBackground() - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView+TableComment.swift b/apple/TablePro/Views/Structure/TableStructureView+TableComment.swift deleted file mode 100644 index 0a1fb9e5de..0000000000 --- a/apple/TablePro/Views/Structure/TableStructureView+TableComment.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// TableStructureView+TableComment.swift -// TablePro -// - -import AppKit -import os -import SwiftUI - -internal extension TableStructureView { - var tableCommentSupport: DatabaseObjectToolEligibility.Support { - DatabaseObjectToolEligibility.Support.of(DatabaseManager.shared.driver(for: connection.id)) - } - - var offersTableComment: Bool { - tableCommentSupport.commentableTypes.contains(objectKind) - } - - var tableCommentField: StructureTableCommentPolicy.Field? { - StructureTableCommentPolicy.resolve( - objectKind: objectKind, - support: tableCommentSupport, - isReadOnly: toolbarState.safeModeLevel.blocksAllWrites || isSessionBound, - load: session.tableComment.erased, - isSaving: structureChangeManager.isHeldForSave - ) - } - - /// One model write per keystroke, so the staged change and its undo run never see a stale value. - private var tableCommentBinding: Binding { - Binding( - get: { structureChangeManager.tableComment.text }, - set: { structureChangeManager.stageTableComment($0) } - ) - } - - @ViewBuilder - var tableCommentBar: some View { - if let field = tableCommentField { - HStack(alignment: .firstTextBaseline, spacing: 12) { - Text("Comment:") - .font(.body.weight(.medium)) - - TextField(field.placeholder, text: tableCommentBinding, axis: .vertical) - .lineLimit(1...4) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - .disabled(!field.isEditable) - .focused($isTableCommentFocused) - .onSubmit { structureChangeManager.endTableCommentRun() } - .help(field.unavailableReason ?? "") - .accessibilityLabel(String(localized: "Comment")) - .accessibilityIdentifier("structure-table-comment") - } - .padding() - .paneBarBackground() - - Divider() - } - } - - func loadTableComment() async { - guard offersTableComment else { return } - session.beginTableCommentLoad() - let outcome: MetadataFetchOutcome - do { - outcome = .fetched(try await structureLoader.tableComment()) - } catch { - switch StructureFetchFailure(error, taskIsCancelled: Task.isCancelled) { - case .cancelled: - outcome = .cancelled - case .failed(let message): - Self.logger.error("Failed to load the table comment: \(error.publicLogShape, privacy: .public)") - outcome = .failed(message) - } - } - session.settleTableComment(outcome) - } - - /// A comment changed elsewhere refetches columns identical to the ones loaded, so the columns' - /// own handler never fires and the baseline has to move here. - func onTableCommentChanged() { - rebaselineUnlessEdited() - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView.swift b/apple/TablePro/Views/Structure/TableStructureView.swift index c7f4083ba7..3ca0325335 100644 --- a/apple/TablePro/Views/Structure/TableStructureView.swift +++ b/apple/TablePro/Views/Structure/TableStructureView.swift @@ -3,255 +3,61 @@ // TablePro // -import AppKit -import Combine -import os import SwiftUI -import TableProPluginKit -import UniformTypeIdentifiers +// The Structure view of a table tab: Definition, Triggers, DDL and, on ClickHouse, Parts. It draws the session and +// hands it the window's state; loading, staging and saving live in the session. struct TableStructureView: View { - static let logger = Logger(subsystem: "com.TablePro", category: "TableStructureView") - static let structurePasteboardType = NSPasteboard.PasteboardType("com.TablePro.structure") - static let structureSourceTypePasteboardType = NSPasteboard.PasteboardType("com.TablePro.structure.database-type") - - static var canPasteStructureRows: Bool { - NSPasteboard.general.data(forType: structurePasteboardType) != nil - } - let tableName: String - let connection: DatabaseConnection - let databaseName: String - let schemaName: String? - + @ObservedObject var session: StructureEditingSession @ObservedObject var toolbarState: ConnectionToolbarState let coordinator: MainContentCoordinator? - @Environment(\.appServices) var services - - var scope: DatabaseScope { - DatabaseScope( - connectionId: connection.id, database: databaseName, schema: schemaName, isSessionBound: isSessionBound - ) - } - - var isSessionBound: Bool { session.isSessionBound } - - var structureLoader: TableStructureLoader { - TableStructureLoader(scope: scope, tableName: tableName) - } - - @ObservedObject var session: StructureEditingSession - - var objectKind: TableInfo.TableType { session.objectKind } - - var selectedTab: StructureTab { - get { session.selectedTab } - nonmutating set { session.selectedTab = newValue } - } - - var searchText: String { - get { session.searchText } - nonmutating set { session.searchText = newValue } - } - - var sortState: SortState { - get { session.sortState } - nonmutating set { session.sortState = newValue } - } - - var structureSortDescriptor: StructureSortDescriptor? { - get { session.sortDescriptor } - nonmutating set { session.sortDescriptor = newValue } - } - - var isReloadingAfterSave: Bool { - get { session.isApplying } - nonmutating set { session.isApplying = newValue } - } - - var lastSaveTime: Date? { - get { session.lastAppliedAt } - nonmutating set { session.lastAppliedAt = newValue } - } - - var gridAdapter: StructureGridAdapter { session.gridAdapter } - - var columns: [ColumnInfo] { - get { session.columns } - nonmutating set { session.columns = newValue } - } - - var indexes: [IndexInfo] { - get { session.indexes } - nonmutating set { session.indexes = newValue } - } - - var foreignKeys: [ForeignKeyInfo] { - get { session.foreignKeys } - nonmutating set { session.foreignKeys = newValue } - } - - var checkConstraints: [CheckConstraintInfo] { - get { session.checkConstraints } - nonmutating set { session.checkConstraints = newValue } - } - - var triggers: [TriggerInfo] { - get { session.triggers } - nonmutating set { session.triggers = newValue } - } - - var ddlStatement: String { - get { session.ddlStatement } - nonmutating set { session.ddlStatement = newValue } - } - - var tabData: StructureTabDataState { - get { session.tabData } - nonmutating set { session.tabData = newValue } - } - - // Observed on its own: a change inside it fires its publisher, never the session's. - @ObservedObject var structureChangeManager: StructureChangeManager - - @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) var ddlFontSize: Double = 13 - @State var showCopyConfirmation = false - @State var copyResetTask: Task? - @State var isLoading = true - @State var isInitialLoading = true - @State var errorMessage: String? - @State var partsReloadToken = 0 - @AppStorage("skipSchemaPreview", store: AppStorageEnvironment.shared.defaults) var skipSchemaPreview = false - - @State var actionHandler = StructureViewActionHandler() - @FocusState var isTableCommentFocused: Bool - - init( - tableName: String, - connection: DatabaseConnection, - databaseName: String, - schemaName: String?, - toolbarState: ConnectionToolbarState, - coordinator: MainContentCoordinator?, - session: StructureEditingSession - ) { - self.tableName = tableName - self.connection = connection - self.databaseName = databaseName - self.schemaName = schemaName - self.toolbarState = toolbarState - self.coordinator = coordinator - self.session = session - self.structureChangeManager = session.changeManager - } - var body: some View { VStack(spacing: 0) { - tableCommentBar - toolbar + paneBar Divider() - contentArea + paneContent .frame(maxWidth: .infinity, maxHeight: .infinity) } - .task(loadInitialData) - .onReceive(session.gridModel.$selectionRevision) { _ in - publishFooterCapability() - } - .onChange(of: selectedTab) { newValue in - onSelectedTabChanged(newValue) - publishFooterCapability() + .task { await session.load() } + .onChange(of: session.pane) { pane in + Task { await session.loadPaneIfNeeded(pane) } } - .onChange(of: columns) { _ in onColumnsChanged() } - .onChange(of: indexes) { _ in onIndexesChanged() } - .onChange(of: foreignKeys) { _ in onForeignKeysChanged() } - .onChange(of: checkConstraints) { _ in onCheckConstraintsChanged() } - .onChange(of: session.tableComment) { _ in onTableCommentChanged() } - .onChange(of: isTableCommentFocused) { isFocused in - if !isFocused { structureChangeManager.endTableCommentRun() } - } - .onChange(of: searchText) { _ in gridAdapter.refresh() } - .onChange(of: session.serverSupport) { _ in gridAdapter.refresh() } .onAppear { - coordinator?.toolbarState.hasStructureChanges = structureChangeManager.hasChanges - coordinator?.inspectorRowSource = gridAdapter - - gridAdapter.coordinator = coordinator - gridAdapter.moveRowHandler = { [self] fromIndex, toIndex in - beginColumnReorder(fromIndex: fromIndex, toIndex: toIndex) - } - gridAdapter.refresh() - - actionHandler.previewSQL = { self.generateStructurePreviewSQL() } - actionHandler.copyRows = { self.gridAdapter.copyRows(self.session.gridModel.selectedRowIDs) } - actionHandler.pasteRows = { self.gridAdapter.pasteRows() } - actionHandler.undo = { self.gridAdapter.undo() } - actionHandler.redo = { self.gridAdapter.redo() } - actionHandler.addRow = { self.gridAdapter.addRow() } - actionHandler.removeRow = { self.gridAdapter.deleteRows(self.session.gridModel.selectedRowIDs) } - actionHandler.refresh = { self.onRefreshData() } - coordinator?.structureActions = actionHandler - publishFooterCapability() - } - .onDisappear { - structureChangeManager.endTableCommentRun() - // Guarded by identity: SwiftUI does not order the outgoing view's onDisappear before the - // incoming one's onAppear, so an unguarded clear can nil the wiring another tab installed. - if coordinator?.structureActions === actionHandler { - coordinator?.structureActions = nil - coordinator?.toolbarState.hasStructureChanges = false - } - if coordinator?.inspectorRowSource === gridAdapter { - coordinator?.inspectorRowSource = nil - } + session.coordinator = coordinator + syncReadOnly() } - .onChange(of: structureChangeManager.hasChanges) { newValue in - coordinator?.toolbarState.hasStructureChanges = newValue - gridAdapter.refresh() - if !newValue, session.settleOwedRefetch() { - Task { await loadInitialData() } + .onChange(of: session.hasChanges) { hasChanges in + if !hasChanges, session.settleOwedRefetch() { + Task { await session.load() } } } - .onChange(of: structureChangeManager.isHeldForSave) { _ in - publishFooterCapability() - gridAdapter.refresh() - } - .onChange(of: session.appliedVersion) { _ in - Task { await refreshAfterApply() } - } - // A mutation that leaves hasChanges as it was, such as a second added row, moves only this. - .onChange(of: structureChangeManager.reloadVersion) { _ in - gridAdapter.refresh() - } + .onChange(of: toolbarState.safeModeLevel) { _ in syncReadOnly() } } - // MARK: - Toolbar - - private var availableTabs: [StructureTab] { - session.availableTabs + private func syncReadOnly() { + let blocksWrites = toolbarState.safeModeLevel.blocksAllWrites + session.editor.readOnlyReason = blocksWrites + ? String(localized: "Safe Mode is read-only for this connection.") + : nil + session.editor.isReadOnly = blocksWrites } - private var toolbar: some View { - HStack { - Spacer() - - Picker("Structure", selection: $session.selectedTab) { - ForEach(availableTabs, id: \.self) { tab in - Text(tabLabel(for: tab)).tag(tab) - } + private var paneBar: some View { + PaneTabBar( + title: String(localized: "Structure"), + selection: $session.pane, + identifier: "structure-tab-picker" + ) { + ForEach(session.availablePanes, id: \.self) { pane in + Text(pane.displayName).tag(pane) } - .pickerStyle(.segmented) - .labelsHidden() - .monospacedDigit() - .accessibilityIdentifier("structure-tab-picker") - - Spacer() } - .padding() .overlay(alignment: .trailing) { - if structureChangeManager.isHeldForSave { + if session.changeManager.isHeldForSave { HStack(spacing: 6) { ProgressView().controlSize(.small) - Text("Saving Changes…") + Text(String(localized: "Saving Changes…")) .font(.callout) .foregroundStyle(.secondary) } @@ -262,180 +68,67 @@ struct TableStructureView: View { } } - // MARK: - Tab Label with Count Badge - - private func tabLabel(for tab: StructureTab) -> String { - StructureTabDataState.label(for: tab, count: loadedCount(for: tab)) - } - - private func loadedCount(for tab: StructureTab) -> Int? { - guard tabData.hasData(tab) else { return nil } - switch tab { - case .columns: return columns.count - case .indexes: return indexes.count - case .foreignKeys: return foreignKeys.count - case .triggers: return triggers.count - case .checkConstraints: return checkConstraints.count - case .ddl, .parts: return nil - } - } - - // MARK: - Content Area - - @ViewBuilder - private var contentArea: some View { - if let error = errorMessage { - errorView(error) - .gridStandInBackground() - } else { - tabContent - } - } - @ViewBuilder - private var tabContent: some View { - switch selectedTab { - case .columns: - structureGrid - case .indexes: - Group { - if shouldShowIndexesEmptyState { - EmptyStateView.indexes { gridAdapter.addRow() } - .gridStandInBackground() - } else { - structureGrid - } - } - .safeAreaInset(edge: .bottom, spacing: 0) { - indexesTabNotes - } - case .foreignKeys: - if shouldShowForeignKeysEmptyState { - EmptyStateView.foreignKeys { gridAdapter.addRow() } - .gridStandInBackground() - } else { - structureGrid - } - case .checkConstraints: - if shouldShowCheckConstraintsEmptyState { - EmptyStateView.checkConstraints { gridAdapter.addRow() } - .gridStandInBackground() - } else { - structureGrid - } + private var paneContent: some View { + switch session.pane { + case .definition: + StructureDefinitionPage( + editor: session.editor, + loadState: session.loadState, + isLoading: session.isLoadingFirstTime, + notes: [.indexes: indexNotes], + onRetry: { session.retry(StructureLoadState.Item($0)) } + ) case .triggers: TriggerDetailView( - triggers: triggers, - scope: scope, - connection: connection, - tableName: tableName, - isLoading: !tabData.hasData(.triggers), - canEdit: editGate.allowsTriggerEditing, + triggers: session.triggers, + scope: session.scope, + connection: session.connection, + tableName: session.tableName, + isLoading: !session.loadState.hasData(.triggers), + failure: session.loadState.failure(.triggers), + onRetry: { session.retry(.triggers) }, + canEdit: session.editor.context.editGate.allowsTriggerEditing, onOpenInEditor: openTriggerInEditor ) case .ddl: - ddlView + StructureDDLPane( + ddl: session.ddlStatement, + tableName: session.tableName, + databaseType: session.connection.type, + failure: session.loadState.failure(.ddl), + isLoading: !session.loadState.hasData(.ddl), + coordinator: coordinator, + onRetry: { session.retry(.ddl) } + ) case .parts: ClickHousePartsView( - tableName: tableName, - scope: scope, - connection: connection, - reloadToken: partsReloadToken + tableName: session.tableName, + scope: session.scope, + connection: session.connection, + reloadToken: session.partsReloadToken ) } } - private var indexesTabNotes: some View { - VStack(spacing: 0) { - if let note = InvalidIndexNote(indexes: indexes) { - StructureTabNoteView( - systemImage: note.systemImage, - text: note.text, - identifier: "structure-invalid-index-note" - ) - } - if objectKind == .materializedView, - let note = MaterializedViewConcurrentRefreshNote(state: session.concurrentRefresh) { - StructureTabNoteView( - systemImage: note.systemImage, - text: note.text, - identifier: "structure-concurrent-refresh-note" - ) - } + private var indexNotes: [StructureSectionNote] { + var notes: [StructureSectionNote] = [] + if let note = InvalidIndexNote(indexes: session.indexes) { + notes.append(StructureSectionNote( + systemImage: note.systemImage, text: note.text, identifier: "structure-invalid-index-note" + )) } - } - - private var shouldShowIndexesEmptyState: Bool { - tabData.hasData(.indexes) - && structureChangeManager.workingIndexes.isEmpty - && editGate.allows(.addIndex) - } - - private var shouldShowForeignKeysEmptyState: Bool { - tabData.hasData(.foreignKeys) - && structureChangeManager.workingForeignKeys.isEmpty - && connection.type.supportsForeignKeys - && editGate.allows(.addForeignKey) - } - - private var shouldShowCheckConstraintsEmptyState: Bool { - tabData.hasData(.checkConstraints) - && structureChangeManager.workingCheckConstraints.isEmpty - && editGate.allows(.addCheckConstraint) - } - - // MARK: - Structure Grid - - private var structureGrid: some View { - DataGridView(model: session.gridModel) - .safeAreaInset(edge: .top, spacing: 0) { - VStack(spacing: 0) { - NativeSearchField(text: $session.searchText, placeholder: String(localized: "Filter")) - .padding(.horizontal, 6) - .padding(.vertical, 4) - Divider() - } - } - } - - // MARK: - Helper Views - - func errorView(_ message: String) -> some View { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundStyle(.orange) - .accessibilityHidden(true) - RevealedTextView(message) - .foregroundStyle(.secondary) + if session.objectKind == .materializedView, + let note = MaterializedViewConcurrentRefreshNote(state: session.concurrentRefresh) { + notes.append(StructureSectionNote( + systemImage: note.systemImage, text: note.text, identifier: "structure-concurrent-refresh-note" + )) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + return notes } -} -#Preview { - let connection = DatabaseConnection( - name: "Test", - host: "localhost", - port: 3_306, - database: "test", - username: "root", - type: .mysql - ) - return TableStructureView( - tableName: "users", - connection: connection, - databaseName: "test", - schemaName: nil, - toolbarState: ConnectionToolbarState(), - coordinator: nil, - session: StructureEditingSession( - identity: "test.users", - connection: connection, - databaseName: "test", - schemaName: nil, - tableName: "users" - ) - ) - .frame(width: 800, height: 600) + private func openTriggerInEditor(_ trigger: TriggerInfo) { + guard !trigger.statement.isEmpty else { return } + coordinator?.tabManager.addTab(initialQuery: trigger.statement, title: trigger.name) + } } diff --git a/apple/TablePro/Views/Structure/TriggerDetailView.swift b/apple/TablePro/Views/Structure/TriggerDetailView.swift index 7820902422..fb869f481a 100644 --- a/apple/TablePro/Views/Structure/TriggerDetailView.swift +++ b/apple/TablePro/Views/Structure/TriggerDetailView.swift @@ -108,6 +108,8 @@ struct TriggerDetailView: View { let connection: DatabaseConnection let tableName: String let isLoading: Bool + var failure: String? + var onRetry: () -> Void = {} let canEdit: Bool let onOpenInEditor: (TriggerInfo) -> Void @@ -117,7 +119,17 @@ struct TriggerDetailView: View { @State private var actionError: String? var body: some View { - if isLoading { + if let failure, triggers.isEmpty { + UnavailableStateView { + Label(String(localized: "Couldn't Read the Triggers"), systemImage: "exclamationmark.triangle") + } description: { + Text(failure) + } actions: { + Button(String(localized: "Try Again"), action: onRetry) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() + } else if isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) .gridStandInBackground() @@ -279,7 +291,7 @@ internal struct TriggerListPane: View { @ObservedObject internal var state: TriggerInspectorState @StateObject private var grid = StructureListGridModel( - accessibilityLabel: StructureTab.triggers.displayName + accessibilityLabel: StructurePane.triggers.displayName ) private var showsEnabled: Bool { triggers.contains { $0.enabled != nil } } diff --git a/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift b/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift index 7f19473eb0..aea26eaa01 100644 --- a/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift +++ b/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift @@ -43,8 +43,8 @@ struct PrincipalDetailPane: View { Text(segment.title).tag(segment) } } - .pickerStyle(.segmented) .labelsHidden() + .viewSwitcherStyle() .fixedSize() .accessibilityIdentifier("usersroles-detail-segment") } diff --git a/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift b/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift index b543a45e93..a26e3ef286 100644 --- a/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift +++ b/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift @@ -21,6 +21,7 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, var principal: PluginPrincipalRef? private var isRestoringExpansion = false + private var isRestoringSelection = false private var pendingExpansions: Set = [] private var nodeChanges: AnyCancellable? @@ -115,10 +116,16 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, } func restoreExpansion() { - guard viewModel.privilegeTree.mode == .hierarchy else { return } + guard viewModel.privilegeTree.mode == .hierarchy else { + restoreSelection() + return + } let saved = Set(viewModel.expansionStore.load()) - guard !saved.isEmpty else { return } + guard !saved.isEmpty else { + restoreSelection() + return + } Task { @MainActor in isRestoringExpansion = true @@ -137,13 +144,29 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, } frontier = next } + restoreSelection() } } + /// The outline is rebuilt each time Privileges is shown again, while the checklist keeps acting + /// on the scopes the model holds, so the rows for those scopes are selected again. A scope inside + /// a collapsed node has no row, and the model keeps it rather than shrinking to what is visible. + func restoreSelection() { + guard let outlineView, !viewModel.selectedScopes.isEmpty else { return } + let rows = IndexSet((0.. Bool { // A build without notes records nothing, so a Debug run never marks a release as seen. guard WhatsNewContent.load(from: bundle) != nil else { return false } - let key = PreferenceKeys.lastSeenAppVersion.name - let current = bundle.appVersion - let previous = defaults.string(forKey: key) + // Sparkle orders releases by CFBundleVersion, which RELEASING.md numbers so a beta sorts below its stable. + let current = bundle.buildNumber + let key = PreferenceKeys.lastSeenAppBuild.name + guard let seen = defaults.object(forKey: key) as? Int else { + defaults.set(current, forKey: key) + // Every build that recorded only the version is older than this one. + let legacy = defaults.string(forKey: PreferenceKeys.lastSeenAppVersion.name) + return legacy.map { $0 != bundle.appVersion } ?? false + } + // An older build writes nothing, so going back to the newer one does not offer its notes again. + guard seen < current else { return false } defaults.set(current, forKey: key) - return previous.map { $0 != current } ?? false + return true } } diff --git a/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index 1111957986..cc0065f44b 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -338,15 +338,14 @@ nonisolated enum SQLBuilder { let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !columns.isEmpty else { return "" } - let dialect = dialectDescriptor(for: type) - let pattern = escapeLikePattern(trimmed, dialect: dialect) - let likeEscape: String = dialect.likeEscapeStyle == .explicit ? " ESCAPE '!'" : "" + let generator = filterGenerator(dialect: dialectDescriptor(for: type), type: type) + let pattern = generator.likePatternBody(trimmed) let conditions = columns.map { col -> String in let quotedCol = quoteIdentifier(col.name, for: type) let castExpr = searchCastExpression(quotedCol, for: type) let likeOp = (type == .postgresql || type == .redshift) ? "ILIKE" : "LIKE" - return "\(castExpr) \(likeOp) '%\(pattern)%'\(likeEscape)" + return "\(castExpr) \(likeOp) '%\(pattern)%'\(generator.likeEscapeClause)" } return "(\(conditions.joined(separator: " OR ")))" @@ -370,25 +369,6 @@ nonisolated enum SQLBuilder { } } - private static func escapeLikePattern(_ value: String, dialect: SQLDialectDescriptor) -> String { - var result = value - .replacingOccurrences(of: "'", with: "''") - .replacingOccurrences(of: "\0", with: "") - if dialect.likeEscapeStyle == .explicit { - result = result - .replacingOccurrences(of: "!", with: "!!") - .replacingOccurrences(of: "%", with: "!%") - .replacingOccurrences(of: "_", with: "!_") - } else { - if dialect.requiresBackslashEscaping { - result = result.replacingOccurrences(of: "\\", with: "\\\\") - } - result = result.replacingOccurrences(of: "%", with: "\\%") - result = result.replacingOccurrences(of: "_", with: "\\_") - } - return result - } - private static func buildOrderByClause(_ sortState: SortState, for type: DatabaseType) -> String { guard sortState.isSorting else { return "" } let clauses = sortState.columns.map { col in diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index 5e685cb14e..d20e9d9398 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -10363,6 +10363,10 @@ }, "Folder Deleted" : { + }, + "Foreign key enforcement could not be put back while a transaction is open." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Foreign Keys" : { "localizations" : { diff --git a/apple/TableProMobile/TableProMobile/Onboarding/FirstRunPlan.swift b/apple/TableProMobile/TableProMobile/Onboarding/FirstRunPlan.swift index a04718ab8a..064eb5b518 100644 --- a/apple/TableProMobile/TableProMobile/Onboarding/FirstRunPlan.swift +++ b/apple/TableProMobile/TableProMobile/Onboarding/FirstRunPlan.swift @@ -45,6 +45,11 @@ nonisolated struct FirstRunPlan: Sendable { private var isUpgrade: Bool { guard let lastSeenVersion else { return false } - return lastSeenVersion != currentVersion + return Self.isNewer(currentVersion, than: lastSeenVersion) + } + + // The App Store takes only period-separated integers as a version, which a numeric comparison orders. + static func isNewer(_ version: String, than seen: String) -> Bool { + seen.compare(version, options: .numeric) == .orderedAscending } } diff --git a/apple/TableProMobile/TableProMobile/Platform/OnboardingPreferences.swift b/apple/TableProMobile/TableProMobile/Platform/OnboardingPreferences.swift index 82626d932f..fe009431d2 100644 --- a/apple/TableProMobile/TableProMobile/Platform/OnboardingPreferences.swift +++ b/apple/TableProMobile/TableProMobile/Platform/OnboardingPreferences.swift @@ -46,8 +46,9 @@ final class OnboardingPreferences { defaults.set(true, forKey: Self.hasSeenWelcomeKey) } + // An older build writes nothing, so going back to the newer one does not show its highlights again. func recordLaunch(version: String) { - guard lastSeenVersion != version else { return } + if let lastSeenVersion, !FirstRunPlan.isNewer(version, than: lastSeenVersion) { return } lastSeenVersion = version defaults.set(version, forKey: Self.lastSeenVersionKey) } diff --git a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index d89527b5ae..1ee644392c 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -9,7 +9,7 @@ enum IOSConnectionImportService { // Mac plugin type ids with no DatabaseType constant. static let recognizedTypeIds: Set = Set( (DatabaseType.allKnownTypes + [.cockroachdb, .scylladb, .turso]).map(\.rawValue) - ).union(["SAP HANA", "Elasticsearch", "Typesense", "PGlite", "Presto"]) + ).union(["SAP HANA", "Elasticsearch", "Typesense", "PGlite", "Presto", "KingbaseES"]) static func environment(for appState: AppState) -> ImportEnvironment { ImportEnvironment( diff --git a/apple/TableProMobile/TableProMobile/Views/Components/BottomSafeAreaBar.swift b/apple/TableProMobile/TableProMobile/Views/Components/BottomSafeAreaBar.swift deleted file mode 100644 index 719b06f88d..0000000000 --- a/apple/TableProMobile/TableProMobile/Views/Components/BottomSafeAreaBar.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -extension View { - func bottomSafeAreaBar(spacing: CGFloat? = 0, @ViewBuilder _ bar: () -> Bar) -> some View { - modifier(BottomSafeAreaBar(spacing: spacing, bar: bar())) - } -} - -private struct BottomSafeAreaBar: ViewModifier { - let spacing: CGFloat? - let bar: Bar - - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.safeAreaBar(edge: .bottom, spacing: spacing) { bar } - } else { - content.safeAreaInset(edge: .bottom, spacing: spacing) { - bar.background(.bar) - } - } - } -} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/SafeAreaBar.swift b/apple/TableProMobile/TableProMobile/Views/Components/SafeAreaBar.swift new file mode 100644 index 0000000000..4173f1a675 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/SafeAreaBar.swift @@ -0,0 +1,27 @@ +import SwiftUI + +extension View { + func bottomSafeAreaBar(spacing: CGFloat? = 0, @ViewBuilder _ bar: () -> Bar) -> some View { + modifier(SafeAreaBar(edge: .bottom, spacing: spacing, bar: bar())) + } + + func topSafeAreaBar(spacing: CGFloat? = 0, @ViewBuilder _ bar: () -> Bar) -> some View { + modifier(SafeAreaBar(edge: .top, spacing: spacing, bar: bar())) + } +} + +private struct SafeAreaBar: ViewModifier { + let edge: VerticalEdge + let spacing: CGFloat? + let bar: Bar + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.safeAreaBar(edge: edge, spacing: spacing) { bar } + } else { + content.safeAreaInset(edge: edge, spacing: spacing) { + bar.background(.bar) + } + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 16b26b208b..68eb75f77f 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -677,7 +677,7 @@ struct ConnectionListView: View { .disabled(!appState.connections.contains(where: \.participatesInSync)) } } label: { - Label("More", systemImage: "ellipsis.circle") + Label("More", systemImage: ToolbarSymbols.more()) } } diff --git a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 9325795562..b70ab4aa42 100644 --- a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -468,7 +468,7 @@ struct DataBrowserView: View { } } } label: { - Label("More", systemImage: "ellipsis.circle") + Label("More", systemImage: ToolbarSymbols.more()) } } if canInsertRow { @@ -500,17 +500,13 @@ struct DataBrowserView: View { .pickerStyle(.inline) } } label: { - Label("Sort", systemImage: viewModel.sortState.isSorting - ? "arrow.up.arrow.down.circle.fill" - : "arrow.up.arrow.down.circle") + Label("Sort", systemImage: ToolbarSymbols.sort(isActive: viewModel.sortState.isSorting)) } .disabled(columns.isEmpty) } ToolbarItem(placement: .topBarTrailing) { Button { showFilterSheet = true } label: { - Label("Filter", systemImage: viewModel.hasActiveFilters - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle") + Label("Filter", systemImage: ToolbarSymbols.filter(isActive: viewModel.hasActiveFilters)) } .badge(viewModel.activeFilterCount) } diff --git a/apple/TableProMobile/TableProMobile/Views/StructureView.swift b/apple/TableProMobile/TableProMobile/Views/StructureView.swift index 7f875e3d95..8c02b240e0 100644 --- a/apple/TableProMobile/TableProMobile/Views/StructureView.swift +++ b/apple/TableProMobile/TableProMobile/Views/StructureView.swift @@ -53,7 +53,7 @@ struct StructureView: View { } } } - .safeAreaInset(edge: .top) { + .topSafeAreaBar(spacing: nil) { Picker("Section", selection: $selectedTab) { ForEach(Tab.allCases, id: \.self) { tab in Text(tab.displayName).tag(tab) @@ -62,7 +62,6 @@ struct StructureView: View { .pickerStyle(.segmented) .padding(.horizontal) .padding(.vertical, 8) - .background(.bar) } .navigationTitle(table.name) .navigationBarTitleDisplayMode(.inline) diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift index dab720d898..bf48bbc4c6 100644 --- a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift @@ -70,6 +70,31 @@ struct SQLBuilderFilterLiteralTests { ) == "SELECT COUNT(*) FROM `files` WHERE `path` = 'C:\\\\temp\\\\'") } + private func search(_ type: DatabaseType, _ text: String) -> String { + SQLBuilder.buildSearchCount( + table: "t", schema: nil, type: type, searchText: text, searchColumns: [ColumnInfo(name: "name", typeName: "TEXT")] + ) + } + + @Test("A MySQL search keeps a backslash literal through the string and the pattern") + func mysqlSearchBackslashIsLiteral() { + #expect(search(.mysql, #"a\b"#) == #"SELECT COUNT(*) FROM `t` WHERE (CAST(`name` AS CHAR) LIKE '%a\\\\b%')"#) + } + + @Test("A SQL Server search escapes an opening bracket") + func mssqlSearchEscapesBracket() { + #expect(search(.mssql, "[a]") == "SELECT COUNT(*) FROM [t] WHERE (CAST([name] AS NVARCHAR(MAX)) LIKE '%![a]%' ESCAPE '!')") + } + + // A combining mark joins the quote into one grapheme cluster. + @Test("A search quote fused with a combining mark is still escaped") + func searchFusedQuoteIsEscaped() { + #expect( + search(.postgresql, "x'\u{0301})") + == "SELECT COUNT(*) FROM \"t\" WHERE (CAST(\"name\" AS TEXT) ILIKE '%x''\u{0301})%' ESCAPE '!')" + ) + } + // ClickHouse reads backslash escapes inside backticks, so a name ending in a backslash would leave the quote open. @Test("A ClickHouse filter quotes its column the way the table and the writes do") func clickHouseColumnEndingInABackslash() { diff --git a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift index eb1fe3b32b..8e50943f94 100644 --- a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift +++ b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift @@ -39,7 +39,9 @@ struct MacOnlyImportRowTests { ImportSelection.defaults(for: preview).resolution(for: row.ref) != nil } - @Test("A Mac plugin type is recognized, preselected and marked Mac only", arguments: ["Elasticsearch", "SAP HANA", "Typesense", "PGlite", "Presto"]) + @Test("A Mac plugin type is recognized, preselected and marked Mac only", arguments: [ + "Elasticsearch", "SAP HANA", "Typesense", "PGlite", "Presto", "KingbaseES", + ]) func macPluginTypeIsMarked(_ typeId: String) throws { let preview = try analyzed(type: typeId) let row = try #require(preview.connections.first) diff --git a/apple/TableProMobile/TableProMobileTests/Onboarding/FirstRunPlanTests.swift b/apple/TableProMobile/TableProMobileTests/Onboarding/FirstRunPlanTests.swift index 76d3d12afa..333104fe22 100644 --- a/apple/TableProMobile/TableProMobileTests/Onboarding/FirstRunPlanTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Onboarding/FirstRunPlanTests.swift @@ -88,4 +88,28 @@ struct FirstRunPlanTests { let presentation = plan(hasSeenWelcome: true, syncChoice: true, lastSeenVersion: "1.0") #expect(presentation == .firstRun([.usageData])) } + + @Test("An older version shows nothing") + func olderVersionShowsNothing() { + let presentation = plan( + hasSeenWelcome: true, + syncChoice: true, + usageDataChoice: false, + lastSeenVersion: "1.1", + currentVersion: "1.0" + ) + #expect(presentation == .none) + } + + @Test("Versions compare as numbers, not as text") + func versionsCompareAsNumbers() { + let presentation = plan( + hasSeenWelcome: true, + syncChoice: true, + usageDataChoice: false, + lastSeenVersion: "1.9", + currentVersion: "1.10" + ) + #expect(presentation == .whatsNew(version: "1.10")) + } } diff --git a/apple/TableProMobile/TableProMobileTests/Onboarding/OnboardingPreferencesTests.swift b/apple/TableProMobile/TableProMobileTests/Onboarding/OnboardingPreferencesTests.swift index 54a079fa29..64e1c801f9 100644 --- a/apple/TableProMobile/TableProMobileTests/Onboarding/OnboardingPreferencesTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Onboarding/OnboardingPreferencesTests.swift @@ -58,4 +58,25 @@ struct OnboardingPreferencesTests { #expect(reloaded.hasSeenWelcome) #expect(reloaded.lastSeenVersion == "1.0") } + + @Test("An older version records nothing, so the newer one stays recorded") + func olderVersionKeepsNewerRecorded() { + let preferences = OnboardingPreferences(defaults: defaults) + preferences.recordLaunch(version: "1.1") + + preferences.recordLaunch(version: "1.0") + + #expect(preferences.lastSeenVersion == "1.1") + #expect(OnboardingPreferences(defaults: defaults).lastSeenVersion == "1.1") + } + + @Test("A newer version is recorded, compared as numbers") + func newerVersionIsRecorded() { + let preferences = OnboardingPreferences(defaults: defaults) + preferences.recordLaunch(version: "1.9") + + preferences.recordLaunch(version: "1.10") + + #expect(OnboardingPreferences(defaults: defaults).lastSeenVersion == "1.10") + } } diff --git a/apple/TableProMobile/project.yml b/apple/TableProMobile/project.yml index 9558606aa5..173932e730 100644 --- a/apple/TableProMobile/project.yml +++ b/apple/TableProMobile/project.yml @@ -67,6 +67,8 @@ targets: # through a socket. - ../Plugins/MySQLDriverPlugin/MySQLLocalSocket.swift - ../Plugins/Shared/PluginQueryTimeout.swift + # The toolbar glyph rule both apps follow on and before the 26 releases. + - ../TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift # The ClickHouse plugin driver runs whole behind PluginBackedDriver, so every file of the plugin is compiled in. - path: ../Plugins/ClickHouseDriverPlugin excludes: @@ -188,7 +190,7 @@ targets: GENERATE_INFOPLIST_FILE: YES INFOPLIST_FILE: TableProMobile/Info.plist INFOPLIST_KEY_CFBundleDisplayName: TablePro - INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: YES + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.developer-tools INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES diff --git a/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift b/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift index 3df470a03e..49c4676102 100644 --- a/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift +++ b/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift @@ -48,7 +48,7 @@ struct CompareSQLLiteralTests { @Test func postgresFamilyUsesAByteaCast() throws { - for type in [DatabaseType.postgresql, .cockroachdb, .redshift, .pglite] { + for type in [DatabaseType.postgresql, .cockroachdb, .redshift, .pglite, .kingbasees] { let literal = try #require(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) #expect(literal == "'\\x89504e47'::bytea", "\(type.rawValue) uses a bytea literal") } diff --git a/apple/TableProTests/Core/Compare/CompareSyncSessionFamilyTests.swift b/apple/TableProTests/Core/Compare/CompareSyncSessionFamilyTests.swift new file mode 100644 index 0000000000..0ab6f306dc --- /dev/null +++ b/apple/TableProTests/Core/Compare/CompareSyncSessionFamilyTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +struct CompareSyncSessionFamilyTests { + private static let oracleMode = PluginSessionLexicalState(determined: [.plsqlBlocks], enabled: [.plsqlBlocks]) + private static let pgMode = PluginSessionLexicalState(determined: [.plsqlBlocks], enabled: []) + + @Test + func kingbaseESSyncsStructureWithPostgreSQLInPostgreSQLMode() { + #expect(CompareSyncEngineFamily.canGenerateStructureScript( + from: .postgresql, to: .kingbasees, sourceSession: nil, targetSession: Self.pgMode + )) + #expect(CompareSyncEngineFamily.canGenerateStructureScript( + from: .kingbasees, to: .postgresql, sourceSession: Self.pgMode, targetSession: nil + )) + } + + @Test + func anOracleModeServerSyncsStructureOnlyWithAnotherOne() { + #expect(!CompareSyncEngineFamily.canGenerateStructureScript( + from: .postgresql, to: .kingbasees, sourceSession: nil, targetSession: Self.oracleMode + )) + #expect(!CompareSyncEngineFamily.canGenerateStructureScript( + from: .kingbasees, to: .kingbasees, sourceSession: Self.pgMode, targetSession: Self.oracleMode + )) + #expect(CompareSyncEngineFamily.canGenerateStructureScript( + from: .kingbasees, to: .kingbasees, sourceSession: Self.oracleMode, targetSession: Self.oracleMode + )) + let refusal = CompareSyncEngineFamily.structureScriptRefusal( + from: .postgresql, to: .kingbasees, sourceSession: nil, targetSession: Self.oracleMode + ) + #expect(refusal.contains("compatibility mode")) + } + + @Test + func postgreSQLStaysUnpairedWithItsOtherVariants() { + #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .postgresql, to: .cockroachdb)) + #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .postgresql, to: .pglite)) + } +} diff --git a/apple/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift b/apple/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift index 4ffe1cfa8a..08291e640a 100644 --- a/apple/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift +++ b/apple/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift @@ -5,6 +5,7 @@ import Foundation import TableProPluginKit +import Testing import XCTest @testable import TablePro @@ -324,6 +325,171 @@ final class SchemaSyncScriptBuilderTests: XCTestCase { } } +// The listing leaves the table's schema nil while each key's `referencedSchema` names the endpoint's +// namespace: MySQL's database, PostgreSQL's schema. +@MainActor +struct CompareForeignKeyOrderTests { + private func endpoint(database: String, schema: String?, type: DatabaseType) -> DatabaseEndpoint { + DatabaseEndpoint( + scope: DatabaseScope(connectionId: UUID(), database: database, schema: schema), + connectionName: "server", + databaseType: type, + safeModeLevel: .silent, + color: .blue + ) + } + + private func mysql(_ database: String) -> CompareRunner.ReferenceNamespace { + CompareRunner.ReferenceNamespace( + endpoint: endpoint(database: database, schema: nil, type: .mysql), + supportsSchemas: false, + supportsDatabases: true + ) + } + + private func postgres(_ schema: String) -> CompareRunner.ReferenceNamespace { + CompareRunner.ReferenceNamespace( + endpoint: endpoint(database: "app", schema: schema, type: .postgresql), + supportsSchemas: true, + supportsDatabases: true + ) + } + + private func table( + _ name: String, + schema: String? = nil, + referencing parent: String? = nil, + in referencedSchema: String? = nil + ) -> TableStructureSnapshot { + let foreignKeys = parent.map { parent in + [EditableForeignKeyDefinition( + id: UUID(), name: "fk_\(name)_\(parent)", columns: ["\(parent)_id"], + referencedTable: parent, referencedColumns: ["id"], referencedSchema: referencedSchema, + onDelete: .noAction, onUpdate: .noAction + )] + } ?? [] + return TableStructureSnapshot(name: name, schema: schema, columns: [], foreignKeys: foreignKeys) + } + + private func keyed(_ snapshots: TableStructureSnapshot...) -> [String: TableStructureSnapshot] { + Dictionary(uniqueKeysWithValues: snapshots.map { ($0.qualifiedName, $0) }) + } + + private func rows( + for table: String, + schema: String? = nil + ) -> (plan: DataComparePlan, statements: DataSyncStatements) { + let qualified = schema.map { "\($0).\(table)" } ?? table + return ( + DataComparePlan(table: table, schema: schema, columns: [], scope: DataTableScope(), isEnabled: true), + DataSyncStatements( + inserts: [SyncStatement(sql: "INSERT \(qualified)", objectName: qualified, summary: "")], + deletes: [SyncStatement(sql: "DELETE \(qualified)", objectName: qualified, summary: "")] + ) + ) + } + + @Test + func mysqlCreatesAParentBeforeItsChild() { + let estates = table("estates") + let buildings = table("buildings", referencing: "estates", in: "regest") + let foreignKeys = CompareRunner.structureForeignKeys( + source: keyed(buildings, estates), in: mysql("regest"), target: [:], in: mysql("regest_copy") + ) + + let ordered = SchemaSyncScriptBuilder.order( + operations: [.createTable(buildings), .createTable(estates)], foreignKeysByTable: foreignKeys + ) + + let names = ordered.map(\.tableName) + #expect(names == ["estates", "buildings"]) + } + + /// A case-sensitive server holds `Shop` and `shop` side by side, so a key into one is not a key into the other. + @Test + func mysqlDatabasesThatDifferOnlyInCaseStayApart() { + #expect(mysql("Shop").names("Shop")) + #expect(!mysql("Shop").names("shop")) + } + + @Test + func mysqlDropsAChildBeforeItsParentFromTheTargetsKeys() { + let estates = table("estates") + let buildings = table("buildings", referencing: "estates", in: "regest_copy") + let foreignKeys = CompareRunner.structureForeignKeys( + source: [:], in: mysql("regest"), target: keyed(buildings, estates), in: mysql("regest_copy") + ) + + let ordered = SchemaSyncScriptBuilder.order( + operations: [.dropTable(name: "estates", schema: nil), .dropTable(name: "buildings", schema: nil)], + foreignKeysByTable: foreignKeys + ) + + let names = ordered.map(\.tableName) + #expect(names == ["buildings", "estates"]) + } + + @Test + func mysqlInsertsAParentFirstAndDeletesItLast() { + let statements = CompareRunner.orderedAcrossTables( + [rows(for: "buildings"), rows(for: "estates")], + sourceSnapshots: keyed(table("buildings", referencing: "estates", in: "regest"), table("estates")), + in: mysql("regest") + ) + + let sql = statements.map(\.sql) + #expect(sql == ["INSERT estates", "INSERT buildings", "DELETE buildings", "DELETE estates"]) + } + + // The plan takes the endpoint's schema, so its id is qualified while the snapshot's key is bare. + @Test + func postgresInsertsAParentFirstWhenTheListingLeavesTheSchemaOff() { + let statements = CompareRunner.orderedAcrossTables( + [rows(for: "buildings", schema: "public"), rows(for: "estates", schema: "public")], + sourceSnapshots: keyed(table("buildings", referencing: "estates", in: "public"), table("estates")), + in: postgres("public") + ) + + let sql = statements.map(\.sql) + #expect(sql == [ + "INSERT public.estates", "INSERT public.buildings", "DELETE public.buildings", "DELETE public.estates", + ]) + } + + @Test + func postgresKeysIntoTheTablesOwnSchemaKeepTheParentFirst() { + let estates = table("estates", schema: "public") + let buildings = table("buildings", schema: "public", referencing: "estates", in: "public") + let foreignKeys = CompareRunner.structureForeignKeys( + source: keyed(buildings, estates), in: postgres("public"), target: [:], in: postgres("public") + ) + + let ordered = SchemaSyncScriptBuilder.order( + operations: [.createTable(buildings), .createTable(estates)], foreignKeysByTable: foreignKeys + ) + + let identifiers = ordered.map(\.tableIdentifier) + #expect(identifiers == ["public.estates", "public.buildings"]) + } + + // `app` and `App` are two PostgreSQL schemas, so a key into the other one is no edge here. + @Test + func postgresSchemaNamesFoldOnlyWhenTheyMatchExactly() { + let estates = table("estates") + let buildings = table("buildings", referencing: "estates", in: "app") + let foreignKeys = CompareRunner.structureForeignKeys( + source: keyed(buildings, estates), in: postgres("App"), target: [:], in: postgres("App") + ) + + let ordered = SchemaSyncScriptBuilder.order( + operations: [.createTable(buildings), .createTable(estates)], foreignKeysByTable: foreignKeys + ) + + let names = ordered.map(\.tableName) + #expect(names == ["buildings", "estates"]) + } +} + final class SyncSafetyClassifierTests: XCTestCase { private let classifier = SyncSafetyClassifier() diff --git a/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift b/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift index 8a04459f24..cd3ae7a589 100644 --- a/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift +++ b/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift @@ -79,6 +79,13 @@ struct CrossEngineIndexTypeTests { #expect(index.ddlMethodAndKeys == "USING hnsw (a vector_l2_ops)") } + @Test("KingbaseES reports its access method from pg_am, so a PostgreSQL copy keeps it") + func kingbaseAccessMethodSurvives() { + let bloom = Self.index("items_bloom", type: "bloom").type + #expect(CrossEngineIndexTranslator.resolvedType(bloom, from: .kingbasees, to: .postgresql) == bloom) + #expect(CrossEngineIndexTranslator.resolvedType(bloom, from: .mssql, to: .postgresql) == .btree) + } + @Test("A Redshift DISTKEY and SORTKEY are left out of a PostgreSQL copy, though the two share a family") func redshiftKeysAreLeftOut() { let result = CrossEngineStructureTranslator.translate( diff --git a/apple/TableProTests/Core/DataGrid/BindingCommittingGridEditTests.swift b/apple/TableProTests/Core/DataGrid/BindingCommittingGridEditTests.swift new file mode 100644 index 0000000000..b178ae1dd5 --- /dev/null +++ b/apple/TableProTests/Core/DataGrid/BindingCommittingGridEditTests.swift @@ -0,0 +1,46 @@ +// +// BindingCommittingGridEditTests.swift +// TableProTests +// + +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct BindingCommittingGridEditTests { + @MainActor + private final class Selection { + var tab = "columns" + } + + @Test("A tab change commits the open edit while the pane being left is still selected") + func commitsBeforePublishing() { + let model = DataGridModel(configuration: DataGridConfiguration()) + let grid = DataTabGridMountStub() + grid.isEditing = true + model.attach(grid) + let selection = Selection() + var tabAtCommit: String? + grid.onCommit = { tabAtCommit = selection.tab } + let binding = Binding(get: { selection.tab }, set: { selection.tab = $0 }).committingEdit(of: model) + + binding.wrappedValue = "indexes" + + #expect(tabAtCommit == "columns") + #expect(selection.tab == "indexes") + #expect(grid.commitCount == 1) + #expect(!grid.isEditing) + } + + @Test("With no grid mounted the tab still changes") + func changesWithoutAGrid() { + let model = DataGridModel(configuration: DataGridConfiguration()) + let selection = Selection() + let binding = Binding(get: { selection.tab }, set: { selection.tab = $0 }).committingEdit(of: model) + + binding.wrappedValue = "indexes" + + #expect(selection.tab == "indexes") + } +} diff --git a/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift b/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift index c5cda01873..5234fead9b 100644 --- a/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift +++ b/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift @@ -22,11 +22,11 @@ private final class CountingDriver: PluginDriverStub, @unchecked Sendable { /// What the session answers when the executor asks what it is already holding. let sessionState: PluginSessionTransactionState - /// Every call in the order it arrived: statement text, plus "BEGIN", "COMMIT" and "ROLLBACK". + /// Every call in the order it arrived: statement text, plus "BEGIN", "COMMIT", "ROLLBACK" and + /// "open" or "close" with a session scope's purpose. private(set) var trace: [String] = [] private var statementCount = 0 - var executed: [String] { trace.filter { !["BEGIN", "COMMIT", "ROLLBACK"].contains($0) } } var didCommit: Bool { trace.contains("COMMIT") } var didRollBack: Bool { trace.contains("ROLLBACK") } @@ -62,6 +62,15 @@ private final class CountingDriver: PluginDriverStub, @unchecked Sendable { func sessionTransactionState() async -> PluginSessionTransactionState { sessionState } + func openSessionScope(for purpose: PluginSessionPurpose) async throws -> PluginSessionScope? { + trace.append("open \(purpose)") + return PluginSessionScope(purpose: purpose, saved: [], applied: []) + } + + func closeSessionScope(_ scope: PluginSessionScope) async throws { + trace.append("close \(scope.purpose)") + } + func beginTransaction(mode: PluginTransactionAccessMode) async throws { trace.append("BEGIN") } func commitTransaction() async throws { trace.append("COMMIT") } func rollbackTransaction() async throws { @@ -181,6 +190,18 @@ struct DataWriteExecutorTests { #expect(counting.didRollBack) } + @Test("A KingbaseES keyless write that found no row is rolled back, as libpq reports a real count") + func kingbaseKeylessWriteThatMatchedNothingFails() async throws { + let counting = CountingDriver(affectedRows: 0) + await #expect(throws: DataWriteError.rowsNoLongerMatch(table: "t", expected: 1, actual: 0)) { + try await DataWriteExecutor.run( + plan(expectedRowCount: 1, matchesRowsWithoutKey: true, databaseType: .kingbasees), + on: driver(counting) + ) + } + #expect(counting.didRollBack) + } + @Test("An engine whose driver reports no real count is not held to one") func keylessWriteOnAnEngineWithoutCountsSucceeds() async throws { let counting = CountingDriver(affectedRows: 0) @@ -193,7 +214,8 @@ struct DataWriteExecutorTests { #expect(counting.didCommit) } - @Test("The foreign-key disable runs before the transaction, and the re-enable after it") + /// SQLite opens with foreign_keys off, and the epilogue alone would leave it on. + @Test("The foreign-key disable runs before the transaction and the re-enable after it, inside a preserving scope") func togglesRunOutsideTheTransaction() async throws { let counting = CountingDriver(affectedRows: 1) _ = try await DataWriteExecutor.run( @@ -206,15 +228,17 @@ struct DataWriteExecutorTests { ) #expect(counting.trace == [ + "open preserve", "PRAGMA foreign_keys = OFF", "BEGIN", "UPDATE \"t\" SET \"b\" = 0", "COMMIT", "PRAGMA foreign_keys = ON", + "close preserve", ]) } - @Test("Foreign keys are re-enabled after a rollback too") + @Test("Foreign keys are re-enabled and the session's settings put back after a rollback too") func togglesRunAfterRollback() async throws { let counting = CountingDriver(affectedRows: 1, failOnStatement: 2) await #expect(throws: (any Error).self) { @@ -228,8 +252,36 @@ struct DataWriteExecutorTests { ) } - #expect(counting.didRollBack) - #expect(counting.trace.last == "PRAGMA foreign_keys = ON") + #expect(counting.trace.first == "open preserve") + #expect(Array(counting.trace.suffix(3)) == ["ROLLBACK", "PRAGMA foreign_keys = ON", "close preserve"]) + } + + /// PostgreSQL would put a `SET LOCAL` value back session-wide, where it outlives the user's transaction. + @Test( + "A save inside the user's transaction saves no session settings to put back", + arguments: [PluginSessionTransactionState.inTransaction, .abortedTransaction] + ) + func saveInsideTheUsersTransactionOpensNoScope(state: PluginSessionTransactionState) async throws { + let counting = CountingDriver(affectedRows: 1, sessionState: state) + _ = try? await DataWriteExecutor.run( + plan( + expectedRowCount: 1, + prologue: ["SET session_replication_role = replica"], + epilogue: ["SET session_replication_role = origin"] + ), + on: driver(counting) + ) + + #expect(!counting.trace.contains("open preserve")) + #expect(counting.trace.contains("SET session_replication_role = origin")) + } + + @Test("A plan without a prologue leaves the session's settings alone") + func planWithoutPrologueOpensNoScope() async throws { + let counting = CountingDriver(affectedRows: 1) + _ = try await DataWriteExecutor.run(plan(expectedRowCount: 1), on: driver(counting)) + + #expect(counting.trace == ["BEGIN", "UPDATE \"t\" SET \"b\" = 0", "COMMIT"]) } @Test @@ -247,7 +299,12 @@ struct DataWriteExecutorTests { try await DataWriteExecutor.run(required, on: driver(counting)) } - #expect(counting.executed == ["SET IDENTITY_INSERT [dbo].[t] ON", "SET IDENTITY_INSERT [dbo].[t] OFF"]) + #expect(counting.trace == [ + "open preserve", + "SET IDENTITY_INSERT [dbo].[t] ON", + "SET IDENTITY_INSERT [dbo].[t] OFF", + "close preserve", + ]) #expect(!counting.didCommit) } diff --git a/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift b/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift index 6a93d5068f..7f37204287 100644 --- a/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift +++ b/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift @@ -203,9 +203,9 @@ struct RowChangeStatementFactoryServerOwnedTests { #expect(!sql.contains("Doubled")) } - @Test - func aPostgreSQLRestoreOverridesTheSystemValue() throws { - let restored = try factory(databaseType: .postgresql, schemaName: "sales").restoreStatements( + @Test(arguments: [DatabaseType.postgresql, .pglite, .kingbasees]) + func aPostgreSQLRestoreOverridesTheSystemValue(databaseType: DatabaseType) throws { + let restored = try factory(databaseType: databaseType, schemaName: "sales").restoreStatements( rows: [["5", "a", "10"]] ) #expect(restored.prologue.isEmpty) diff --git a/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift b/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift index 34d93a9709..2f7b8a90dd 100644 --- a/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift +++ b/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift @@ -47,6 +47,29 @@ struct DatabaseAccessBridgeStatementTests { #expect(QueryClassifier.classifyTier(sent, databaseType: .postgresql) == .safe) } + @Test( + "An external statement on a KingbaseES Oracle-mode session keeps the ; after its END", + arguments: ["BEGIN x := 1; y := 2; END;", "BEGIN x := 1; y := 2; END;\n/\n"] + ) + @MainActor + func oracleModeBlockKeepsItsTerminator(sql: String) async throws { + let driver = ScriptAnsweringDriver( + connection: TestFixtures.makeConnection(database: "app", type: .kingbasees), + sessionLexicalState: TestGrammar.kingbaseESOracleSession + ) + var session = ConnectionSession(connection: driver.connection) + session.driver = driver + DatabaseManager.shared.injectSession(session, for: driver.connection.id) + defer { DatabaseManager.shared.removeSession(for: driver.connection.id) } + let scope = DatabaseScope(connectionId: driver.connection.id, database: "app", schema: nil) + + _ = try await DatabaseAccessBridge().runStatement( + scope: scope, query: sql, maxRows: 10, timeoutSeconds: 30, cancellation: nil + ) + + #expect(driver.sentStatements == ["BEGIN x := 1; y := 2; END;"]) + } + @Test("An explain behind an invisible character is not wrapped in a second explain") func explainBehindInvisibleCharacterIsNotWrapped() throws { let statement = try MCPConnectionBridge.explainStatement( diff --git a/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift b/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift index 10a2ebd9d0..4265622d87 100644 --- a/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift +++ b/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift @@ -442,6 +442,40 @@ struct FilterSQLGeneratorColumnTypeTests { #expect(postgres?.contains("'%a[bc]%'") == true) } + // '' is NULL on Oracle and on KingbaseES in Oracle mode, so `v != ''` found no row there. Measured on Oracle 23ai, + // KingbaseES V9R1C10 in both modes and PostgreSQL 18: the length form returns what `= ''` returns on PostgreSQL. + @Test( + "Is empty and is not empty test the length where '' can read as NULL", + arguments: [DatabaseType.kingbasees, .oracle] + ) + func emptyFiltersTestTheLength(databaseType: DatabaseType) { + let text = ColumnType.text(rawType: "text") + let empty = Self.engineCondition(databaseType, type: text, op: .isEmpty, value: "", dialect: Self.postgresqlDialect) + let notEmpty = Self.engineCondition( + databaseType, type: text, op: .isNotEmpty, value: "", dialect: Self.postgresqlDialect + ) + #expect(empty == "(\"v\" IS NULL OR LENGTH(\"v\") = 0)") + #expect(notEmpty == "(\"v\" IS NOT NULL AND LENGTH(\"v\") > 0)") + } + + @Test("The length form measures the text cast of a column of unknown type, and PostgreSQL and MySQL keep = ''") + func emptyFiltersElsewhere() { + let cast = Self.engineCondition(.kingbasees, type: nil, op: .isNotEmpty, value: "", dialect: Self.castingPostgres) + let mysql = Self.engineCondition( + .mysql, type: .text(rawType: "VARCHAR"), op: .isNotEmpty, value: "", dialect: Self.mysqlDialect + ) + let integer = Self.engineCondition( + .kingbasees, type: .integer(rawType: "integer"), op: .isNotEmpty, value: "", dialect: Self.postgresqlDialect + ) + let postgreSQL = Self.engineCondition( + .postgresql, type: .text(rawType: "text"), op: .isNotEmpty, value: "", dialect: Self.postgresqlDialect + ) + #expect(cast == "(\"v\" IS NOT NULL AND LENGTH(CAST(\"v\" AS TEXT)) > 0)") + #expect(mysql == "(`v` IS NOT NULL AND `v` != '')") + #expect(postgreSQL == "(\"v\" IS NOT NULL AND \"v\" != '')") + #expect(integer == "\"v\" IS NOT NULL") + } + @Test("A PostgreSQL bit read as a string of digits compares quoted") func postgresBitIsQuoted() { let type = ColumnTypeClassifier(family: .postgres).classify(rawTypeName: "bit") diff --git a/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift b/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift index 9126b678f0..34f8a47c9f 100644 --- a/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift +++ b/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift @@ -56,6 +56,13 @@ struct FilterSQLGeneratorTests { offsetFetchOrderBy: "" ) + private static let snowflakeDialect = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .regexpLike, booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, paginationStyle: .limit, + requiresBackslashEscaping: true + ) + // MARK: - Per-Operator Tests (MySQL) @Test("Equal operator generates correct condition") @@ -1038,4 +1045,64 @@ struct FilterSQLGeneratorTests { let result = generator.generateCondition(from: filter) #expect(result == "`status` IS NULL") } + + // MARK: - Literal Escaping + + private func condition( + _ dialect: SQLDialectDescriptor, _ filterOperator: FilterOperator, _ value: String, column: String = "code" + ) -> String? { + FilterSQLGenerator(dialect: dialect).generateCondition(from: TableFilter( + columnName: column, filterOperator: filterOperator, value: value, isCaseSensitive: true + )) + } + + // Snowflake takes an ESCAPE clause for LIKE and still reads `\'` inside a literal as a quote. + @Test("A Snowflake filter value doubles its backslash, whatever the operator", arguments: [ + (FilterOperator.equal, #""CODE" = '\\''; DROP TABLE AUDIT_LOG; --'"#), + (.inList, #""CODE" IN ('\\''; DROP TABLE AUDIT_LOG; --')"#), + (.contains, #""CODE" LIKE '%\\''; DROP TABLE AUDIT!_LOG; --%' ESCAPE '!'"#), + (.startsWith, #""CODE" LIKE '\\''; DROP TABLE AUDIT!_LOG; --%' ESCAPE '!'"#), + (.regex, #"REGEXP_LIKE("CODE", '\\''; DROP TABLE AUDIT_LOG; --')"#) + ]) + func snowflakeDoublesBackslash(filterOperator: FilterOperator, expected: String) { + #expect(condition(Self.snowflakeDialect, filterOperator, #"\'; DROP TABLE AUDIT_LOG; --"#, column: "CODE") == expected) + } + + @Test("A Snowflake LIKE pattern reads a backslash as a character, not as its escape") + func snowflakeLikeBackslashIsLiteral() { + #expect(condition(Self.snowflakeDialect, .endsWith, #"C:\temp\"#) == #""code" LIKE '%C:\\temp\\' ESCAPE '!'"#) + } + + @Test("The LIKE a regex falls back to doubles a backslash where the engine reads it as an escape") + func regexFallbackDoublesBackslash() { + let implicitWithoutRegex = SQLDialectDescriptor( + identifierQuote: "`", keywords: [], functions: [], dataTypes: [], + regexSyntax: .unsupported, booleanLiteralStyle: .truefalse, + likeEscapeStyle: .implicit, paginationStyle: .limit + ) + #expect(condition(implicitWithoutRegex, .regex, #"\' OR 1=1 -- "#) == #"`code` LIKE '%\\'' OR 1=1 -- %'"#) + #expect(condition(Self.sqliteDialect, .regex, #"\' OR 1=1 -- "#) == #"`code` LIKE '%\'' OR 1=1 -- %'"#) + } + + @Test("A MySQL LIKE keeps a backslash literal through the string and the pattern") + func mysqlLikeBackslash() { + #expect(condition(Self.mysqlDialect, .contains, #"a\b%"#) == #"`code` LIKE '%a\\\\b\\%%'"#) + } + + // A combining mark or a Prepend scalar joins the quote or backslash into one grapheme cluster. + @Test("A quote or backslash fused with its neighbour is still escaped", arguments: [ + ("x'\u{0301} OR 1=1 --", #"`code` = 'x''"# + "\u{0301}" + #" OR 1=1 --'"#), + ("\u{0600}\\' OR 1=1 --", "`code` = '\u{0600}" + #"\\'' OR 1=1 --'"#) + ]) + func fusedQuoteIsEscaped(value: String, expected: String) { + #expect(condition(Self.mysqlDialect, .equal, value) == expected) + } + + @Test("A quote fused with a combining mark is escaped in a LIKE pattern") + func fusedQuoteInLikeIsEscaped() { + #expect( + condition(Self.postgresqlDialect, .contains, "x'\u{0301})") + == "\"code\" LIKE '%x''\u{0301})%' ESCAPE '!'" + ) + } } diff --git a/apple/TableProTests/Core/Database/StartupStatementsTests.swift b/apple/TableProTests/Core/Database/StartupStatementsTests.swift index 503c3a3f1c..5f26c1f07c 100644 --- a/apple/TableProTests/Core/Database/StartupStatementsTests.swift +++ b/apple/TableProTests/Core/Database/StartupStatementsTests.swift @@ -38,6 +38,14 @@ struct StartupStatementsTests { #expect(statements == ["SET marker \"a; FLUSHALL; b\"", "CLIENT SETNAME 'line\nbreak'"]) } + @Test("A startup block on a KingbaseES Oracle-mode session runs whole") + func oracleModeBlockRunsWhole() { + let block = "BEGIN dbms_session.set_identifier('x'); END;" + #expect(DatabaseManager.startupStatements( + from: block, databaseType: .kingbasees, grammar: TestGrammar.kingbaseESOracle + ) == [block]) + } + @Test("Blank text holds no startup statement") func blank() { #expect(DatabaseManager.startupStatements(from: " \n ; ", databaseType: .postgresql).isEmpty) diff --git a/apple/TableProTests/Core/Database/TableOptionsEligibilityTests.swift b/apple/TableProTests/Core/Database/TableOptionsEligibilityTests.swift new file mode 100644 index 0000000000..a735599e4d --- /dev/null +++ b/apple/TableProTests/Core/Database/TableOptionsEligibilityTests.swift @@ -0,0 +1,101 @@ +// +// TableOptionsEligibilityTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class NoOptionsDriver: PluginDriverStub, @unchecked Sendable {} + +private final class TableOptionsDriver: PluginDriverStub, @unchecked Sendable { + private let writesOptions: Bool + + init(writesOptions: Bool) { + self.writesOptions = writesOptions + } + + func tableOptionsStatement(table: String, schema: String?, change: PluginTableOptionsChange) -> String? { + writesOptions ? "ALTER TABLE \(table) DEFAULT CHARACTER SET \(change.characterSet ?? "")" : nil + } + + func fetchTableOptions(table: String, schema: String?) async throws -> PluginTableOptions? { + PluginTableOptions(characterSet: "latin1", collation: "latin1_swedish_ci", autoIncrement: 42) + } + + func fetchTableOptionsCatalog() async throws -> PluginTableOptionsCatalog? { + PluginTableOptionsCatalog( + characterSets: [ + PluginCharacterSet( + name: "utf8mb4", defaultCollation: "utf8mb4_0900_ai_ci", + collations: ["utf8mb4_0900_ai_ci", "utf8mb4_bin"], description: "UTF-8 Unicode" + ) + ], + engines: ["InnoDB", "MyISAM"], + defaultEngine: "InnoDB" + ) + } + + func fetchDefaultTableOptions(schema: String?) async throws -> PluginTableOptions? { + PluginTableOptions(characterSet: "utf8mb4", collation: "utf8mb4_0900_ai_ci") + } +} + +@MainActor +struct TableOptionsEligibilityTests { + private func adapter(writesOptions: Bool) -> PluginDriverAdapter { + PluginDriverAdapter( + connection: TestFixtures.makeConnection(database: "shop", type: .mysql), + pluginDriver: TableOptionsDriver(writesOptions: writesOptions) + ) + } + + @Test("A driver with a table options statement offers them on tables and nowhere else") + func offeredOnTablesOfADriverThatWritesThem() { + let driver = adapter(writesOptions: true) + + #expect(DatabaseObjectToolEligibility.offersTableOptions(driver: driver, objectKind: .table)) + #expect(DatabaseObjectToolEligibility.offersTableOptions(driver: driver, objectKind: .partitionedTable)) + #expect(!DatabaseObjectToolEligibility.offersTableOptions(driver: driver, objectKind: .view)) + #expect(!DatabaseObjectToolEligibility.offersTableOptions(driver: driver, objectKind: .materializedView)) + } + + @Test("A driver without the statement, or no driver, offers none") + func notOfferedWithoutTheStatement() { + #expect(!DatabaseObjectToolEligibility.offersTableOptions(driver: adapter(writesOptions: false), objectKind: .table)) + #expect(!DatabaseObjectToolEligibility.offersTableOptions(driver: nil, objectKind: .table)) + } + + @Test("The adapter maps the plugin's options and catalog") + func adapterMapsOptionsAndCatalog() async throws { + let driver = adapter(writesOptions: true) + + let options = try await driver.fetchTableOptions(tableName: "orders") + let catalog = try #require(try await driver.fetchTableOptionsCatalog()) + let defaults = try await driver.fetchDefaultTableOptions(schema: "shop") + + #expect(options == TableOptions(characterSet: "latin1", collation: "latin1_swedish_ci", autoIncrement: 42)) + #expect(catalog.characterSets == [TableCharacterSet( + name: "utf8mb4", defaultCollation: "utf8mb4_0900_ai_ci", + collations: ["utf8mb4_0900_ai_ci", "utf8mb4_bin"], description: "UTF-8 Unicode" + )]) + #expect(catalog.engines == ["InnoDB", "MyISAM"]) + #expect(catalog.defaultEngine == "InnoDB") + #expect(defaults == TableOptions(characterSet: "utf8mb4", collation: "utf8mb4_0900_ai_ci")) + } + + @Test("An engine with no table options reads nil through the adapter") + func engineWithoutOptionsReadsNil() async throws { + let driver = PluginDriverAdapter( + connection: TestFixtures.makeConnection(type: .postgresql), pluginDriver: NoOptionsDriver() + ) + + #expect(try await driver.fetchTableOptions(tableName: "orders") == nil) + #expect(try await driver.fetchTableOptionsCatalog() == nil) + #expect(driver.tableOptionsStatement( + tableName: "orders", schema: nil, change: PluginTableOptionsChange(autoIncrement: 5) + ) == nil) + } +} diff --git a/apple/TableProTests/Core/Database/TriggerInfoMappingTests.swift b/apple/TableProTests/Core/Database/TriggerInfoMappingTests.swift index 7aeb16248d..c47fc806ea 100644 --- a/apple/TableProTests/Core/Database/TriggerInfoMappingTests.swift +++ b/apple/TableProTests/Core/Database/TriggerInfoMappingTests.swift @@ -100,15 +100,15 @@ struct TriggerInfoMappingTests { } } -struct StructureTabTriggersTests { - @Test("Triggers tab is part of the canonical tab set") +struct StructurePaneTriggersTests { + @Test("Triggers is one of the Structure view's panes") func triggersInAllCases() { - #expect(StructureTab.allCases.contains(.triggers)) + #expect(StructurePane.allCases.contains(.triggers)) } - @Test("Triggers tab has a localized display name") + @Test("The Triggers pane has a localized display name") func triggersDisplayName() { - #expect(StructureTab.triggers.displayName == "Triggers") + #expect(StructurePane.triggers.displayName == "Triggers") } } @@ -221,13 +221,8 @@ struct TriggerApplyExecutionTests { #expect(stub.executedQueries == ["DROP TRIGGER t", "CREATE TRIGGER t", "RESTORE t"]) } - /// The session driver holds whatever transaction a query tab left open, so a trigger edit - /// with a BEGIN of its own ran inside it and committed or rolled back the tab's work. - /// - /// The gate is supplied rather than taken from `ExecutionGateProvider`. A drop is a - /// `.destructiveQuery`, which the real gate confirms with an `NSAlert`, and an alert raised - /// with no window runs application-modal: on a CI runner nobody answers it, so the whole - /// unit job stops there and is killed by its timeout rather than failing. + // A trigger edit's own BEGIN ran inside the transaction a query tab left open on the session driver. + // The gate is injected: the real one confirms a drop with an NSAlert nobody answers on CI. @Test("Apply and drop run on the pooled connection and leave the session driver alone") func applyAndDropRunOnThePooledConnection() async throws { let connection = TestFixtures.makeConnection(database: "app", type: .postgresql) @@ -241,12 +236,7 @@ struct TriggerApplyExecutionTests { let scope = DatabaseScope(connectionId: connection.id, database: "app", schema: "public") let pooledStub = StubTriggerDriver() pooledStub.transactionalDDL = true - /// The drop statement is built on the driver that will run it, because an engine that - /// qualifies its DDL with the connection's current database would otherwise write the - /// session's database into a statement the pooled connection runs somewhere else. Both - /// stubs can produce it, so the assertions below are what say which one was asked: a - /// regression back to the session driver shows up as a query it executed rather than as a - /// statement nobody could generate. + // Both stubs build the drop, so only the queries each one executed show which driver was asked. pooledStub.dropToReturn = "DROP TRIGGER t" let pooledAdapter = PluginDriverAdapter(connection: connection, pluginDriver: pooledStub) try await pooledAdapter.connect() diff --git a/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift b/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift index 262e470279..3bcb48a356 100644 --- a/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift +++ b/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift @@ -76,7 +76,9 @@ struct ExternalStatementGateTests { @Test("A statement that reaches the filesystem or runs server code is refused", arguments: [ "COPY users FROM '/etc/passwd'", "COPY users TO PROGRAM 'curl attacker.example'", - "SELECT pg_read_file('/etc/passwd')" + "SELECT pg_read_file('/etc/passwd')", + "SELECT sys_read_file('kingbase.conf')", + "SELECT * FROM sys_ls_dir('.')" ]) func filesystemAndCodeRefused(sql: String) { #expect(refusal(statement(sql)) == .denied( @@ -182,6 +184,8 @@ struct ExternalStatementGateTests { ("SELECT pg_cancel_backend(123)", DatabaseType.postgresql), ("SELECT pg_terminate_backend(123)", DatabaseType.redshift), ("SELECT pg_cancel_backend(123)", DatabaseType.cockroachdb), + ("SELECT sys_terminate_backend(123)", DatabaseType.kingbasees), + ("SELECT sys_reload_conf()", DatabaseType.kingbasees), ("SELECT query_to_xml('SELECT pg_terminate_backend(123)', true, false, '')", DatabaseType.postgresql), ("EXPLAIN ANALYSE SELECT pg_terminate_backend(123)", DatabaseType.postgresql), ("KILL 123", DatabaseType.mysql), diff --git a/apple/TableProTests/Core/Menu/ChromeSnapshot.json b/apple/TableProTests/Core/Menu/ChromeSnapshot.json index 9d709aff7a..538e3869b2 100644 --- a/apple/TableProTests/Core/Menu/ChromeSnapshot.json +++ b/apple/TableProTests/Core/Menu/ChromeSnapshot.json @@ -323,9 +323,9 @@ "com.TablePro.toolbar.database", "NSToolbarFlexibleSpaceItem", "com.TablePro.toolbar.refresh", - "com.TablePro.toolbar.saveChanges", "com.TablePro.toolbar.actions", "com.TablePro.toolbar.safeMode", + "com.TablePro.toolbar.saveChanges", "NSToolbarInspectorTrackingSeparatorItemIdentifier", "NSToolbarFlexibleSpaceItem", "NSToolbarToggleInspectorItem" @@ -338,9 +338,9 @@ "com.TablePro.toolbar.database", "NSToolbarFlexibleSpaceItem", "com.TablePro.toolbar.refresh", - "com.TablePro.toolbar.saveChanges", "com.TablePro.toolbar.actions", "com.TablePro.toolbar.safeMode", + "com.TablePro.toolbar.saveChanges", "NSToolbarInspectorTrackingSeparatorItemIdentifier", "NSToolbarFlexibleSpaceItem", "NSToolbarToggleInspectorItem", diff --git a/apple/TableProTests/Core/Menu/DebugMenuBuilderTests.swift b/apple/TableProTests/Core/Menu/DebugMenuBuilderTests.swift new file mode 100644 index 0000000000..820f372d7d --- /dev/null +++ b/apple/TableProTests/Core/Menu/DebugMenuBuilderTests.swift @@ -0,0 +1,190 @@ +import AppKit +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct DebugMenuBuilderTests { + private static let defaultedActions: [ShortcutAction] = [.debugContinue, .debugStepOver, .debugStepInto, .debugStepOut] + + private func bar(_ keyboard: KeyboardSettings = KeyboardSettings()) -> NSMenu { + MainMenuBuilder.build(keyboard: keyboard, modules: [.routineDebugger]).bar + } + + private func debugMenu(_ keyboard: KeyboardSettings = KeyboardSettings()) throws -> NSMenu { + try #require(bar(keyboard).items.first { $0.identifier == DebugMenuBuilder.identifier }?.submenu) + } + + private func flatten(_ menu: NSMenu) -> [NSMenuItem] { + menu.items.flatMap { item -> [NSMenuItem] in + guard let submenu = item.submenu else { return [item] } + return [item] + flatten(submenu) + } + } + + private func functionKey(_ scalar: Int) -> String { + Unicode.Scalar(UInt32(scalar)).map { String(Character($0)) } ?? "" + } + + @Test("The Debug menu holds the debugger's commands in order") + func commandOrder() throws { + let menu = try debugMenu() + let actions = menu.items.map { $0.isSeparatorItem ? nil : $0.action } + + #expect(actions == [ + #selector(MainSplitViewController.chooseRoutineToDebug(_:)), + nil, + #selector(MainSplitViewController.debugContinue(_:)), + #selector(MainSplitViewController.debugStepOver(_:)), + #selector(MainSplitViewController.debugStepInto(_:)), + #selector(MainSplitViewController.debugStepOut(_:)), + #selector(MainSplitViewController.debugStop(_:)), + nil, + #selector(MainSplitViewController.toggleBreakpoint(_:)), + #selector(MainSplitViewController.removeAllBreakpoints(_:)), + ]) + #expect(menu.items.first?.title == String(localized: "Debug Routine…")) + } + + @Test("Continue, Step Over, Step Into and Step Out carry Xcode's keys and nothing else ships bound") + func defaultKeys() throws { + let items = try debugMenu().items.filter { !$0.isSeparatorItem } + let keys = Dictionary(uniqueKeysWithValues: items.compactMap { item in + item.action.map { ($0, (item.keyEquivalent, item.keyEquivalentModifierMask)) } + }) + + let continueKey = keys[#selector(MainSplitViewController.debugContinue(_:))] + let commandControl: NSEvent.ModifierFlags = [.command, .control] + let bare: NSEvent.ModifierFlags = [] + #expect(continueKey?.0 == "y") + #expect(continueKey?.1 == commandControl) + #expect(keys[#selector(MainSplitViewController.debugStepOver(_:))]?.0 == functionKey(NSF6FunctionKey)) + #expect(keys[#selector(MainSplitViewController.debugStepInto(_:))]?.0 == functionKey(NSF7FunctionKey)) + #expect(keys[#selector(MainSplitViewController.debugStepOut(_:))]?.0 == functionKey(NSF8FunctionKey)) + #expect(keys[#selector(MainSplitViewController.debugStepOver(_:))]?.1 == bare) + + let unbound: [Selector] = [ + #selector(MainSplitViewController.chooseRoutineToDebug(_:)), + #selector(MainSplitViewController.debugStop(_:)), + #selector(MainSplitViewController.toggleBreakpoint(_:)), + #selector(MainSplitViewController.removeAllBreakpoints(_:)), + ] + for selector in unbound { + #expect(keys[selector]?.0.isEmpty == true, "\(NSStringFromSelector(selector))") + } + #expect(KeyboardSettings.defaultShortcuts[.toggleBreakpoint] == nil) + } + + @Test("Each debug default can be recorded again in Settings > Keyboard and is reserved by nothing") + func defaultsAreRecordable() throws { + for action in Self.defaultedActions { + let key = try #require(KeyboardSettings.defaultShortcuts[action]) + let event = try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: key.modifierFlags, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: key.menuKeyEquivalent ?? "", + charactersIgnoringModifiers: key.menuKeyEquivalent ?? "", + isARepeat: false, + keyCode: key.keyCode + )) + + #expect(BoundKey(from: event) == key, "\(action.rawValue)") + #expect(key.hasModifier || key.isFunctionKey, "\(action.rawValue)") + #expect(ShortcutAction.reservedConflict(for: key, context: action.context) == nil, "\(action.rawValue)") + #expect(KeyboardSettings().sanitized().shortcut(for: action) == key, "\(action.rawValue)") + } + } + + @Test("A rebound debug command reaches its menu item") + func reboundKeyApplies() throws { + let f10 = BoundKey.special(.f10) + let menu = try debugMenu(KeyboardSettings(shortcuts: [ShortcutAction.debugStepOver.rawValue: f10])) + let stepOver = try #require(menu.items.first { $0.action == #selector(MainSplitViewController.debugStepOver(_:)) }) + + #expect(stepOver.keyEquivalent == functionKey(NSF10FunctionKey)) + #expect(stepOver.identifier == MenuItemFactory.identifier(for: .debugStepOver)) + } + + @Test("With the module on, no two items share a title or a key equivalent, and each leaf is a nil-target command") + func moduleOnBarStaysConsistent() { + let items = flatten(bar()) + let leaves = items.filter { !$0.isSeparatorItem && $0.submenu == nil } + + let titles = leaves.map { $0.title } + let duplicateTitles = Dictionary(grouping: titles, by: { $0 }).filter { $0.value.count > 1 }.keys + #expect(duplicateTitles.isEmpty, "\(duplicateTitles)") + + let combos = items.filter { !$0.keyEquivalent.isEmpty } + .map { "\($0.keyEquivalentModifierMask.rawValue)-\($0.keyEquivalent)" } + let duplicateCombos = Dictionary(grouping: combos, by: { $0 }).filter { $0.value.count > 1 }.keys + #expect(duplicateCombos.isEmpty, "\(duplicateCombos)") + + let debugLeaves = leaves.filter { $0.action.map(MainSplitViewController.routineDebugSelectors.contains) ?? false } + #expect(debugLeaves.count == MainSplitViewController.routineDebugSelectors.count) + for item in debugLeaves { + #expect(item.target == nil, "\(item.title)") + } + } + + @Test("Every Debug command reaches the window and dims with no debug tab in front") + func everyDebugCommandIsDecided() throws { + let selectors = try debugMenu().items.compactMap { $0.action } + + #expect(Set(selectors) == MainSplitViewController.routineDebugSelectors) + for selector in selectors { + let name = NSStringFromSelector(selector) + #expect(MainSplitViewController.instancesRespond(to: selector), "\(name)") + var connected = MenuValidationContext() + connected.isConnected = true + #expect(MainSplitViewController.resolvedEnablement(selector, context: connected) == false, "\(name)") + } + } + + @Test("Each Debug command follows the debug tab in front, and Agent mode dims them all") + func debugCommandsFollowTheSession() { + var context = MenuValidationContext() + context.isConnected = true + context.routineDebug = RoutineDebugMenuState( + canChooseRoutine: true, + performableCommands: [.stepOver, .stepInto], + canStop: true, + canToggleBreakpoint: true, + canRemoveAllBreakpoints: false + ) + let expected: [(Selector, Bool)] = [ + (#selector(MainSplitViewController.chooseRoutineToDebug(_:)), true), + (#selector(MainSplitViewController.debugContinue(_:)), false), + (#selector(MainSplitViewController.debugStepOver(_:)), true), + (#selector(MainSplitViewController.debugStepInto(_:)), true), + (#selector(MainSplitViewController.debugStepOut(_:)), false), + (#selector(MainSplitViewController.debugStop(_:)), true), + (#selector(MainSplitViewController.toggleBreakpoint(_:)), true), + (#selector(MainSplitViewController.removeAllBreakpoints(_:)), false), + ] + for (selector, enabled) in expected { + #expect( + MainSplitViewController.resolvedEnablement(selector, context: context) == enabled, + "\(NSStringFromSelector(selector))" + ) + } + + var disconnected = context + disconnected.isConnected = false + #expect(MainSplitViewController.resolvedEnablement( + #selector(MainSplitViewController.chooseRoutineToDebug(_:)), context: disconnected + ) == false) + + var agent = context + agent.isAgentMode = true + for (selector, _) in expected { + #expect( + MainSplitViewController.resolvedEnablement(selector, context: agent) == false, + "\(NSStringFromSelector(selector))" + ) + } + } +} diff --git a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift index b451009d15..2a01ca20ff 100644 --- a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -11,8 +11,8 @@ import TableProPluginKit import Testing @MainActor -private func buildMenu(_ keyboard: KeyboardSettings = KeyboardSettings()) -> NSMenu { - MainMenuBuilder.build(keyboard: keyboard).bar +private func buildMenu(_ keyboard: KeyboardSettings = KeyboardSettings(), modules: Set = []) -> NSMenu { + MainMenuBuilder.build(keyboard: keyboard, modules: modules).bar } private func flatten(_ menu: NSMenu) -> [NSMenuItem] { @@ -186,9 +186,10 @@ struct MainMenuStructureTests { @MainActor struct MainMenuShortcutCoverageTests { + // Every module on: a module's actions have menu items only while it is. @Test("Every customizable action reaches exactly one menu item") func everyShortcutActionIsReachable() { - let identifiers = flatten(buildMenu()).compactMap(\.identifier?.rawValue) + let identifiers = flatten(buildMenu(modules: Set(AppModule.allCases))).compactMap(\.identifier?.rawValue) for action in ShortcutAction.allCases { let expected = MenuItemFactory.identifier(for: action).rawValue let matches = identifiers.filter { $0 == expected }.count diff --git a/apple/TableProTests/Core/Modules/AppModuleResidueTests.swift b/apple/TableProTests/Core/Modules/AppModuleResidueTests.swift new file mode 100644 index 0000000000..740c691310 --- /dev/null +++ b/apple/TableProTests/Core/Modules/AppModuleResidueTests.swift @@ -0,0 +1,231 @@ +import AppKit +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +// An off module leaves nothing behind (apple/AGENTS.md, Scope). With it off no path opens a debug tab, so none can +// reach an MCP snapshot, AppleScript or recent tabs; the debug tab's own tests pin that one left open is excluded. +@MainActor +struct AppModuleResidueTests { + private static let debugActions: [ShortcutAction] = [ + .debugContinue, .debugStepOver, .debugStepInto, .debugStepOut, .toggleBreakpoint, + ] + + private static let routine = DatabaseTreeRoutineRef( + database: "app", + schema: "public", + routine: RoutineInfo(name: "order_total", kind: .function, schema: "public", language: "plpgsql") + ) + + private static let target = PluginDebugTarget( + identity: "16431", + name: "order_total", + schema: "public", + kindDisplayName: "Function" + ) + + private func modules(on: Bool, in suite: EphemeralDefaults) -> AppModuleSettings { + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + settings.setEnabled(on, for: .routineDebugger) + return settings + } + + private func flatten(_ menu: NSMenu) -> [NSMenuItem] { + menu.items.flatMap { item -> [NSMenuItem] in + guard let submenu = item.submenu else { return [item] } + return [item] + flatten(submenu) + } + } + + private func debugItems(in bar: NSMenu) -> [NSMenuItem] { + let identifiers = Set(Self.debugActions.map { MenuItemFactory.identifier(for: $0) }) + return flatten(bar).filter { item in + if let action = item.action, MainSplitViewController.routineDebugSelectors.contains(action) { return true } + if let identifier = item.identifier, identifiers.contains(identifier) { return true } + return item.identifier == DebugMenuBuilder.identifier || item.title == DebugMenuBuilder.title + } + } + + private func routineMenu(canDebug: Bool) -> [DatabaseTreeMenuSection] { + var context = DatabaseTreeMenuSpecTests().context(clicked: .routine(Self.routine)) + context.canDebugRoutine = canDebug + return DatabaseTreeMenuSpec.sections(for: context) + } + + private func titles(_ sections: [DatabaseTreeMenuSection]) -> [String] { + sections.flatMap { $0.items }.compactMap { item in + guard case .command(let entry) = item else { return nil } + return entry.title + } + } + + private func keyboardRows(_ enabled: Set) -> [ShortcutAction] { + ShortcutCategory.allCases.flatMap { ShortcutAction.settingsRows(in: $0, enabledModules: enabled) } + } + + // MARK: - Off + + @Test("Off: the built menu bar has no Debug menu, no debug command and no debug key equivalent") + func offLeavesNoMenu() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: false, in: suite) + let bar = MainMenuBuilder.build(keyboard: KeyboardSettings(), modules: settings.enabledModules).bar + + let leftovers = debugItems(in: bar).map { $0.title } + #expect(leftovers.isEmpty, "Debug items in the menu bar with the module off: \(leftovers)") + + let debugKeys = Set(Self.debugActions.compactMap { KeyboardSettings().menuKeyEquivalent(for: $0) } + .map { "\($0.modifiers.rawValue)-\($0.characters)" }) + let claimed = flatten(bar) + .filter { !$0.keyEquivalent.isEmpty } + .map { "\($0.keyEquivalentModifierMask.rawValue)-\($0.keyEquivalent)" } + .filter { debugKeys.contains($0) } + #expect(claimed.isEmpty, "A debug default still reaches the menu bar: \(claimed)") + } + + @Test("Off: a routine row offers no Debug… and the debugger is never asked") + func offLeavesNoSidebarItem() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: false, in: suite) + var asked = 0 + let canDebug = DatabaseTreeMenuContext.canDebugRoutine(.routine(Self.routine), modules: settings) { _ in + asked += 1 + return Self.target + } + let sections = routineMenu(canDebug: canDebug) + let issued = DatabaseTreeMenuSpecTests().commands(sections) + + #expect(!canDebug) + #expect(asked == 0) + #expect(!issued.contains(.debugRoutine(Self.routine))) + #expect(!titles(sections).contains(String(localized: "Debug…"))) + } + + @Test("Off: Settings > Keyboard lists no debug row and no Debug section") + func offLeavesNoKeyboardRows() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: false, in: suite) + let rows = keyboardRows(settings.enabledModules) + + for action in Self.debugActions { + #expect(!rows.contains(action), "\(action.rawValue)") + } + #expect(ShortcutAction.settingsRows(in: .debug, enabledModules: settings.enabledModules).isEmpty) + } + + @Test("Off: recording a debug default for another command names no hidden debug command") + func offDebugDefaultsClaimNothing() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: false, in: suite) + let keyboard = KeyboardSettings() + + for action in Self.debugActions { + guard let key = KeyboardSettings.defaultShortcuts[action] else { continue } + #expect( + keyboard.findConflict(for: key, excluding: .refresh, enabledModules: settings.enabledModules) == nil, + "\(key.displayString) is reported against \(action.rawValue)" + ) + } + } + + // Read from the host's own defaults, which no test turns the module on in. + @Test("Off: a debug binding the user reassigned earlier stands no other command down") + func offDebugOverrideClaimsNothing() throws { + try #require(!AppModule.storedEnabledModules().contains(.routineDebugger)) + let refresh = try #require(KeyboardSettings.defaultShortcuts[.refresh]) + let keyboard = KeyboardSettings(shortcuts: [ShortcutAction.debugStepOver.rawValue: refresh]) + + #expect(keyboard.shortcut(for: .refresh) == refresh) + } + + // The coordinator reads the host's own module state, which no test turns on. + @Test("Off: no path opens a debug tab or a debug session") + func offOpensNoDebugTab() throws { + try #require(!AppModuleSettings.shared.isEnabled(.routineDebugger)) + let fixture = CoordinatorFixture() + let coordinator = fixture.coordinator + + #expect(coordinator.routineDebugTarget(for: Self.routine.routine) == nil) + #expect(!coordinator.canChooseRoutineToDebug()) + coordinator.debugRoutine(Self.routine.routine) + coordinator.chooseRoutineToDebug() + + #expect(fixture.tabManager.tabs.isEmpty) + #expect(coordinator.frontRoutineDebugController == nil) + #expect(!RoutineDebugSessions.shared.hasActiveSessions) + } + + // MARK: - On + + @Test("On: the Debug menu sits after Query and before Window") + func onAddsTheMenu() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: true, in: suite) + let titles = MainMenuBuilder.build(keyboard: KeyboardSettings(), modules: settings.enabledModules).bar + .items.map { $0.title } + + let query = try #require(titles.firstIndex(of: String(localized: "Query"))) + let debug = try #require(titles.firstIndex(of: DebugMenuBuilder.title)) + let window = try #require(titles.firstIndex(of: String(localized: "Window"))) + #expect(debug == query + 1) + #expect(window == debug + 1) + } + + @Test("On: a routine the driver can debug offers Debug…") + func onAddsTheSidebarItem() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: true, in: suite) + let canDebug = DatabaseTreeMenuContext.canDebugRoutine(.routine(Self.routine), modules: settings) { _ in + Self.target + } + let sections = routineMenu(canDebug: canDebug) + + #expect(canDebug) + #expect(DatabaseTreeMenuSpecTests().commands(sections).contains(.debugRoutine(Self.routine))) + #expect(titles(sections).contains(String(localized: "Debug…"))) + } + + @Test("On: Settings > Keyboard lists the debug rows under Debug") + func onAddsKeyboardRows() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: true, in: suite) + + #expect(ShortcutAction.settingsRows(in: .debug, enabledModules: settings.enabledModules) == Self.debugActions) + let rows = keyboardRows(settings.enabledModules) + for action in Self.debugActions { + #expect(rows.contains(action), "\(action.rawValue)") + } + } + + @Test("On: a debug default is reported against its command when recorded for another") + func onDebugDefaultsAreClaimed() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: true, in: suite) + let stepOver = try #require(KeyboardSettings.defaultShortcuts[.debugStepOver]) + + #expect( + KeyboardSettings().findConflict(for: stepOver, excluding: .refresh, enabledModules: settings.enabledModules) + == .debugStepOver + ) + } + + // MARK: - Switching + + @Test("Switching the module adds and removes the Debug menu in the installed bar at once") + func switchingEditsTheInstalledBar() throws { + let built = MainMenuBuilder.build(keyboard: KeyboardSettings(), modules: []) + let offTitles = built.bar.items.map { $0.title } + + MainMenuBuilder.applyModules([.routineDebugger], keyboard: KeyboardSettings(), to: built.bar, windowsMenu: built.windowsMenu) + MainMenuBuilder.applyModules([.routineDebugger], keyboard: KeyboardSettings(), to: built.bar, windowsMenu: built.windowsMenu) + let onTitles = built.bar.items.map { $0.title } + let onBuilt = MainMenuBuilder.build(keyboard: KeyboardSettings(), modules: [.routineDebugger]).bar.items.map { $0.title } + #expect(onTitles == onBuilt) + #expect(built.bar.items.filter { $0.identifier == DebugMenuBuilder.identifier }.count == 1) + + MainMenuBuilder.applyModules([], keyboard: KeyboardSettings(), to: built.bar, windowsMenu: built.windowsMenu) + #expect(built.bar.items.map { $0.title } == offTitles) + #expect(debugItems(in: built.bar).isEmpty) + } +} diff --git a/apple/TableProTests/Core/Modules/AppModuleSettingsTests.swift b/apple/TableProTests/Core/Modules/AppModuleSettingsTests.swift new file mode 100644 index 0000000000..9ba525bbbd --- /dev/null +++ b/apple/TableProTests/Core/Modules/AppModuleSettingsTests.swift @@ -0,0 +1,107 @@ +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct AppModuleSettingsTests { + @Test("Every module starts off on a Mac that never turned it on") + func modulesStartOff() throws { + let suite = try EphemeralDefaults() + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + + for module in AppModule.allCases { + #expect(module.defaultEnabled == false, "\(module.rawValue)") + #expect(!settings.isEnabled(module), "\(module.rawValue)") + } + #expect(settings.enabledModules.isEmpty) + } + + @Test("Turning a module on is stored in the defaults suite and read back by a new instance") + func stateSurvivesRelaunch() throws { + let suite = try EphemeralDefaults() + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + + settings.setEnabled(true, for: .routineDebugger) + + #expect(suite.defaults.object(forKey: AppModule.routineDebugger.defaultsKey) as? Bool == true) + let relaunched = AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + #expect(relaunched.isEnabled(.routineDebugger)) + #expect(AppModule.enabledModules(in: suite.defaults) == [.routineDebugger]) + + settings.setEnabled(false, for: .routineDebugger) + #expect(!AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + .isEnabled(.routineDebugger)) + } + + @Test("Reset All Settings turns every module back to its default") + func resetRestoresDefaults() throws { + let suite = try EphemeralDefaults() + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + settings.setEnabled(true, for: .routineDebugger) + + AppModuleActivation.resetToDefaults(settings: settings) + + for module in AppModule.allCases { + #expect(settings.isEnabled(module) == module.defaultEnabled, "\(module.rawValue)") + } + } + + @Test("The change notification names the module and arrives after the new state is stored") + func notificationFollowsTheStore() throws { + let suite = try EphemeralDefaults() + let center = NotificationCenter() + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: center) + let defaults = suite.defaults + var received: [(module: AppModule?, storedEnabled: Bool?)] = [] + let observer = center.addObserver(forName: .appModulesDidChange, object: nil, queue: nil) { note in + received.append(( + note.object as? AppModule, + defaults.object(forKey: AppModule.routineDebugger.defaultsKey) as? Bool + )) + } + defer { center.removeObserver(observer) } + + settings.setEnabled(true, for: .routineDebugger) + settings.setEnabled(false, for: .routineDebugger) + + let modules = received.map { $0.module } + let stored = received.map { $0.storedEnabled } + #expect(modules == [.routineDebugger, .routineDebugger]) + #expect(stored == [true, false]) + } + + @Test("Setting the state a module already has stores and posts nothing") + func unchangedStateIsSilent() throws { + let suite = try EphemeralDefaults() + let center = NotificationCenter() + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: center) + var posts = 0 + let observer = center.addObserver(forName: .appModulesDidChange, object: nil, queue: nil) { _ in + posts += 1 + } + defer { center.removeObserver(observer) } + + settings.setEnabled(false, for: .routineDebugger) + + #expect(posts == 0) + #expect(suite.defaults.object(forKey: AppModule.routineDebugger.defaultsKey) == nil) + } + + @Test("The key is namespaced and per module") + func keysAreNamespaced() { + let keys = AppModule.allCases.map { $0.defaultsKey } + #expect(Set(keys).count == keys.count) + for key in keys { + #expect(key.hasPrefix("com.TablePro.modules."), "\(key)") + } + } + + @Test("Every module names itself and says what it does") + func modulesDescribeThemselves() { + for module in AppModule.allCases { + #expect(!module.title.isEmpty) + #expect(!module.summary.isEmpty) + } + #expect(AppModule.routineDebugger.rawValue == "routineDebugger") + } +} diff --git a/apple/TableProTests/Core/Modules/AppModuleTerminationTests.swift b/apple/TableProTests/Core/Modules/AppModuleTerminationTests.swift new file mode 100644 index 0000000000..2e7620e902 --- /dev/null +++ b/apple/TableProTests/Core/Modules/AppModuleTerminationTests.swift @@ -0,0 +1,48 @@ +import Foundation +@testable import TablePro +import TableProTestSupport +import Testing + +@MainActor +private final class StopRecord { + var didStop = false +} + +@MainActor +struct AppModuleTerminationTests { + @Test("Quitting waits for a stop that finishes in time") + func stopInTimeIsAwaited() async { + let clock = ManualClock() + let record = StopRecord() + + let finished = await AppModuleTermination.runCapped(at: AppModuleTermination.stopLimit, clock: clock) { + record.didStop = true + } + + #expect(finished) + #expect(record.didStop) + } + + @Test("A stop that never returns holds the quit no longer than the limit") + func stuckStopIsAbandonedAtTheLimit() async { + let clock = ManualClock() + let stuck = AsyncGate() + let run = Task { @MainActor in + await AppModuleTermination.runCapped(at: .seconds(5), clock: clock) { + await stuck.wait() + } + } + + await stuck.arrived(1) + await clock.waitUntilSleeping() + clock.advance(by: .seconds(5)) + + #expect(await run.value == false) + await stuck.open() + } + + @Test("The quit limit is five seconds") + func limitIsFiveSeconds() { + #expect(AppModuleTermination.stopLimit == .seconds(5)) + } +} diff --git a/apple/TableProTests/Core/ObjectCopy/ObjectCopyDDLSessionTests.swift b/apple/TableProTests/Core/ObjectCopy/ObjectCopyDDLSessionTests.swift index 535157f743..cc8368d46c 100644 --- a/apple/TableProTests/Core/ObjectCopy/ObjectCopyDDLSessionTests.swift +++ b/apple/TableProTests/Core/ObjectCopy/ObjectCopyDDLSessionTests.swift @@ -180,7 +180,8 @@ struct ObjectCopyDDLSessionTests { createsDatabase: false, tableSteps: tables, definitionSteps: definitions, - isStructureReadPinned: isStructureReadPinned + isStructureReadPinned: isStructureReadPinned, + keysInside: ObjectCopyKeysInside(created: true, rows: true) ) } @@ -200,7 +201,9 @@ struct ObjectCopyDDLSessionTests { let result = try await run(plan(tables: [tableStep()]), on: driver) #expect(result.failedCount == 0) - #expect(driver.events == ["open verbatimWrite", createTable, "close verbatimWrite"]) + #expect(driver.events == [ + "open relaxedForeignKeys", "open verbatimWrite", createTable, "close verbatimWrite", "close relaxedForeignKeys" + ]) } @Test("A failed CREATE TABLE still closes the write scope") @@ -210,7 +213,9 @@ struct ObjectCopyDDLSessionTests { let result = try await run(plan(tables: [tableStep()]), on: driver) #expect(result.failedCount == 1) - #expect(driver.events == ["open verbatimWrite", createTable, "close verbatimWrite"]) + #expect(driver.events == [ + "open relaxedForeignKeys", "open verbatimWrite", createTable, "close verbatimWrite", "close relaxedForeignKeys" + ]) } /// MariaDB renders a TIMESTAMP default in the session's zone, so DDL read as the session is @@ -221,7 +226,7 @@ struct ObjectCopyDDLSessionTests { _ = try await run(plan(tables: [tableStep()], isStructureReadPinned: false), on: driver) - #expect(driver.events == [createTable]) + #expect(driver.events == ["open relaxedForeignKeys", createTable, "close relaxedForeignKeys"]) } @Test("A copy between engines creates its tables under the session as it is") @@ -230,7 +235,7 @@ struct ObjectCopyDDLSessionTests { _ = try await run(plan(from: .postgresql, tables: [tableStep()]), on: driver) - #expect(driver.events == [createTable]) + #expect(driver.events == ["open relaxedForeignKeys", createTable, "close relaxedForeignKeys"]) } // MARK: - Reading the source tables @@ -289,7 +294,7 @@ struct ObjectCopyDDLSessionTests { #expect(result.failedCount == 0) #expect(driver.events == [ - "open verbatimWrite", createTable, "close verbatimWrite", + "open relaxedForeignKeys", "open verbatimWrite", createTable, "close verbatimWrite", "close relaxedForeignKeys", saveMode, setMode, createProcedure, restoreMode ]) } diff --git a/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift b/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift index 377c2c42be..c96879b303 100644 --- a/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift +++ b/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift @@ -90,6 +90,41 @@ struct ObjectCopyEligibilityTests { #expect(ObjectCopyEligibility.definitionEngineRefusal(from: .postgresql, to: .postgresql) == nil) } + private static let oracleModeSession = PluginSessionLexicalState( + determined: [.plsqlBlocks, .alternativeQuoting], enabled: [.plsqlBlocks, .alternativeQuoting] + ) + private static let pgModeSession = PluginSessionLexicalState(determined: [.plsqlBlocks], enabled: []) + + @Test + func kingbaseESCopiesWithPostgreSQLOnlyInPostgreSQLMode() { + #expect(ObjectCopyEligibility.engineRefusal( + from: .postgresql, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: nil, targetSession: Self.pgModeSession + ) == nil) + #expect(ObjectCopyEligibility.definitionEngineRefusal( + from: .kingbasees, to: .postgresql, sourceSession: Self.pgModeSession, targetSession: nil + ) == nil) + #expect(ObjectCopyEligibility.engineRefusal( + from: .postgresql, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: nil, targetSession: Self.oracleModeSession + ) != nil) + #expect(ObjectCopyEligibility.definitionEngineRefusal( + from: .kingbasees, to: .postgresql, sourceSession: Self.oracleModeSession, targetSession: nil + ) != nil) + } + + @Test + func kingbaseESCopiesBetweenServersOfTheSameModeOnly() { + #expect(ObjectCopyEligibility.engineRefusal( + from: .kingbasees, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: Self.oracleModeSession, targetSession: Self.oracleModeSession + ) == nil) + #expect(ObjectCopyEligibility.engineRefusal( + from: .kingbasees, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: Self.oracleModeSession, targetSession: Self.pgModeSession + ) != nil) + } + /// Copying a database onto itself either drops the rows it is about to read or doubles them. @Test func theSameScopeIsRefusedAsATarget() { diff --git a/apple/TableProTests/Core/ObjectCopy/ObjectCopyPlanTests.swift b/apple/TableProTests/Core/ObjectCopy/ObjectCopyPlanTests.swift index bd62b9c081..aedfff83c8 100644 --- a/apple/TableProTests/Core/ObjectCopy/ObjectCopyPlanTests.swift +++ b/apple/TableProTests/Core/ObjectCopy/ObjectCopyPlanTests.swift @@ -128,6 +128,11 @@ struct ObjectCopyPlanTests { [step("orders")], request: request(errorHandling: .skipAndContinue) ).clearsInsideDataTransaction)) + /// PostgreSQL commits a transaction a failure aborted as a ROLLBACK, which keeps nothing. + #expect(!(plan( + [step("orders")], + request: request(errorHandling: .stopAndCommit) + ).clearsInsideDataTransaction)) #expect(!(plan( [step("orders")], request: request(wrapEachTableInTransaction: false) diff --git a/apple/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift b/apple/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift index 83e1162625..8572262700 100644 --- a/apple/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift +++ b/apple/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift @@ -48,6 +48,15 @@ struct ObjectCopyPlannerOrderingTests { ) } + private func parentsFirst( + _ selections: [ObjectCopySelection], + reads: [ObjectCopySelection: TableStructureRead] + ) -> [ObjectCopySelection] { + ObjectCopyPlanner.orderedByDependency( + [(namespace: "public", tables: selections)], reads: reads + ) + } + @Test func aParentIsCreatedBeforeItsChild() { let orders = selection("orders") @@ -57,9 +66,7 @@ struct ObjectCopyPlannerOrderingTests { customers: read("customers") ] - let ordered = ObjectCopyPlanner.orderedByDependency( - [orders, customers], reads: reads, effectiveSchema: "public" - ) + let ordered = parentsFirst([orders, customers], reads: reads) #expect(ordered.map(\.name) == ["customers", "orders"]) } @@ -76,14 +83,67 @@ struct ObjectCopyPlannerOrderingTests { customers: read("customers") ] - let ordered = ObjectCopyPlanner.orderedByDependency( - [orders, customers], reads: reads, effectiveSchema: "public" - ) + let ordered = parentsFirst([orders, customers], reads: reads) #expect(ordered.map(\.name) == ["customers", "orders"], "the edge has to survive the schema the table itself does not report") } + /// Sorted one scope at a time, a PostgreSQL database copy created `app.orders` before the + /// `core.users` it references, because `app` was the first scope. + @Test + func aParentInAnotherScopeIsCreatedFirst() { + let orders = selection("orders", schema: "app") + let users = selection("users", schema: "core") + let reads: [ObjectCopySelection: TableStructureRead] = [ + orders: read("orders", referencing: ["users"], referencedSchema: "core"), + users: read("users") + ] + + let ordered = ObjectCopyPlanner.orderedByDependency( + [(namespace: "app", tables: [orders]), (namespace: "core", tables: [users])], + reads: reads + ) + + #expect(ordered.map(\.qualifiedName) == ["core.users", "app.orders"]) + } + + /// A case-sensitive MySQL server holds `Shop` and `shop` side by side, so a key into `shop` is + /// no edge in `Shop`. Without one the tie-break puts `buildings` first. + @Test + func aDatabaseSpelledInAnotherCaseIsAnotherDatabase() { + let buildings = selection("buildings", schema: nil) + let estates = selection("estates", schema: nil) + let reads: [ObjectCopySelection: TableStructureRead] = [ + buildings: read("buildings", referencing: ["estates"], referencedSchema: "shop"), + estates: read("estates") + ] + + let ordered = ObjectCopyPlanner.orderedByDependency( + [(namespace: "Shop", tables: [buildings, estates])], reads: reads + ) + + #expect(ordered.map(\.name) == ["buildings", "estates"]) + } + + /// PostgreSQL schemas `App` and `app` are two schemas, so a key into `App` is no edge in `app`. + /// Without one the tie-break puts `orders` first. + @Test + func aSchemaSpelledInAnotherCaseIsAnotherSchema() { + let orders = selection("orders", schema: "app") + let users = selection("users", schema: "app") + let reads: [ObjectCopySelection: TableStructureRead] = [ + orders: read("orders", referencing: ["users"], referencedSchema: "App"), + users: read("users") + ] + + let ordered = ObjectCopyPlanner.orderedByDependency( + [(namespace: "app", tables: [users, orders])], reads: reads + ) + + #expect(ordered.map(\.name) == ["orders", "users"]) + } + /// A cycle cannot be ordered, and dropping one of its tables would be worse than letting the /// server refuse the second CREATE, so every input still comes back exactly once. @Test @@ -95,9 +155,7 @@ struct ObjectCopyPlannerOrderingTests { second: read("b", referencing: ["a"]) ] - let ordered = ObjectCopyPlanner.orderedByDependency( - [first, second], reads: reads, effectiveSchema: "public" - ) + let ordered = parentsFirst([first, second], reads: reads) #expect(Set(ordered.map(\.name)) == ["a", "b"]) #expect(ordered.count == 2) @@ -106,9 +164,7 @@ struct ObjectCopyPlannerOrderingTests { @Test func aSingleTableIsReturnedUnchanged() { let only = selection("orders") - #expect(ObjectCopyPlanner.orderedByDependency( - [only], reads: [only: read("orders")], effectiveSchema: "public" - ).map(\.name) == ["orders"]) + #expect(parentsFirst([only], reads: [only: read("orders")]).map(\.name) == ["orders"]) } @Test @@ -302,9 +358,7 @@ struct ObjectCopyPlannerOrderingTests { let unread = names.map { selection($0) } let reads: [ObjectCopySelection: TableStructureRead] = [placed: read("customers")] - let ordered = ObjectCopyPlanner.orderedByDependency( - [placed] + unread, reads: reads, effectiveSchema: "public" - ) + let ordered = parentsFirst([placed] + unread, reads: reads) #expect(ordered.map(\.name) == ["customers"] + names) } @@ -320,12 +374,8 @@ struct ObjectCopyPlannerOrderingTests { var backwards: [ObjectCopySelection: TableStructureRead] = [:] for (selection, name) in zip(selections, names).reversed() { backwards[selection] = read(name) } - let first = ObjectCopyPlanner.orderedByDependency( - selections, reads: forwards, effectiveSchema: "public" - ) - let second = ObjectCopyPlanner.orderedByDependency( - selections, reads: backwards, effectiveSchema: "public" - ) + let first = parentsFirst(selections, reads: forwards) + let second = parentsFirst(selections, reads: backwards) #expect(first.map(\.name) == second.map(\.name)) #expect(Set(first.map(\.name)) == Set(names)) @@ -344,9 +394,7 @@ struct ObjectCopyPlannerOrderingTests { audit: read("audit") ] - let ordered = ObjectCopyPlanner.orderedByDependency( - [orders, audit, customers], reads: reads, effectiveSchema: "public" - ) + let ordered = parentsFirst([orders, audit, customers], reads: reads) guard let parent = ordered.firstIndex(of: customers), let child = ordered.firstIndex(of: orders) else { @@ -356,4 +404,135 @@ struct ObjectCopyPlannerOrderingTests { #expect(parent < child) #expect(ordered.count == 3) } + + // MARK: - Keys inside the copy + + private func keysInside( + _ selections: [ObjectCopySelection], + reads: [ObjectCopySelection: TableStructureRead], + created: Set? = nil, + rows: [ObjectCopySelection: Bool] + ) -> ObjectCopyKeysInside { + ObjectCopyPlanner.keysInside( + [(namespace: "public", tables: selections)], reads: reads, created: created ?? Set(selections), rows: rows + ) + } + + @Test + func aCycleCopiedWholeKeepsItsKeysInside() { + let orders = selection("orders", schema: nil) + let customers = selection("customers", schema: nil) + let reads = [ + orders: read("orders", referencing: ["customers"]), + customers: read("customers", referencing: ["orders", "customers"]) + ] + + let inside = keysInside([orders, customers], reads: reads, rows: [orders: true, customers: true]) + + #expect(inside == ObjectCopyKeysInside(created: true, rows: true)) + } + + @Test + func aChildWithoutItsParentPointsOutside() { + let orders = selection("orders") + + let inside = keysInside([orders], reads: [orders: read("orders", referencing: ["customers"])], rows: [orders: true]) + + #expect(inside == ObjectCopyKeysInside(created: false, rows: false)) + } + + /// A table that gets no rows only needs its parent created, not filled. + @Test + func aParentMissingRowsLeavesOnlyTheRowsOutside() { + let orders = selection("orders") + let customers = selection("customers") + let reads = [orders: read("orders", referencing: ["customers"]), customers: read("customers")] + + #expect(keysInside([orders, customers], reads: reads, rows: [orders: true, customers: false]) + == ObjectCopyKeysInside(created: true, rows: false)) + #expect(keysInside([orders, customers], reads: reads, rows: [orders: true]) + == ObjectCopyKeysInside(created: true, rows: false)) + #expect(keysInside([orders, customers], reads: reads, rows: [:]) + == ObjectCopyKeysInside(created: true, rows: true)) + } + + /// The source's keys say nothing about a table the target keeps, which may reference a parent the copy never touches. + @Test + func aTableTheTargetKeepsLeavesTheRowsOutside() { + let orders = selection("orders") + + let inside = keysInside([orders], reads: [orders: read("orders")], created: [], rows: [orders: true]) + + #expect(inside == ObjectCopyKeysInside(created: true, rows: false)) + } + + @Test + func aKeyIntoAnotherSchemaNeedsThatSchemasTable() { + let orders = selection("orders", schema: "app") + let users = selection("users", schema: "core") + let reads = [ + orders: read("orders", tableSchema: "app", referencing: ["users"], referencedSchema: "core"), + users: read("users", tableSchema: "core") + ] + + let whole = ObjectCopyPlanner.keysInside( + [(namespace: "app", tables: [orders]), (namespace: "core", tables: [users])], + reads: reads, + created: [orders, users], + rows: [orders: true, users: true] + ) + let alone = ObjectCopyPlanner.keysInside( + [(namespace: "app", tables: [orders])], reads: reads, created: [orders], rows: [orders: true] + ) + + #expect(whole == ObjectCopyKeysInside(created: true, rows: true)) + #expect(alone == ObjectCopyKeysInside(created: false, rows: false)) + } + + // MARK: - Key checks + + private func key( + _ name: String, _ columns: [String], references table: String, _ referenced: [String] + ) -> EditableForeignKeyDefinition { + EditableForeignKeyDefinition( + id: UUID(), name: name, columns: columns, referencedTable: table, referencedColumns: referenced, + referencedSchema: nil, onDelete: .noAction, onUpdate: .noAction + ) + } + + private func keyChecks(_ keys: [EditableForeignKeyDefinition]) -> [ObjectCopyKeyCheck]? { + ObjectCopyPlanner.keyChecks( + for: TableStructureSnapshot(name: "lines", columns: [], foreignKeys: keys), + schema: nil, + driver: QuotingDriver(), + databaseType: .sqlite + ) + } + + @Test + func aCompositeKeyIsCheckedOnEveryColumnAndSkipsNulls() { + let checks = keyChecks([key("fk_order", ["order_id", "shop_id"], references: "orders", ["id", "shop_id"])]) + + #expect(checks == [ObjectCopyKeyCheck( + constraint: "fk_order", + sql: #"SELECT COUNT(*) FROM "lines" c WHERE c."order_id" IS NOT NULL AND c."shop_id" IS NOT NULL "# + + #"AND NOT EXISTS (SELECT 1 FROM "orders" p WHERE p."id" = c."order_id" AND p."shop_id" = c."shop_id")"# + )]) + } + + @Test + func aSelfReferenceIsCheckedAgainstItsOwnTable() { + let checks = keyChecks([key("fk_parent", ["parent_id"], references: "lines", ["id"])]) + + #expect(checks?.first?.sql.contains(#"FROM "lines" c"#) == true) + #expect(checks?.first?.sql.contains(#"FROM "lines" p"#) == true) + } + + @Test + func aKeyWhoseColumnsDoNotPairCannotBeChecked() { + #expect(keyChecks([key("fk_bad", ["a", "b"], references: "orders", ["id"])]) == nil) + #expect(keyChecks([]) == []) + } } + +private final class QuotingDriver: PluginDriverStub, @unchecked Sendable {} diff --git a/apple/TableProTests/Core/ObjectCopy/ObjectCopyPlannerStructureReadTests.swift b/apple/TableProTests/Core/ObjectCopy/ObjectCopyPlannerStructureReadTests.swift new file mode 100644 index 0000000000..947d035fc1 --- /dev/null +++ b/apple/TableProTests/Core/ObjectCopy/ObjectCopyPlannerStructureReadTests.swift @@ -0,0 +1,109 @@ +// +// ObjectCopyPlannerStructureReadTests.swift +// TableProTests +// +// What reading the source costs before the review shows. A selection of +// every table takes the whole-schema reads; a strict subset keeps the +// per-table ones. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class BulkReadCountingDriver: PluginDriverStub, @unchecked Sendable { + private let lock = NSLock() + private var log: [String] = [] + private let listed: [PluginTableInfo] + + init(_ names: [String]) { + listed = names.map { PluginTableInfo(name: $0, type: "TABLE", schema: nil, comment: nil) } + } + + var calls: [String] { lock.withLock { log } } + + private func record(_ call: String) { + lock.withLock { log.append(call) } + } + + private var column: [PluginColumnInfo] { [PluginColumnInfo(name: "id", dataType: "int")] } + + var providesBulkColumnFetch: Bool { true } + var providesBulkIndexFetch: Bool { true } + var providesBulkForeignKeyFetch: Bool { true } + var providesBulkTableMetadataFetch: Bool { true } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { listed } + + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { + record("fetchAllColumns") + return Dictionary(uniqueKeysWithValues: listed.map { ($0.name, column) }) + } + + func fetchAllIndexes(schema: String?) async throws -> [String: [PluginIndexInfo]] { + record("fetchAllIndexes") + return [:] + } + + func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { + record("fetchAllForeignKeys") + return [:] + } + + func fetchAllTableMetadata(schema: String?) async throws -> [String: PluginTableMetadata] { + record("fetchAllTableMetadata") + return [:] + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + record("fetchColumns \(table)") + return column + } +} + +struct ObjectCopyPlannerStructureReadTests { + /// Every name handed over, in another case than the server's, still covers the scope. + @Test + func aSelectionOfEveryTableReadsTheWholeSchema() async throws { + let driver = BulkReadCountingDriver(["estates", "buildings", "users"]) + + let read = try await ObjectCopyPlanner.pinnedTableReads( + names: ["Estates", "buildings", "users"], schema: nil, databaseType: .mysql, using: driver + ) + + #expect(read.reads.map(\.table.name) == ["estates", "buildings", "users"]) + #expect(driver.calls.filter { $0 == "fetchAllColumns" }.count == 1) + #expect(!driver.calls.contains { $0.hasPrefix("fetchColumns") }) + } + + @Test + func aStrictSubsetKeepsThePerTableReads() async throws { + let driver = BulkReadCountingDriver(["estates", "buildings", "users"]) + + let read = try await ObjectCopyPlanner.pinnedTableReads( + names: ["estates"], schema: nil, databaseType: .mysql, using: driver + ) + + #expect(read.reads.map(\.table.name) == ["estates"]) + #expect(!driver.calls.contains("fetchAllColumns")) + #expect(driver.calls.contains("fetchColumns estates")) + #expect(!driver.calls.contains("fetchColumns users")) + } + + /// A copy never offers a foreign table or a sequence, and leaving a view out costs a bulk read + /// little, so none of them makes the selection a subset. + @Test + func onlyATableLeftOutNarrowsTheRead() { + let listed = [ + PluginTableInfo(name: "orders", type: "TABLE", schema: nil, comment: nil), + PluginTableInfo(name: "recent", type: "VIEW", schema: nil, comment: nil), + PluginTableInfo(name: "remote", type: "FOREIGN TABLE", schema: nil, comment: nil), + PluginTableInfo(name: "orders_id_seq", type: "SEQUENCE", schema: nil, comment: nil) + ] + + #expect(!CompareMetadataService.narrows(listed, to: ["orders"])) + #expect(CompareMetadataService.narrows(listed, to: ["recent"])) + #expect(!CompareMetadataService.narrows(listed, to: nil), "a comparison names nothing and reads the scope") + } +} diff --git a/apple/TableProTests/Core/ObjectCopy/ObjectCopySessionScopeTests.swift b/apple/TableProTests/Core/ObjectCopy/ObjectCopySessionScopeTests.swift index c89b4dd285..d469f19cc2 100644 --- a/apple/TableProTests/Core/ObjectCopy/ObjectCopySessionScopeTests.swift +++ b/apple/TableProTests/Core/ObjectCopy/ObjectCopySessionScopeTests.swift @@ -331,7 +331,8 @@ struct ObjectCopySessionScopeTests { ), createsDatabase: false, tableSteps: [step()], - definitionSteps: [] + definitionSteps: [], + keysInside: ObjectCopyKeysInside(created: true, rows: true) ) let provider = TwoConnectionProvider(drivers: [ sourceScope.connectionId: PluginDriverAdapter( @@ -356,7 +357,10 @@ struct ObjectCopySessionScopeTests { #expect(result.rowsCopied == 2) #expect(source.events == ["open verbatimRead", "stream", "close verbatimRead"]) - #expect(target.events == ["begin", "open verbatimWrite", "insert", "close verbatimWrite", "commit"]) + #expect(target.events == [ + "begin", "open relaxedForeignKeys", "open verbatimWrite", "insert", "close verbatimWrite", + "close relaxedForeignKeys", "commit" + ]) } @MainActor @Test("The runner opens no scope for a copy between engines") @@ -368,6 +372,6 @@ struct ObjectCopySessionScopeTests { #expect(result.rowsCopied == 2) #expect(source.events == ["stream"]) - #expect(target.events == ["begin", "insert", "commit"]) + #expect(target.events == ["begin", "open relaxedForeignKeys", "insert", "close relaxedForeignKeys", "commit"]) } } diff --git a/apple/TableProTests/Core/ObjectCopy/ObjectCopyTransactionTests.swift b/apple/TableProTests/Core/ObjectCopy/ObjectCopyTransactionTests.swift index 80bf80c55e..ab8b8205a5 100644 --- a/apple/TableProTests/Core/ObjectCopy/ObjectCopyTransactionTests.swift +++ b/apple/TableProTests/Core/ObjectCopy/ObjectCopyTransactionTests.swift @@ -14,69 +14,217 @@ import Foundation import TableProPluginKit import Testing -@MainActor -private final class StubMetadataProvider: ScopedMetadataProviding { - private let driver: DatabaseDriver +private struct StatementFailure: Error {} - init(driver: DatabaseDriver) { - self.driver = driver - } +private final class CopyEventLog: @unchecked Sendable { + private let lock = NSLock() + private var entries: [String] = [] - func withMetadataDriver( - scope: DatabaseScope, - workload: MetadataConnectionPool.Workload, - _ body: @Sendable @escaping (DatabaseDriver) async throws -> T - ) async throws -> T { - try await body(driver) - } + var events: [String] { lock.withLock { entries } } - func browseScope(for connectionId: UUID) -> DatabaseScope? { nil } + func record(_ event: String) { + lock.withLock { entries.append(event) } + } } private final class RecordingCopyDriver: PluginDriverStub, @unchecked Sendable { - private let lock = NSLock() - private var statements: [String] = [] - private var transactions: [String] = [] - private var state: PluginSessionTransactionState + private let name: String + private let log: CopyEventLog + private let sessionState: PluginSessionTransactionState + private let failing: Set + private let failsInserts: Bool + private let failsInsertsAfter: Int? + private let cancelsOnInsert: Bool + private let rowCount: Int + private let transactional: Bool + private let brokenRows: Int + private let throwsCancellationAfter: Int? + private var inserts = 0 - init(sessionState: PluginSessionTransactionState) { - state = sessionState + init( + _ name: String, + log: CopyEventLog, + sessionState: PluginSessionTransactionState = .idle, + failing: Set = [], + failsInserts: Bool = false, + failsInsertsAfter: Int? = nil, + cancelsOnInsert: Bool = false, + rowCount: Int = 1, + transactional: Bool = true, + brokenRows: Int = 0, + throwsCancellationAfter: Int? = nil + ) { + self.name = name + self.log = log + self.sessionState = sessionState + self.failing = failing + self.failsInserts = failsInserts + self.failsInsertsAfter = failsInsertsAfter + self.cancelsOnInsert = cancelsOnInsert + self.rowCount = rowCount + self.transactional = transactional + self.brokenRows = brokenRows + self.throwsCancellationAfter = throwsCancellationAfter } - var executed: [String] { lock.withLock { statements } } - var transactionEvents: [String] { lock.withLock { transactions } } - var capabilities: PluginCapabilities { [] } - var supportsTransactions: Bool { true } - var supportsTransactionalDDL: Bool { true } + var supportsTransactions: Bool { transactional } + var supportsTransactionalDDL: Bool { transactional } - func sessionTransactionState() async -> PluginSessionTransactionState { lock.withLock { state } } + private func record(_ event: String) { + log.record("\(name) \(event)") + } + + func sessionTransactionState() async -> PluginSessionTransactionState { sessionState } + + func openSessionScope(for purpose: PluginSessionPurpose) async throws -> PluginSessionScope? { + record("open \(purpose)") + guard purpose == .relaxedForeignKeys else { return nil } + return PluginSessionScope(purpose: purpose, saved: [], applied: []) + } + + func closeSessionScope(_ scope: PluginSessionScope) async throws { + record(Task.isCancelled ? "close \(scope.purpose) while cancelled" : "close \(scope.purpose)") + } func execute(query: String) async throws -> PluginQueryResult { - lock.withLock { statements.append(query) } - return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + record(query) + if failing.contains(query) { throw StatementFailure() } + if query.hasPrefix("SELECT COUNT(*)") { + return PluginQueryResult( + columns: ["count"], columnTypeNames: [], rows: [[.text("\(brokenRows)")]], rowsAffected: 0, executionTime: 0 + ) + } + return PluginQueryResult( + columns: ["id"], + columnTypeNames: [], + rows: Array(repeating: [.text("1")], count: rowCount), + rowsAffected: rowCount, + executionTime: 0 + ) } - func beginTransaction() async throws { lock.withLock { transactions.append("begin") } } - func commitTransaction() async throws { lock.withLock { transactions.append("commit") } } - func rollbackTransaction() async throws { lock.withLock { transactions.append("rollback") } } + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + record("insert") + if cancelsOnInsert { + withUnsafeCurrentTask { task in + guard let task else { return } + task.cancel() + } + } + inserts += 1 + if inserts > throwsCancellationAfter ?? .max { throw CancellationError() } + if failsInserts || inserts > failsInsertsAfter ?? .max { throw StatementFailure() } + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 1, executionTime: 0) + } + + func beginTransaction() async throws { record("begin") } + func commitTransaction() async throws { record("commit") } + func rollbackTransaction() async throws { record("rollback") } +} + +@MainActor +private final class StubMetadataProvider: ScopedMetadataProviding { + private let drivers: [UUID: DatabaseDriver] + private let leases: CopyEventLog? + + init(leases: CopyEventLog? = nil, drivers: [UUID: DatabaseDriver]) { + self.drivers = drivers + self.leases = leases + } + + func withMetadataDriver( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + guard let driver = drivers[scope.connectionId] else { + throw ObjectCopyError.refused("No driver for \(scope.connectionId)") + } + leases?.record("lease \(scope.database)") + return try await body(driver) + } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { nil } } @MainActor struct ObjectCopyTransactionTests { - private func endpoint(_ database: String) -> DatabaseEndpoint { + private let sourceScope = DatabaseScope(connectionId: UUID(), database: "app", schema: nil) + private let targetScope = DatabaseScope(connectionId: UUID(), database: "staging", schema: nil) + + // Different engines, so the copier opens no scope of its own and the log holds only the runner's. + private let sourceType = DatabaseType.postgresql + private let targetType = DatabaseType.mysql + + private func endpoint(_ scope: DatabaseScope, _ type: DatabaseType) -> DatabaseEndpoint { DatabaseEndpoint( - scope: DatabaseScope(connectionId: UUID(), database: database, schema: nil), - connectionName: "server", - databaseType: .duckdb, + scope: scope, + connectionName: scope.database, + databaseType: type, safeModeLevel: .silent, color: .blue ) } + private func request( + _ selections: [ObjectCopySelection], + content: ObjectCopyContent, + errorHandling: ImportErrorHandling = .stopAndRollback + ) -> ObjectCopyRequest { + ObjectCopyRequest( + source: endpoint(sourceScope, sourceType), + destination: .existing(endpoint(targetScope, targetType)), + objects: selections, + content: content, + existingPolicy: .replace, + errorHandling: errorHandling, + wrapEachTableInTransaction: true + ) + } + + private func dataStep( + _ table: String, clears: Bool = false, keyChecks: [ObjectCopyKeyCheck] = [], rollsBack: Bool = true + ) -> ObjectCopyTableStep { + ObjectCopyTableStep( + selection: ObjectCopySelection(kind: .table, name: table, schema: nil), + dropStatements: [], + sequenceStatements: [], + createStatements: [], + truncateStatements: clears + ? [SyncStatement(sql: "DELETE FROM \(table)", objectName: table, summary: "clear")] + : [], + columns: ["id"], + primaryKeyColumns: ["id"], + sourceQuery: "SELECT id FROM \(table)", + targetTable: table, + targetSchema: nil, + estimatedRows: nil, + copiesData: true, + copiesIdentityColumn: false, + note: nil, + keyChecks: keyChecks, + rollsBack: rollsBack + ) + } + + private func dataPlan( + _ steps: [ObjectCopyTableStep], + errorHandling: ImportErrorHandling = .stopAndRollback, + keysInside: Bool = true + ) -> ObjectCopyPlan { + ObjectCopyPlan( + request: request(steps.map(\.selection), content: .data, errorHandling: errorHandling), + createsDatabase: false, + tableSteps: steps, + definitionSteps: [], + keysInside: ObjectCopyKeysInside(created: keysInside, rows: keysInside) + ) + } + /// A replacement: the drop and the create are one unit, which is what makes the structure phase /// want a transaction of its own. - private func replacementPlan() -> ObjectCopyPlan { + private func replacementPlan(errorHandling: ImportErrorHandling = .stopAndRollback) -> ObjectCopyPlan { let selection = ObjectCopySelection(kind: .table, name: "orders", schema: nil) let step = ObjectCopyTableStep( selection: selection, @@ -88,7 +236,7 @@ struct ObjectCopyTransactionTests { truncateStatements: [], columns: [], primaryKeyColumns: ["id"], - sourceQuery: "SELECT \"id\" FROM \"orders\"", + sourceQuery: "SELECT id FROM orders", targetTable: "orders", targetSchema: nil, estimatedRows: nil, @@ -96,50 +244,58 @@ struct ObjectCopyTransactionTests { copiesIdentityColumn: false, note: nil ) - let request = ObjectCopyRequest( - source: endpoint("app"), - destination: .existing(endpoint("staging")), - objects: [selection], - content: .structure, - existingPolicy: .replace, - errorHandling: .stopAndRollback, - wrapEachTableInTransaction: true - ) return ObjectCopyPlan( - request: request, + request: request([selection], content: .structure, errorHandling: errorHandling), createsDatabase: false, tableSteps: [step], - definitionSteps: [], - schemaStatements: [] + definitionSteps: [] ) } - private func run(sessionState: PluginSessionTransactionState) async throws -> (ObjectCopyRunResult, RecordingCopyDriver) { - let plugin = RecordingCopyDriver(sessionState: sessionState) - let connection = TestFixtures.makeConnection(type: .duckdb) - let adapter = PluginDriverAdapter(connection: connection, pluginDriver: plugin) - let runner = ObjectCopyRunner(manager: StubMetadataProvider(driver: adapter), gate: AlwaysAllowGate()) - let result = try await runner.run(replacementPlan(), progress: ObjectCopyProgress(progress: Progress())) - return (result, plugin) + private func run( + _ plan: ObjectCopyPlan, + source: RecordingCopyDriver, + target: RecordingCopyDriver, + leases: CopyEventLog? = nil + ) async throws -> ObjectCopyRunResult { + let provider = StubMetadataProvider(leases: leases, drivers: [ + sourceScope.connectionId: PluginDriverAdapter( + connection: TestFixtures.makeConnection(type: sourceType), pluginDriver: source + ), + targetScope.connectionId: PluginDriverAdapter( + connection: TestFixtures.makeConnection(type: targetType), pluginDriver: target + ), + ]) + let runner = ObjectCopyRunner(manager: provider, gate: AlwaysAllowGate()) + return try await runner.run(plan, progress: ObjectCopyProgress(progress: Progress())) } + // MARK: - Structure + @Test("An idle session lets the copy open its own transaction") func idleSessionKeepsTheCopysOwnTransaction() async throws { - let (result, driver) = try await run(sessionState: .idle) + let log = CopyEventLog() + + let result = try await run( + replacementPlan(), source: RecordingCopyDriver("source", log: log), target: RecordingCopyDriver("target", log: log) + ) - #expect(driver.transactionEvents == ["begin", "commit"]) - #expect(driver.executed == ["DROP TABLE orders;", "CREATE TABLE orders (id INTEGER);"]) + #expect(log.events == [ + "target begin", "target DROP TABLE orders;", "target CREATE TABLE orders (id INTEGER);", "target commit", + ]) #expect(result.pendingInSessionTransaction == false) } /// The statements still run: they join the transaction the user opened, and only the user can /// end it. Opening one over it is what aborted it on DuckDB. - @Test("A session holding a transaction is joined rather than wrapped") + @Test("A session holding a transaction is joined rather than wrapped, with its keys as they are") func openSessionTransactionIsJoined() async throws { - let (result, driver) = try await run(sessionState: .inTransaction) + let log = CopyEventLog() + let target = RecordingCopyDriver("target", log: log, sessionState: .inTransaction) - #expect(driver.transactionEvents.isEmpty) - #expect(driver.executed == ["DROP TABLE orders;", "CREATE TABLE orders (id INTEGER);"]) + let result = try await run(replacementPlan(), source: RecordingCopyDriver("source", log: log), target: target) + + #expect(log.events == ["target DROP TABLE orders;", "target CREATE TABLE orders (id INTEGER);"]) #expect(result.pendingInSessionTransaction) } @@ -147,9 +303,435 @@ struct ObjectCopyTransactionTests { /// so the copy sends none. @Test("A session holding locks is joined too") func sessionLocksAreJoined() async throws { - let (result, driver) = try await run(sessionState: .holdsSessionLocks) + let log = CopyEventLog() + let target = RecordingCopyDriver("target", log: log, sessionState: .holdsSessionLocks) + + let result = try await run(replacementPlan(), source: RecordingCopyDriver("source", log: log), target: target) + + #expect(!log.events.contains("target begin")) + #expect(result.pendingInSessionTransaction) + } + + /// PostgreSQL commits an aborted transaction as a ROLLBACK, so a run that keeps going past a + /// failure would report tables that were never created. + @Test( + "Only a run that promises a rollback puts the structure in a transaction", + arguments: [ImportErrorHandling.skipAndContinue, .stopAndCommit] + ) + func structureWithoutRollbackHasNoTransaction(errorHandling: ImportErrorHandling) async throws { + let log = CopyEventLog() + + _ = try await run( + replacementPlan(errorHandling: errorHandling), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log) + ) + + #expect(log.events == [ + "target open relaxedForeignKeys", + "target DROP TABLE orders;", + "target CREATE TABLE orders (id INTEGER);", + "target close relaxedForeignKeys", + ]) + } + + /// MySQL refuses a CREATE whose key names a missing table only while the checks are on. + @Test("A new table relaxes the target's checks only when its keys stay inside the copy", arguments: [true, false]) + func creationRelaxesOnlyInsideTheCopy(keysInside: Bool) async throws { + let log = CopyEventLog() + let base = replacementPlan(errorHandling: .stopAndCommit) + let created = base.tableSteps.map { step in + ObjectCopyTableStep( + selection: step.selection, + dropStatements: [], + sequenceStatements: [], + createStatements: step.createStatements, + truncateStatements: [], + columns: [], + primaryKeyColumns: step.primaryKeyColumns, + sourceQuery: step.sourceQuery, + targetTable: step.targetTable, + targetSchema: nil, + estimatedRows: nil, + copiesData: false, + copiesIdentityColumn: false, + note: nil + ) + } + let plan = ObjectCopyPlan( + request: base.request, + createsDatabase: false, + tableSteps: created, + definitionSteps: [], + keysInside: ObjectCopyKeysInside(created: keysInside) + ) + + _ = try await run(plan, source: RecordingCopyDriver("source", log: log), target: RecordingCopyDriver("target", log: log)) + + let create = "target CREATE TABLE orders (id INTEGER);" + #expect(log.events == (keysInside + ? ["target open relaxedForeignKeys", create, "target close relaxedForeignKeys"] + : [create])) + } + + @Test("A schema that cannot be created rolls the structure transaction back before the lease returns") + func failedSchemaRollsBack() async throws { + let log = CopyEventLog() + let createSchema = "CREATE SCHEMA staging" + let base = replacementPlan() + let plan = ObjectCopyPlan( + request: base.request, + createsDatabase: false, + tableSteps: base.tableSteps, + definitionSteps: [], + schemaStatements: [SyncStatement(sql: createSchema, objectName: "staging", summary: "schema")] + ) + + await #expect(throws: (any Error).self) { + _ = try await run( + plan, + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log, failing: [createSchema]) + ) + } + + #expect(log.events == ["target begin", "target \(createSchema)", "target rollback"]) + } + + // MARK: - Rows + + @Test("Rows are written with the target's keys relaxed inside its transaction, and the source left alone") + func rowsAreRelaxedOnTheTargetInsideItsTransaction() async throws { + let log = CopyEventLog() + + let result = try await run( + dataPlan([dataStep("buildings")]), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log) + ) + + #expect(result.rowsCopied == 1) + #expect(log.events == [ + "target begin", + "target open relaxedForeignKeys", + "source SELECT id FROM buildings", + "target insert", + "target close relaxedForeignKeys", + "target commit", + ]) + } + + /// Turning the checks back on checks nothing already written, so a child copied without its + /// parent's rows would keep pointing at nothing. + @Test("Rows whose keys point outside the copy keep the target's checks on") + func keysOutsideTheCopyKeepTheChecks() async throws { + let log = CopyEventLog() + + _ = try await run( + dataPlan([dataStep("buildings")], keysInside: false), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log) + ) + + #expect(log.events == [ + "target begin", "source SELECT id FROM buildings", "target insert", "target commit", + ]) + } + + /// Committed table by table, a cycle's first table kept rows pointing at a second that then failed. + @Test("Stop and roll back writes every table's rows in one transaction") + func rollbackRunsTheRowsInOneTransaction() async throws { + let log = CopyEventLog() + + let result = try await run( + dataPlan([dataStep("estates"), dataStep("buildings")]), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log) + ) + + #expect(result.rowsCopied == 2) + #expect(log.events == [ + "target begin", + "target open relaxedForeignKeys", + "source SELECT id FROM estates", + "target insert", + "source SELECT id FROM buildings", + "target insert", + "target close relaxedForeignKeys", + "target commit", + ]) + } + + /// PostgreSQL commits an aborted transaction as a ROLLBACK, so a table wrapped in one lost + /// every batch written ahead of its failure. + @Test("Stop and keep what ran commits each batch with the checks on, and counts what a failure kept") + func stopAndKeepCommitsEachBatch() async throws { + let log = CopyEventLog() + + let result = try await run( + dataPlan([dataStep("buildings")], errorHandling: .stopAndCommit), + source: RecordingCopyDriver("source", log: log, rowCount: SQLWriteBatchBudget.maximumRows + 1), + target: RecordingCopyDriver("target", log: log, failsInsertsAfter: 1) + ) + + #expect(result.rowsCopied == SQLWriteBatchBudget.maximumRows) + #expect(result.failures.map { $0.outcome.rowsCopied } == [SQLWriteBatchBudget.maximumRows]) + #expect(log.events == ["source SELECT id FROM buildings", "target insert", "target insert"]) + } + + @Test("A rolled back table reports none of the batches it wrote before failing") + func rolledBackFailureCountsNothing() async throws { + let log = CopyEventLog() + + let result = try await run( + dataPlan([dataStep("buildings")]), + source: RecordingCopyDriver("source", log: log, rowCount: SQLWriteBatchBudget.maximumRows + 1), + target: RecordingCopyDriver("target", log: log, failsInsertsAfter: 1) + ) + + #expect(result.rowsCopied == 0) + #expect(result.failures.map { $0.outcome.rowsCopied } == [0]) + #expect(log.events.last == "target rollback") + } + + /// Nothing can be taken back there, so leaving the rows out of the count would send a retry over them. + @Test("A target with no transactions counts what a stopped or failed table wrote") + func untransactionalTargetCountsWhatItKept() async throws { + let log = CopyEventLog() + + let stopped = try await Task { + try await run( + dataPlan([dataStep("buildings")]), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log, cancelsOnInsert: true, transactional: false) + ) + }.value + let failed = try await run( + dataPlan([dataStep("buildings")]), + source: RecordingCopyDriver("source", log: log, rowCount: SQLWriteBatchBudget.maximumRows + 1), + target: RecordingCopyDriver("target", log: log, failsInsertsAfter: 1, transactional: false) + ) + + #expect(stopped.cancelled) + #expect(stopped.rowsCopied == 1) + #expect(failed.rowsCopied == SQLWriteBatchBudget.maximumRows) + #expect(!log.events.contains("target begin")) + #expect(!log.events.contains("target open relaxedForeignKeys")) + } + + @Test("A relaxed copy checks every key it wrote before it commits") + func relaxedCopyChecksKeysBeforeCommit() async throws { + let log = CopyEventLog() + let check = ObjectCopyKeyCheck(constraint: "fk_estate", sql: "SELECT COUNT(*) FROM buildings") + + let result = try await run( + dataPlan([dataStep("buildings", keyChecks: [check])]), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log) + ) + + #expect(result.rowsCopied == 1) + #expect(log.events == [ + "target begin", + "target SELECT COUNT(*) FROM buildings", + "target open relaxedForeignKeys", + "source SELECT id FROM buildings", + "target insert", + "target SELECT COUNT(*) FROM buildings", + "target close relaxedForeignKeys", + "target commit", + ]) + } + + /// A write-only login copies parents first with the checks on, as it did before they could be relaxed. + @Test("A key the account cannot check keeps the target's checks on") + func uncheckableKeyKeepsTheChecks() async throws { + let log = CopyEventLog() + let check = ObjectCopyKeyCheck(constraint: "fk_estate", sql: "SELECT COUNT(*) FROM buildings") + + let result = try await run( + dataPlan([dataStep("buildings", keyChecks: [check])]), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log, failing: [check.sql]) + ) + + #expect(result.rowsCopied == 1) + #expect(log.events == [ + "target begin", + "target SELECT COUNT(*) FROM buildings", + "source SELECT id FROM buildings", + "target insert", + "target commit", + ]) + } + + /// A child read after its parent while the source was changing, or one the source already broke. + @Test("A row a key refuses fails the copy and takes every table back") + func brokenKeyRollsTheCopyBack() async throws { + let log = CopyEventLog() + let check = ObjectCopyKeyCheck(constraint: "fk_estate", sql: "SELECT COUNT(*) FROM buildings") + + let result = try await run( + dataPlan([dataStep("estates"), dataStep("buildings", keyChecks: [check])]), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log, brokenRows: 2) + ) + + #expect(result.rowsCopied == 0) + #expect(result.failures.map { $0.outcome.selection.name } == ["buildings"]) + #expect(log.events.suffix(2) == ["target close relaxedForeignKeys", "target rollback"]) + } + + /// MySQL keeps a MyISAM table's rows through a ROLLBACK. + @Test("A table the target keeps through a rollback still reports its rows") + func tableThatCannotRollBackKeepsItsCount() async throws { + let log = CopyEventLog() + + let result = try await run( + dataPlan([dataStep("audit", rollsBack: false), dataStep("buildings")]), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log, failsInsertsAfter: 1) + ) + + #expect(log.events.last == "target rollback") + #expect(result.rowsCopied == 1) + #expect(result.outcomes.map { "\($0.selection.name) \($0.rowsCopied) \($0.error == nil)" } == [ + "audit 1 true", "buildings 0 false", + ]) + } + + @Test( + "Both paths lease the target before the source", + arguments: [ImportErrorHandling.stopAndRollback, .stopAndCommit] + ) + func leasesTheTargetFirst(errorHandling: ImportErrorHandling) async throws { + let log = CopyEventLog() + let leases = CopyEventLog() + + _ = try await run( + dataPlan([dataStep("buildings")], errorHandling: errorHandling), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log), + leases: leases + ) + + #expect(leases.events == ["lease staging", "lease app"]) + } + + @Test("A driver that throws a cancellation keeps the count of the batches before it") + func thrownCancellationKeepsTheCount() async throws { + let log = CopyEventLog() + + let result = try await run( + dataPlan([dataStep("buildings")], errorHandling: .stopAndCommit), + source: RecordingCopyDriver("source", log: log, rowCount: SQLWriteBatchBudget.maximumRows + 1), + target: RecordingCopyDriver("target", log: log, throwsCancellationAfter: 1) + ) + + #expect(result.cancelled) + #expect(result.rowsCopied == SQLWriteBatchBudget.maximumRows) + } + + /// Relaxed, a DELETE would leave rows in a table outside the copy pointing at nothing. + @Test("Clears run with the target's keys on, before the rows relax them") + func clearsRunWithKeysOn() async throws { + let log = CopyEventLog() + + _ = try await run( + dataPlan([dataStep("buildings", clears: true)]), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log) + ) + + #expect(log.events == [ + "target begin", + "target DELETE FROM buildings", + "target open relaxedForeignKeys", + "source SELECT id FROM buildings", + "target insert", + "target close relaxedForeignKeys", + "target commit", + ]) + } + + @Test("A copy that throws puts the target's keys back before rolling back") + func failedCopyRestoresKeys() async throws { + let log = CopyEventLog() + + let result = try await run( + dataPlan([dataStep("buildings")]), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log, failsInserts: true) + ) + + #expect(result.failedCount == 1) + #expect(log.events == [ + "target begin", + "target open relaxedForeignKeys", + "source SELECT id FROM buildings", + "target insert", + "target close relaxedForeignKeys", + "target rollback", + ]) + } + + @Test("A stopped copy puts the target's keys back outside the cancellation") + func cancelledCopyRestoresKeys() async throws { + let log = CopyEventLog() + let plan = dataPlan([dataStep("buildings")]) + let source = RecordingCopyDriver("source", log: log) + let target = RecordingCopyDriver("target", log: log, cancelsOnInsert: true) + + let result = try await Task { try await run(plan, source: source, target: target) }.value + + #expect(result.cancelled) + #expect(log.events == [ + "target begin", + "target open relaxedForeignKeys", + "source SELECT id FROM buildings", + "target insert", + "target close relaxedForeignKeys", + "target rollback", + ]) + } + + @Test("Rows joining the session's own transaction keep its keys as they are") + func sessionTransactionIsNotRelaxed() async throws { + let log = CopyEventLog() + + let result = try await run( + dataPlan([dataStep("buildings")]), + source: RecordingCopyDriver("source", log: log), + target: RecordingCopyDriver("target", log: log, sessionState: .inTransaction) + ) - #expect(driver.transactionEvents.isEmpty) #expect(result.pendingInSessionTransaction) + #expect(log.events == ["source SELECT id FROM buildings", "target insert"]) + } + + /// The table still holds its own rows, so the source's would be appended beside them. Past that + /// failure the rows keep the target's keys, since a child's parent rows can be missing. + @Test("Under Skip and continue a table whose clear failed gets no rows, and none are relaxed") + func failedClearGetsNoRows() async throws { + let log = CopyEventLog() + let target = RecordingCopyDriver("target", log: log, failing: ["DELETE FROM buildings"]) + + let result = try await run( + dataPlan( + [dataStep("estates", clears: true), dataStep("buildings", clears: true)], + errorHandling: .skipAndContinue + ), + source: RecordingCopyDriver("source", log: log), + target: target + ) + + #expect(result.failures.map { $0.outcome.selection.name } == ["buildings"]) + #expect(result.rowsCopied == 1) + #expect(log.events == [ + "target DELETE FROM buildings", + "target DELETE FROM estates", + "source SELECT id FROM estates", + "target insert", + ]) } } diff --git a/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift b/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift index a07120e9b3..5e78070475 100644 --- a/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift +++ b/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift @@ -93,4 +93,10 @@ struct ImportTypeMapperTests { #expect(ImportTypeMapper.sqlType(for: .integer, databaseType: .clickhouse) == "INTEGER") #expect(ImportTypeMapper.sqlType(for: .boolean, databaseType: .clickhouse) == "BOOLEAN") } + @Test("KingbaseES imports keep 64-bit integers and native JSON") + func kingbaseImportTypes() { + #expect(ImportTypeMapper.sqlType(for: .integer, databaseType: .kingbasees) == "BIGINT") + #expect(ImportTypeMapper.sqlType(for: .json, databaseType: .kingbasees, serverVersion: "12.1") == "JSONB") + #expect(ImportTypeMapper.sqlType(for: .boolean, databaseType: .kingbasees) == "BOOLEAN") + } } diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift new file mode 100644 index 0000000000..88182458fc --- /dev/null +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift @@ -0,0 +1,37 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct PluginMetadataRegistryKingbaseESTests { + @Test("KingbaseES is offered before installation and resolves to its own registry plugin") + func registryIdentity() throws { + let registry = PluginMetadataRegistry.shared + #expect(registry.allRegisteredTypeIds().contains("KingbaseES")) + #expect(registry.pluginTypeId(for: "KingbaseES") == "KingbaseES") + #expect(DatabaseType.kingbasees.isDownloadablePlugin) + let snapshot = try #require(registry.snapshot(forRegisteredTypeId: "KingbaseES")) + let postgreSQL = try #require(registry.snapshot(forRegisteredTypeId: "PostgreSQL")) + #expect(snapshot.displayName == "KingbaseES") + #expect(snapshot.iconName == "cylinder") + #expect(snapshot.connection.tagline != postgreSQL.connection.tagline) + #expect(snapshot.defaultPort == 54_321) + #expect(snapshot.urlSchemes == ["kingbase", "kingbase8"]) + #expect(snapshot.parameterStyle == .dollar) + #expect(snapshot.schema.systemSchemaNames == PluginMetadataRegistry.kingbaseESSystemSchemaNames) + #expect(snapshot.capabilities.supportsSchemaSwitching) + #expect(snapshot.connection.additionalConnectionFields.map(\.id) == ["usePgpass", "connectionOptions"]) + } + + @Test("Both KingbaseES URL schemes keep credentials and route to the dedicated engine", arguments: ["kingbase", "kingbase8"]) + func connectionURLs(scheme: String) throws { + let parsed = try ConnectionURLParser.parse("\(scheme)://system:pass@localhost:55432/test").get() + #expect(parsed.type == .kingbasees) + #expect(parsed.host == "localhost") + #expect(parsed.port == 55_432) + #expect(parsed.database == "test") + #expect(parsed.username == "system") + #expect(parsed.password == "pass") + } +} diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index dcbe133766..e5d6ddaacd 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -7,7 +7,7 @@ import Testing struct PluginMetadataRegistryTypeCountTests { private static let expectedTypeIds: Set = [ "Beancount", "BigQuery", "Cassandra", "ClickHouse", "Cloudflare D1", "Cloudflare R2 SQL", - "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", + "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", "KingbaseES", "libSQL", "MariaDB", "MongoDB", "MySQL", "Oracle", "PGlite", "PostgreSQL", "Redis", "Redshift", "SAP HANA", "ScyllaDB", "Snowflake", "Spanner", "SQL Server", "SQLite", "SurrealDB", "Teradata", "TiDB", "Trino", "Presto", "Turso", "Typesense", "Weaviate", "OceanBase", "Firestore", "Firebase Realtime Database", "Bigtable" diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugControllerTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugControllerTests.swift new file mode 100644 index 0000000000..fcc15bd0a1 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugControllerTests.swift @@ -0,0 +1,820 @@ +// +// RoutineDebugControllerTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +@MainActor +struct RoutineDebugControllerTests { + @Test("The first stop pauses at the reported line and loads the source, stack and variables") + func firstStopPausesAndInspects() async throws { + let session = FakeDebugSession(firstStopLine: 2) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + #expect(controller.state.pausedLocation?.line == 2) + #expect(controller.executionLine == 2) + #expect(controller.displayedUnit == FakeDebugSession.unit) + #expect(controller.displayedSource == FakeDebugSession.source) + #expect(controller.frames.count == 1) + #expect(controller.variables.map { $0.name } == ["total", "note"]) + #expect(session.sourceReadCount == 1) + #expect(session.stackReadCount == 1) + #expect(session.variableReadLevels == [0]) + #expect(controller.statusText == String(format: String(localized: "Paused at line %d in %@"), 2, FakeDebugSession.unit.displayName)) + } + + @Test("Stop while paused ends the target once and closes every driver it took") + func stopWhilePaused() async throws { + let session = FakeDebugSession() + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.stop() + controller.stop() + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + #expect(drivers.openedCount == 2) + #expect(drivers.closedCount == 2) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + #expect(controller.statusText == String(localized: "Stopped")) + #expect(!controller.canStop) + } + + @Test("Stop while a step runs ends the target once, and the step's late answer does not reopen it") + func stopWhileRunning() async throws { + let session = FakeDebugSession(performBlocksUntilStop: true) + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.continueExecution) + try await waitUntil { session.performedCommands == [.continueExecution] } + controller.stop() + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + #expect(drivers.closedCount == drivers.openedCount) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("Stop while the debugger is still starting cancels the start, closes its drivers and ends stopped") + func stopWhileStartingCancelsTheStart() async throws { + let session = FakeDebugSession() + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller( + session: session, + drivers: drivers, + startBehavior: .untilCancelled + ) + controller.start() + try await waitUntil { drivers.openedCount == 2 } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(debugger.startWasCancelled) + #expect(drivers.closedCount == 2) + #expect(session.stopCount == 0) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A cancelled start that cannot confirm its target ended shows why") + func unconfirmedCancelledStartBecomesTheStatus() async throws { + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller( + session: FakeDebugSession(), + drivers: drivers, + startBehavior: .untilCancelledThenThrowing(FakeDebugError(message: "Server process 4242 may still be running.")) + ) + controller.start() + try await waitUntil { drivers.openedCount == 2 } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(debugger.startWasCancelled) + #expect(controller.statusText == "Server process 4242 may still be running.") + } + + @Test("A step that finishes as the stop arrives says the routine finished first") + func finishedAnswerToAStopBecomesTheStatus() async throws { + let session = FakeDebugSession(performBlocksUntilStop: true, stopAnswer: .finished(nil)) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.continueExecution) + try await waitUntil { session.performedCommands == [.continueExecution] } + controller.stop() + try await waitUntil { controller.hasEnded && controller.statusText != String(localized: "Stopped") } + + #expect(controller.statusText == String(localized: "The routine finished before the stop reached it.")) + } + + @Test("A start that ignores its cancellation loses its drivers at the limit, and a late session is still ended") + func startIgnoringCancellationIsCapped() async throws { + let late = AsyncGate() + let session = FakeDebugSession() + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller( + session: session, + drivers: drivers, + startBehavior: .ignoringCancellation(late), + cancelLimit: .milliseconds(50) + ) + controller.start() + try await waitUntil { drivers.openedCount == 2 } + + controller.stop() + try await waitUntil { controller.hasEnded } + #expect(drivers.closedCount == 2) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + #expect(controller.statusText == RoutineDebugController.stopNotConfirmed) + + await late.open() + try await waitUntil { session.stopCount == 1 } + } + + @Test("When the plugin answers a stop with its own words, the status says them instead of Stopped") + func stopAnswerBecomesTheStatus() async throws { + let session = FakeDebugSession( + performBlocksUntilStop: true, + stopAnswer: .failed(message: "The routine is still running on the server.", detail: nil) + ) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.continueExecution) + try await waitUntil { session.performedCommands == [.continueExecution] } + controller.stop() + try await waitUntil { controller.hasEnded && controller.statusText != String(localized: "Stopped") } + + #expect(controller.statusText == "The routine is still running on the server.") + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A stop the plugin cannot confirm shows its words, whether or not a step was waiting", arguments: [false, true]) + func unconfirmedStopBecomesTheStatus(stepWaiting: Bool) async throws { + let session = FakeDebugSession( + performBlocksUntilStop: stepWaiting, + stopAnswer: .failed(message: "Cancelled.", detail: nil), + stopOutcome: .unconfirmed(message: "Server process 4242 may still be running.", detail: "End it with pg_terminate_backend(4242).") + ) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + if stepWaiting { + controller.perform(.continueExecution) + try await waitUntil { session.performedCommands == [.continueExecution] } + } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(controller.statusText == "Server process 4242 may still be running.\nEnd it with pg_terminate_backend(4242).") + } + + @Test("A command is refused while a step runs") + func performRefusedWhileRunning() async throws { + let session = FakeDebugSession(performBlocksUntilStop: true) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.stepOver) + #expect(!controller.canPerform(.stepOver)) + controller.perform(.stepOver) + controller.perform(.continueExecution) + try await waitUntil { !session.performedCommands.isEmpty } + + #expect(session.performedCommands == [.stepOver]) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A command the session does not support is never sent") + func unsupportedCommandIsRefused() async throws { + let session = FakeDebugSession(supportedCommands: [.continueExecution, .stepOver, .stepInto]) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + #expect(!controller.canPerform(.stepOut)) + controller.perform(.stepOut) + + #expect(session.performedCommands.isEmpty) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A stop that lands before the run begins asks nothing and opens nothing") + func stopBeforeTheRunBegins() async throws { + let gate = WaitingGate() + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller(drivers: drivers, gate: gate) + controller.start() + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(gate.askedCount == 0) + #expect(drivers.openedCount == 0) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A step that fails with its target left running stops the session before its drivers close, and says so once") + func failedStepStopsTheSession() async throws { + let words = "Server process 4242 may still be running." + let session = FakeDebugSession( + stopOutcome: .unconfirmed(message: words, detail: nil), + performAnswer: .failed(message: words, detail: nil) + ) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.continueExecution) + try await waitUntil { controller.hasEnded } + + #expect(session.stopCount == 1) + #expect(controller.unconfirmedStop == words) + #expect(controller.statusText == words) + } + + @Test("Stop while the gate is still asking withdraws its prompt and ends the run Stopped, opening nothing") + func stopWhileTheGateAsks() async throws { + let gate = WaitingGate() + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller(drivers: drivers, gate: gate) + var cancelled = false + controller.onCancelled = { cancelled = true } + controller.start() + try await waitUntil { gate.askedCount == 1 } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(gate.sawCancellation) + #expect(!cancelled) + #expect(debugger.startRequests.isEmpty) + #expect(drivers.openedCount == 0) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A gate that cannot be withdrawn is not waited on past the limit, and its late yes starts nothing") + func gateIgnoringCancellationIsCapped() async throws { + let answer = AsyncGate() + let gate = WaitingGate(ignoringCancellationUntil: answer) + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + gate: gate, + cancelLimit: .milliseconds(50) + ) + controller.start() + try await waitUntil { gate.askedCount == 1 } + + controller.stop() + try await waitUntil { controller.hasEnded } + await answer.open() + + #expect(debugger.startRequests.isEmpty) + #expect(drivers.openedCount == 0) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A plan that may write is denied by a read-only gate, and no session opens") + func readOnlyGateDeniesWritingPlan() async throws { + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(mayWrite: true), + gate: AlwaysDenyGate(reason: "Read-Only connection") + ) + + controller.start() + await controller.waitUntilEnded() + + guard case .failed(let message) = controller.state else { + Issue.record("expected failed, got \(controller.state)") + return + } + #expect(message == "Read-Only connection") + #expect(debugger.startRequests.isEmpty) + #expect(drivers.openedCount == 0) + } + + @Test("The run asks the gate as a write when the plan may write, and as a read otherwise") + func gateKindFollowsThePlan() async throws { + let writing = RecordingGate() + let (writer, _) = RoutineDebugFixtures.controller(plan: RoutineDebugFixtures.plan(mayWrite: true), gate: writing) + writer.start() + try await waitUntil { writer.state.pausedLocation != nil } + let reading = RecordingGate() + let (reader, _) = RoutineDebugFixtures.controller(plan: RoutineDebugFixtures.plan(mayWrite: false), gate: reading) + reader.start() + try await waitUntil { reader.state.pausedLocation != nil } + + #expect(writing.requests.map { $0.kind } == [.writeQuery]) + #expect(reading.requests.map { $0.kind } == [.readQuery]) + #expect(writing.requests.first?.caller == .userInterface) + #expect(writing.requests.first?.operationDescription == String(localized: "Debug Routine")) + writer.stop() + reader.stop() + await writer.waitUntilEnded() + await reader.waitUntilEnded() + } + + @Test("A preparation runs on a driver of its own, after the user confirms, through the gate as DDL") + func preparationRunsOnItsOwnDriver() async throws { + let drivers = DebugDriverLog() + let gate = RecordingGate() + let preparation = PluginDebugPreparation( + message: "Compile for debugging?", + informativeText: nil, + actionTitle: "Compile for Debug", + statements: ["ALTER PACKAGE p COMPILE BODY"] + ) + let (controller, _) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: preparation), + gate: gate + ) + + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + #expect(gate.requests.map { $0.kind } == [.readQuery, .schemaMutation]) + #expect(gate.requests.last?.sql == "ALTER PACKAGE p COMPILE BODY") + #expect(drivers.openedCount == 3) + #expect(drivers.closedCount == 1) + controller.stop() + await controller.waitUntilEnded() + #expect(drivers.closedCount == 3) + } + + @Test("Stop while the preparation statement runs cancels it, closes its driver, and the session never starts") + func stopDuringPreparationStatement() async throws { + let drivers = DebugDriverLog(statements: .untilCancelled) + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: Self.compile) + ) + controller.start() + try await waitUntil { drivers.executedCount == 1 } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(drivers.closedCount == 1) + #expect(debugger.startRequests.isEmpty) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A preparation statement that ignores Stop is abandoned at the limit with its connection closed") + func preparationStatementAbandonedAtTheLimit() async throws { + let statement = AsyncGate() + let drivers = DebugDriverLog(statements: .untilOpened(statement)) + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: Self.compile), + cancelLimit: .milliseconds(50) + ) + controller.start() + try await waitUntil { drivers.executedCount == 1 } + + controller.stop() + try await waitUntil { controller.hasEnded } + #expect(drivers.closedCount == 1) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + + await statement.open() + #expect(debugger.startRequests.isEmpty) + #expect(drivers.closedCount == 1) + #expect(drivers.openedCount == 1) + } + + private static let compile = PluginDebugPreparation( + message: "Compile for debugging?", + informativeText: nil, + actionTitle: "Compile for Debug", + statements: ["ALTER PACKAGE p COMPILE BODY"] + ) + + @Test("Declining the preparation starts nothing and asks for the tab to close") + func declinedPreparationCancels() async throws { + let drivers = DebugDriverLog() + let preparation = PluginDebugPreparation(message: "m", informativeText: nil, actionTitle: "Compile", statements: ["X"]) + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: preparation), + confirmPreparation: { _ in false } + ) + var cancelled = false + controller.onCancelled = { cancelled = true } + + controller.start() + await controller.waitUntilEnded() + + #expect(cancelled) + #expect(debugger.startRequests.isEmpty) + #expect(drivers.openedCount == 0) + } + + @Test("A breakpoint set while running waits for the next pause, then shows where the engine placed it") + func breakpointWhileRunningAppliesAtNextPause() async throws { + let step = AsyncGate() + let session = FakeDebugSession(firstStopLine: 2, placesBreakpoint: { $0 + 1 }, stepGate: step) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation?.line == 2 && !controller.variables.isEmpty } + + controller.perform(.stepOver) + await step.arrived(1) + controller.toggleBreakpoint(atLine: 3) + controller.toggleBreakpoint(atLine: 1) + controller.toggleBreakpoint(atLine: 1) + #expect(controller.displayedBreakpointLines == [3]) + #expect(session.placedBreakpointRequests.isEmpty) + await step.open() + try await waitUntil { controller.displayedBreakpointLines == [4] } + + #expect(session.placedBreakpointRequests == [PluginDebugBreakpoint(unitIdentity: FakeDebugSession.unit.identity, line: 3)]) + #expect(session.overlappingCallCount == 0) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A step waits for the pause's reads to finish, so no session call overlaps another") + func callsNeverOverlap() async throws { + let session = FakeDebugSession() + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.stepOver) + try await waitUntil { controller.state.pausedLocation?.line == 3 } + controller.toggleBreakpoint(atLine: 2) + controller.perform(.stepInto) + try await waitUntil { controller.state.pausedLocation?.line == 4 && !controller.variables.isEmpty } + + #expect(session.performedCommands == [.stepOver, .stepInto]) + #expect(session.overlappingCallCount == 0) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A breakpoint removed while its placement is in flight comes off the server, where the engine put it") + func removalDuringPlacementReachesTheServer() async throws { + let placing = AsyncGate() + let session = FakeDebugSession(placesBreakpoint: { $0 + 1 }, placeGate: placing) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.toggleBreakpoint(atLine: 3) + try await waitUntil { session.placedBreakpointRequests.count == 1 } + controller.toggleBreakpoint(atLine: 3) + #expect(controller.displayedBreakpointLines.isEmpty) + await placing.open() + try await waitUntil { !session.removedBreakpointRequests.isEmpty } + + #expect(session.removedBreakpointRequests == [PluginDebugBreakpoint(unitIdentity: FakeDebugSession.unit.identity, line: 4)]) + #expect(controller.displayedBreakpointLines.isEmpty) + #expect(!controller.hasBreakpoints) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("Remove All while a placement is in flight also takes that breakpoint off the server") + func removeAllDuringPlacementReachesTheServer() async throws { + let placing = AsyncGate() + let session = FakeDebugSession(placeGate: placing) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.toggleBreakpoint(atLine: 3) + try await waitUntil { session.placedBreakpointRequests.count == 1 } + controller.removeAllBreakpoints() + await placing.open() + try await waitUntil { !session.removedBreakpointRequests.isEmpty } + + #expect(session.removedBreakpointRequests == [PluginDebugBreakpoint(unitIdentity: FakeDebugSession.unit.identity, line: 3)]) + #expect(!controller.hasBreakpoints) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A breakpoint changed while the pause is still loading reaches the session before Continue does") + func breakpointBeforeContinueWhileInspecting() async throws { + let reads = AsyncGate() + let session = FakeDebugSession(inspectGate: reads) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { session.sourceReadCount == 1 } + + controller.toggleBreakpoint(atLine: 3) + controller.perform(.continueExecution) + await reads.open() + try await waitUntil { session.performedCommands == [.continueExecution] } + + #expect(Array(session.callLog.prefix(2)) == ["set 3", "perform continueExecution"]) + #expect(session.overlappingCallCount == 0) + controller.stop() + try await waitUntil { controller.hasEnded } + } + + @Test("Stopping while the preparation question is up withdraws it and leaves the tab to show Stopped") + func stopWithdrawsThePreparationQuestion() async throws { + let answer = AsyncGate() + let preparation = PluginDebugPreparation(message: "m", informativeText: nil, actionTitle: "Compile", statements: ["X"]) + let drivers = DebugDriverLog() + var asked = false + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: preparation), + confirmPreparation: { _ in + asked = true + await answer.wait() + return false + }, + withdrawPreparation: { Task { await answer.open() } } + ) + var cancelled = false + controller.onCancelled = { cancelled = true } + controller.start() + try await waitUntil { asked } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(!cancelled) + #expect(debugger.startRequests.isEmpty) + #expect(drivers.openedCount == 0) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A breakpoint the engine cannot place drops its marker") + func unplaceableBreakpointDropsItsMarker() async throws { + let session = FakeDebugSession(placesBreakpoint: { _ in nil }) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.toggleBreakpoint(atLine: 1) + try await waitUntil { !session.placedBreakpointRequests.isEmpty } + try await waitUntil { controller.displayedBreakpointLines.isEmpty } + + #expect(!controller.hasBreakpoints) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A frame of the previous stack cannot be chosen while the next pause's stack loads, and a frame shows its own source and variables") + func frameSelectionWaitsForTheCurrentStack() async throws { + let caller = PluginDebugUnit(identity: "caller", displayName: "caller()") + let nextStack = AsyncGate() + let session = FakeDebugSession( + frames: [ + PluginDebugFrame(level: 0, location: nil, arguments: nil), + PluginDebugFrame(level: 1, location: PluginDebugLocation(unit: caller, line: 5), arguments: nil), + ], + laterStackGate: nextStack + ) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.stackIsCurrent && controller.displayedFrameLevel == 0 } + + controller.perform(.stepOver) + try await waitUntil { session.stackReadCount == 2 } + #expect(!controller.stackIsCurrent) + controller.selectFrame(level: 1) + #expect(controller.selectedFrameLevel != 1) + await nextStack.open() + try await waitUntil { controller.stackIsCurrent && session.variableReadLevels.count == 2 } + try await waitUntil { controller.displayedFrameLevel == 0 } + + #expect(session.variableReadLevels == [0, 0]) + #expect(controller.displayedUnit == FakeDebugSession.unit) + + controller.selectFrame(level: 1) + try await waitUntil { controller.displayedFrameLevel == 1 } + #expect(controller.displayedUnit == caller) + #expect(session.variableReadLevels.last == 1) + #expect(controller.executionLine == 5) + controller.stop() + try await waitUntil { controller.hasEnded } + } + + @Test("Stop during a slow read reaches the session at once, and its drivers close no earlier than the deadline") + func stopDuringReadIsNotQueued() async throws { + let reads = AsyncGate() + let blocker = AsyncGate() + let session = FakeDebugSession(stopBlocker: blocker, inspectGate: reads) + let drivers = DebugDriverLog() + let limit: Duration = .seconds(1) + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers, cancelLimit: limit) + controller.start() + try await waitUntil { session.sourceReadCount == 1 } + + let asked = ContinuousClock.now + controller.stop() + try await waitUntil(timeout: .milliseconds(500)) { session.stopCount == 1 } + #expect(session.stopOverlapCount == 1) + #expect(drivers.closedCount == 0) + try await waitUntil { controller.hasEnded } + + #expect(drivers.closedCount == 2) + #expect(drivers.closeInstants.allSatisfy { $0 >= asked.advanced(by: limit) }) + #expect(session.stackReadCount == 0) + #expect(controller.inspectionError == nil) + #expect(controller.statusText == RoutineDebugController.stopNotConfirmed) + await blocker.open() + } + + @Test("Drivers close as soon as the plugin answers the stop, before the deadline") + func driversCloseWhenStopReturns() async throws { + let reads = AsyncGate() + let session = FakeDebugSession(inspectGate: reads) + let drivers = DebugDriverLog() + let limit: Duration = .seconds(5) + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers, cancelLimit: limit) + controller.start() + try await waitUntil { session.sourceReadCount == 1 } + + let asked = ContinuousClock.now + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(session.stopCount == 1) + #expect(drivers.closedCount == 2) + #expect(drivers.closeInstants.allSatisfy { $0 < asked.advanced(by: limit) }) + #expect(controller.statusText == String(localized: "Stopped")) + } + + @Test("A new pause never shows the last pause's variables, even when its own fail to load") + func newPauseClearsVariables() async throws { + let session = FakeDebugSession(failsLaterVariables: true) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.perform(.stepInto) + try await waitUntil { controller.state.pausedLocation?.line == 3 && controller.inspectionError != nil } + + #expect(controller.variables.isEmpty) + #expect(controller.displayedFrameLevel == nil) + controller.stop() + try await waitUntil { controller.hasEnded } + } + + @Test("Toggle Breakpoint and Remove All are offered only when they can act") + func breakpointCommandsValidate() async throws { + let (controller, _) = RoutineDebugFixtures.controller() + #expect(!controller.canToggleBreakpoint) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + #expect(!controller.canToggleBreakpoint) + controller.cursorLine = 3 + #expect(controller.canToggleBreakpoint) + #expect(!controller.canRemoveAllBreakpoints) + controller.toggleBreakpointAtCursor() + #expect(controller.canRemoveAllBreakpoints) + + controller.stop() + try await waitUntil { controller.hasEnded } + #expect(!controller.canToggleBreakpoint) + #expect(!controller.canRemoveAllBreakpoints) + } + + @Test("Toggle Breakpoint acts on the cursor's line") + func toggleAtCursor() async throws { + let (controller, _) = RoutineDebugFixtures.controller() + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.cursorLine = 4 + controller.toggleBreakpointAtCursor() + try await waitUntil { controller.displayedBreakpointLines == [4] } + controller.removeAllBreakpoints() + + #expect(!controller.hasBreakpoints) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("Output relayed as the routine finishes still reaches the tab") + func outputRelayedAtFinishIsKept() async throws { + let relay = AsyncGate() + let session = FakeDebugSession(relayedOutput: (gate: relay, lines: ["NOTICE: total is 1", "done"])) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.perform(.continueExecution) + try await waitUntil { session.performedCommands == [.continueExecution] } + await relay.open() + try await waitUntil { controller.hasEnded } + + #expect(controller.outputLines == ["NOTICE: total is 1", "done"]) + guard case .finished = controller.state else { + Issue.record("expected finished, got \(controller.state)") + return + } + } + + @Test("A target whose stop never returns loses its connections at the limit") + func stuckTargetStopIsCapped() async throws { + let blocker = AsyncGate() + let session = FakeDebugSession(stopBlocker: blocker) + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller( + session: session, + drivers: drivers, + cancelLimit: .milliseconds(50) + ) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(session.stopCount == 1) + #expect(drivers.closedCount == 2) + #expect(controller.statusText == RoutineDebugController.stopNotConfirmed) + await blocker.open() + } + + @Test("Output lines from the session reach the tab") + func outputLinesArrive() async throws { + let session = FakeDebugSession() + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + session.emit("NOTICE: total is 0") + try await waitUntil { controller.outputLines == ["NOTICE: total is 0"] } + controller.stop() + await controller.waitUntilEnded() + } +} + +// Records what was asked and allows it, so a test can pin the request the run makes. +private final class RecordingGate: ExecutionGate, @unchecked Sendable { + private let lock = NSLock() + private var recorded: [OperationRequest] = [] + + var requests: [OperationRequest] { lock.withLock { recorded } } + + func authorize(_ request: OperationRequest) async -> OperationDecision { + lock.withLock { recorded.append(request) } + return await AlwaysAllowGate().authorize(request) + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugFakes.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugFakes.swift new file mode 100644 index 0000000000..d23ddaac3e --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugFakes.swift @@ -0,0 +1,452 @@ +// +// RoutineDebugFakes.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport + +// How a statement sent to a fake driver behaves: at once, until its task is cancelled, or until a gate opens whatever +// happens to its task (a statement already on the server). +internal enum FakeStatementBehavior: Sendable { + case immediate + case untilCancelled + case untilOpened(AsyncGate) +} + +internal final class FakeDebugDriver: PluginDriverStub, @unchecked Sendable { + private let behavior: FakeStatementBehavior + private let onExecute: @Sendable () -> Void + + init(behavior: FakeStatementBehavior = .immediate, onExecute: @escaping @Sendable () -> Void = {}) { + self.behavior = behavior + self.onExecute = onExecute + } + + func execute(query: String) async throws -> PluginQueryResult { + onExecute() + switch behavior { + case .immediate: + break + case .untilCancelled: + // Stands for a statement that runs until the run gives up on it. + try await Task.sleep(for: .seconds(600)) + case .untilOpened(let gate): + await gate.wait() + } + return .empty + } +} + +// Counts what the host does with the drivers a session took, so a test can see every one was closed. +internal final class DebugDriverLog: @unchecked Sendable { + private let lock = NSLock() + private let statements: FakeStatementBehavior + private var opened = 0 + private var closed = 0 + private var executed = 0 + private var closes: [ContinuousClock.Instant] = [] + + init(statements: FakeStatementBehavior = .immediate) { + self.statements = statements + } + + var openedCount: Int { lock.withLock { opened } } + var closedCount: Int { lock.withLock { closed } } + var executedCount: Int { lock.withLock { executed } } + var closeInstants: [ContinuousClock.Instant] { lock.withLock { closes } } + + func source(admits: @escaping @Sendable () async -> Bool = { true }) -> RoutineDebugSessionSource { + RoutineDebugSessionSource(admits: admits) { [self] in + lock.withLock { opened += 1 } + let driver = FakeDebugDriver(behavior: statements) { [self] in + lock.withLock { executed += 1 } + } + return RoutineDebugDriverHandle(driver: driver) { [self] in + lock.withLock { + closed += 1 + closes.append(.now) + } + } + } + } +} + +internal final class FakeDebugSession: PluginDebugSession, @unchecked Sendable { + static let unit = PluginDebugUnit(identity: "16431", displayName: "order_total(integer)") + static let source = "BEGIN\n total := 0;\n total := total + 1;\n RETURN total;\nEND" + + let supportedCommands: Set + let output: AsyncStream + private let outputContinuation: AsyncStream.Continuation + private let lock = NSLock() + private let stopGate = AsyncGate() + private let firstStopLine: Int + private let performBlocksUntilStop: Bool + private let placesBreakpoint: (Int) -> Int? + private let stopBlocker: AsyncGate? + private let stepGate: AsyncGate? + private let placeGate: AsyncGate? + private let stopAnswer: PluginDebugStop + private let stopOutcome: PluginDebugStopOutcome + private let inspectGate: AsyncGate? + private let relayedOutput: (gate: AsyncGate, lines: [String])? + private let frames: [PluginDebugFrame] + private let laterStackGate: AsyncGate? + private let failsLaterVariables: Bool + private let performAnswer: PluginDebugStop? + private var calls: [String] = [] + private var removedBreakpoints: [PluginDebugBreakpoint] = [] + private var stepInFlight = false + private var overlaps = 0 + private var callsInFlight = 0 + private var stopOverlaps = 0 + private var stops = 0 + private var stopped = false + private var performs: [PluginDebugCommand] = [] + private var sourceReads = 0 + private var stackReads = 0 + private var variableReads: [Int] = [] + private var setBreakpoints: [PluginDebugBreakpoint] = [] + private var nextPerformLine: Int + + init( + supportedCommands: Set = [.continueExecution, .stepOver, .stepInto], + firstStopLine: Int = 2, + performBlocksUntilStop: Bool = false, + placesBreakpoint: @escaping (Int) -> Int? = { $0 }, + stopBlocker: AsyncGate? = nil, + stepGate: AsyncGate? = nil, + placeGate: AsyncGate? = nil, + stopAnswer: PluginDebugStop = .finished(nil), + stopOutcome: PluginDebugStopOutcome = .ended, + inspectGate: AsyncGate? = nil, + relayedOutput: (gate: AsyncGate, lines: [String])? = nil, + frames: [PluginDebugFrame] = [PluginDebugFrame(level: 0, location: nil, arguments: nil)], + laterStackGate: AsyncGate? = nil, + failsLaterVariables: Bool = false, + performAnswer: PluginDebugStop? = nil + ) { + self.supportedCommands = supportedCommands + self.firstStopLine = firstStopLine + self.performBlocksUntilStop = performBlocksUntilStop + self.placesBreakpoint = placesBreakpoint + self.stopBlocker = stopBlocker + self.stepGate = stepGate + self.placeGate = placeGate + self.stopAnswer = stopAnswer + self.stopOutcome = stopOutcome + self.inspectGate = inspectGate + self.relayedOutput = relayedOutput + self.frames = frames + self.laterStackGate = laterStackGate + self.failsLaterVariables = failsLaterVariables + self.performAnswer = performAnswer + self.nextPerformLine = firstStopLine + 1 + var continuation: AsyncStream.Continuation? + output = AsyncStream { continuation = $0 } + outputContinuation = continuation! + } + + var stopCount: Int { lock.withLock { stops } } + var performedCommands: [PluginDebugCommand] { lock.withLock { performs } } + var sourceReadCount: Int { lock.withLock { sourceReads } } + var stackReadCount: Int { lock.withLock { stackReads } } + var variableReadLevels: [Int] { lock.withLock { variableReads } } + var placedBreakpointRequests: [PluginDebugBreakpoint] { lock.withLock { setBreakpoints } } + var removedBreakpointRequests: [PluginDebugBreakpoint] { lock.withLock { removedBreakpoints } } + // Steps and breakpoint edits in the order they reached the session. + var callLog: [String] { lock.withLock { calls } } + // Calls other than `stop()` that arrived while a step was still waiting, which the contract forbids. + var overlappingCallCount: Int { lock.withLock { overlaps } } + // Stops that arrived while a read or a breakpoint edit was still out. + var stopOverlapCount: Int { lock.withLock { stopOverlaps } } + + private func beginCall() { + lock.withLock { + if stepInFlight { overlaps += 1 } + callsInFlight += 1 + } + } + + private func endCall() { + lock.withLock { callsInFlight -= 1 } + } + + // As the contract asks of a plugin: a read or an edit that `stop()` overlapped throws. + private func throwIfStopped() throws { + if lock.withLock({ stopped }) { + throw RoutineDebugError.sessionEnded + } + } + + func emit(_ line: String) { + outputContinuation.yield(line) + } + + func firstStop() async throws -> PluginDebugStop { + .paused(PluginDebugLocation(unit: Self.unit, line: firstStopLine)) + } + + func perform(_ command: PluginDebugCommand) async throws -> PluginDebugStop { + let line = lock.withLock { () -> Int in + if stepInFlight { overlaps += 1 } + stepInFlight = true + performs.append(command) + calls.append("perform \(command.rawValue)") + defer { nextPerformLine += 1 } + return nextPerformLine + } + defer { lock.withLock { stepInFlight = false } } + await stepGate?.wait() + if let performAnswer { return performAnswer } + if performBlocksUntilStop { + await stopGate.wait() + return stopAnswer + } + if let relayedOutput { + // The routine completes, and its output arrives a moment later from another task, as Oracle relays DBMS_OUTPUT. + let continuation = outputContinuation + Task { + await relayedOutput.gate.wait() + for line in relayedOutput.lines { + continuation.yield(line) + } + continuation.finish() + } + return .finished(nil) + } + return .paused(PluginDebugLocation(unit: Self.unit, line: line)) + } + + func source(of unit: PluginDebugUnit) async throws -> String { + beginCall() + defer { endCall() } + lock.withLock { sourceReads += 1 } + await inspectGate?.wait() + try throwIfStopped() + return Self.source + } + + // The first pause's stack comes at once; a later one waits for `laterStackGate`. + func stack() async throws -> [PluginDebugFrame] { + beginCall() + defer { endCall() } + let read = lock.withLock { () -> Int in + stackReads += 1 + return stackReads + } + if read > 1 { + await laterStackGate?.wait() + } + try throwIfStopped() + return frames + } + + func variables(inFrame level: Int) async throws -> [PluginDebugVariable] { + beginCall() + defer { endCall() } + let read = lock.withLock { () -> Int in + variableReads.append(level) + return variableReads.count + } + if failsLaterVariables, read > 1 { + throw RoutineDebugError.unsupported + } + return [ + PluginDebugVariable(name: "total", kind: .local, typeName: "integer", value: "0", isConstant: false), + PluginDebugVariable(name: "note", kind: .local, typeName: "text", value: nil, isConstant: false), + ] + } + + func setBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws -> PluginDebugBreakpoint? { + beginCall() + defer { endCall() } + lock.withLock { + setBreakpoints.append(breakpoint) + calls.append("set \(breakpoint.line)") + } + await placeGate?.wait() + try throwIfStopped() + return placesBreakpoint(breakpoint.line).map { + PluginDebugBreakpoint(unitIdentity: breakpoint.unitIdentity, line: $0) + } + } + + func removeBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws { + beginCall() + defer { endCall() } + lock.withLock { + removedBreakpoints.append(breakpoint) + calls.append("remove \(breakpoint.line)") + } + } + + // Ends every call still running at once, then confirms the stop when `stopBlocker` lets it. + func stop() async -> PluginDebugStopOutcome { + lock.withLock { + stops += 1 + stopped = true + if callsInFlight > 0 { stopOverlaps += 1 } + } + await stopGate.open() + await inspectGate?.open() + await placeGate?.open() + await laterStackGate?.open() + await stopBlocker?.wait() + outputContinuation.finish() + return stopOutcome + } +} + +internal struct FakeDebugError: LocalizedError, Sendable { + let message: String + var errorDescription: String? { message } +} + +internal enum FakeStartBehavior: Sendable { + case immediate + // The target's call is under way and waits for a breakpoint that never comes, until the start is cancelled. + case untilCancelled + // A plugin that never looks at cancellation and returns its session once the gate opens. + case ignoringCancellation(AsyncGate) + // Cancelled, the plugin cannot confirm its target ended and throws why. + case untilCancelledThenThrowing(any Error & Sendable) +} + +internal final class FakeRoutineDebugger: PluginRoutineDebugging, @unchecked Sendable { + let session: FakeDebugSession + private let driversPerSession: Int + private let startBehavior: FakeStartBehavior + private let lock = NSLock() + private var requests: [PluginDebugRequest] = [] + private var cancelled = false + + init(session: FakeDebugSession, driversPerSession: Int = 2, startBehavior: FakeStartBehavior = .immediate) { + self.session = session + self.driversPerSession = driversPerSession + self.startBehavior = startBehavior + } + + var startRequests: [PluginDebugRequest] { lock.withLock { requests } } + var startWasCancelled: Bool { lock.withLock { cancelled } } + + var canDebugRoutines: Bool { true } + + func debugTarget(for routine: PluginRoutineInfo) -> PluginDebugTarget? { + PluginDebugTarget(identity: routine.identity ?? routine.name, name: routine.name, schema: routine.schema, kindDisplayName: "Function") + } + + func debugTargets(schema: String?) async throws -> [PluginDebugTarget] { [] } + + func prepareDebug(_ target: PluginDebugTarget) async throws -> PluginDebugPlan { + RoutineDebugFixtures.plan() + } + + func startDebug(_ request: PluginDebugRequest, sessions: any PluginDebugSessionSource) async throws -> any PluginDebugSession { + lock.withLock { requests.append(request) } + for _ in 0.. PluginDebugPlan { + PluginDebugPlan(target: target, entryPoints: [entryPoint], mayWrite: mayWrite, preparation: preparation) + } + + @MainActor + static func controller( + session: FakeDebugSession = FakeDebugSession(), + drivers: DebugDriverLog = DebugDriverLog(), + plan: PluginDebugPlan = RoutineDebugFixtures.plan(), + gate: any ExecutionGate = AlwaysAllowGate(), + startBehavior: FakeStartBehavior = .immediate, + cancelLimit: Duration = .seconds(5), + admits: @escaping @Sendable () async -> Bool = { true }, + confirmPreparation: @escaping @MainActor (PluginDebugPreparation) async -> Bool = { _ in true }, + withdrawPreparation: @escaping @MainActor () -> Void = {} + ) -> (RoutineDebugController, FakeRoutineDebugger) { + let debugger = FakeRoutineDebugger(session: session, startBehavior: startBehavior) + let controller = RoutineDebugController( + plan: plan, + choice: RoutineDebugStartChoice( + entryPoint: entryPoint, + arguments: [PluginDebugArgument(parameterName: "id", value: .text("7"))], + optionValues: [:] + ), + connectionId: UUID(), + databaseType: .postgresql, + debugger: debugger, + sessionSource: drivers.source(admits: admits), + gate: gate, + cancelLimit: cancelLimit, + confirmPreparation: confirmPreparation, + withdrawPreparation: withdrawPreparation + ) + return (controller, debugger) + } +} + +// A gate still waiting on the person: it answers as Cancel once its task is cancelled, or, ignoring cancellation, as +// allowed when `answer` opens. +internal final class WaitingGate: ExecutionGate, @unchecked Sendable { + private let lock = NSLock() + private let answer: AsyncGate? + private var asked = 0 + private var cancelled = false + + init(ignoringCancellationUntil answer: AsyncGate? = nil) { + self.answer = answer + } + + var askedCount: Int { lock.withLock { asked } } + var sawCancellation: Bool { lock.withLock { cancelled } } + + func authorize(_ request: OperationRequest) async -> OperationDecision { + lock.withLock { asked += 1 } + if let answer { + await answer.wait() + return await AlwaysAllowGate().authorize(request) + } + do { + try await Task.sleep(for: .seconds(600)) + } catch { + lock.withLock { cancelled = true } + return .denied(reason: "Operation cancelled by user", cause: .cancelledByUser) + } + return .denied(reason: "No answer") + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugOptionStoreTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugOptionStoreTests.swift new file mode 100644 index 0000000000..f167756bef --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugOptionStoreTests.swift @@ -0,0 +1,64 @@ +// +// RoutineDebugOptionStoreTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct RoutineDebugOptionStoreTests { + private let pool = EphemeralDefaultsPool() + + private static func isolatedQueryHistory() -> QueryHistoryManager { + QueryHistoryManager( + storage: QueryHistoryStorage( + databaseURL: FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("debug_options_\(UUID().uuidString).db"), + removeDatabaseOnDeinit: true + ), + isCapturePaused: { false } + ) + } + + @Test("Deleting a connection removes its start options and keeps another connection's") + func connectionDeleteRemovesItsOptions() throws { + let defaults = try pool.make().defaults + let store = RoutineDebugOptionStore(defaults: { defaults }) + let deleted = UUID() + let kept = UUID() + store.save(["host": "10.0.0.5", "port": "4000"], connectionId: deleted) + store.save(["host": "10.0.0.9"], connectionId: kept) + + ConnectionLocalState.purge( + connectionIds: [deleted], + origin: .remote, + tableScopedStores: [], + queryHistory: Self.isolatedQueryHistory(), + defaults: defaults + ) + + #expect(store.value(for: "host", connectionId: deleted) == nil) + #expect(store.value(for: "port", connectionId: deleted) == nil) + #expect(store.value(for: "host", connectionId: kept) == "10.0.0.9") + } + + @Test("Reset All Settings removes every connection's start options and nothing else") + func resetRemovesAllOptions() throws { + let defaults = try pool.make().defaults + let store = RoutineDebugOptionStore(defaults: { defaults }) + let first = UUID() + let second = UUID() + store.save(["host": "10.0.0.5"], connectionId: first) + store.save(["port": "4000"], connectionId: second) + defaults.set("kept", forKey: "RoutineDebugOptionsUnrelated") + + AppSettingsStorage(userDefaults: defaults).resetToDefaults() + + #expect(store.value(for: "host", connectionId: first) == nil) + #expect(store.value(for: "port", connectionId: second) == nil) + #expect(defaults.string(forKey: "RoutineDebugOptionsUnrelated") == "kept") + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionSourceTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionSourceTests.swift new file mode 100644 index 0000000000..c6fae5f98a --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionSourceTests.swift @@ -0,0 +1,86 @@ +// +// RoutineDebugSessionSourceTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +private final class StatementLog: @unchecked Sendable { + private let lock = NSLock() + private var statements: [String] = [] + private var closes = 0 + + var executed: [String] { lock.withLock { statements } } + var closedCount: Int { lock.withLock { closes } } + + func record(_ statement: String) { lock.withLock { statements.append(statement) } } + func recordClose() { lock.withLock { closes += 1 } } +} + +private final class StatementDriver: PluginDriverStub, @unchecked Sendable { + let log: StatementLog + + init(log: StatementLog) { + self.log = log + } + + func execute(query: String) async throws -> PluginQueryResult { + log.record(query) + return .empty + } +} + +@MainActor +struct RoutineDebugSessionSourceTests { + @Test("A preparation's statements run in order on a driver of their own, which is closed after") + func preparationUsesAndClosesItsOwnDriver() async throws { + let log = StatementLog() + let source = RoutineDebugSessionSource { + RoutineDebugDriverHandle(driver: StatementDriver(log: log)) { log.recordClose() } + } + + try await source.run(["ALTER PACKAGE p COMPILE", "ALTER PACKAGE p COMPILE BODY"]) + await source.closeAll() + + #expect(log.executed == ["ALTER PACKAGE p COMPILE", "ALTER PACKAGE p COMPILE BODY"]) + #expect(log.closedCount == 1) + } + + @Test("A driver still dialing when the session ends is closed and refused, never handed to the plugin") + func lateDriverIsClosed() async throws { + let log = StatementLog() + let dialing = AsyncGate() + let source = RoutineDebugSessionSource { + await dialing.wait() + return RoutineDebugDriverHandle(driver: StatementDriver(log: log)) { log.recordClose() } + } + + let opening = Task { try await source.openSession() } + await dialing.arrived(1) + await source.closeAll() + await dialing.open() + + await #expect(throws: RoutineDebugError.sessionEnded) { _ = try await opening.value } + #expect(log.closedCount == 1) + await #expect(throws: RoutineDebugError.sessionEnded) { _ = try await source.openSession() } + } + + @Test("Ending the session closes every driver it opened, once") + func closeAllClosesEachDriverOnce() async throws { + let log = StatementLog() + let source = RoutineDebugSessionSource { + RoutineDebugDriverHandle(driver: StatementDriver(log: log)) { log.recordClose() } + } + + _ = try await source.openSession() + _ = try await source.openSession() + await source.closeAll() + await source.closeAll() + + #expect(log.closedCount == 2) + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionsTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionsTests.swift new file mode 100644 index 0000000000..ab03edd386 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionsTests.swift @@ -0,0 +1,304 @@ +// +// RoutineDebugSessionsTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +@MainActor +private final class UnconfirmedStopRecorder { + private(set) var texts: [String?] = [] + private(set) var windows: [NSWindow?] = [] + + func record(_ controller: RoutineDebugController, _ window: NSWindow?) { + texts.append(controller.unconfirmedStop) + windows.append(window) + } +} + +@MainActor +struct RoutineDebugSessionsTests { + @Test("Stopping a connection's runs closes their drivers at the caller's deadline when a target cannot be reached") + func stopAllSharesItsDeadline() async throws { + let blocker = AsyncGate() + let session = FakeDebugSession(stopBlocker: blocker) + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers, cancelLimit: .seconds(30)) + let registry = RoutineDebugSessions(isModuleEnabled: { true }) + let connectionId = UUID() + registry.register(controller, tabId: UUID(), connectionId: connectionId) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + let timeout: Duration = .milliseconds(200) + let asked = ContinuousClock.now + await registry.stopAll(connectionId: connectionId, timeout: timeout) + #expect(ContinuousClock.now >= asked.advanced(by: timeout)) + try await waitUntil(timeout: .seconds(2)) { controller.hasEnded } + + #expect(session.stopCount == 1) + #expect(drivers.closedCount == 2) + #expect(drivers.closeInstants.allSatisfy { $0 >= asked.advanced(by: timeout) }) + #expect(controller.statusText == RoutineDebugController.stopNotConfirmed) + await blocker.open() + } + + @Test("Closing the tab while the debugger is still starting cancels the start and closes its drivers") + func endSessionWhileStarting() async throws { + let session = FakeDebugSession() + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller( + session: session, + drivers: drivers, + startBehavior: .untilCancelled + ) + let registry = RoutineDebugSessions(isModuleEnabled: { true }) + let tabId = UUID() + registry.register(controller, tabId: tabId, connectionId: UUID()) + controller.start() + try await waitUntil { drivers.openedCount == 2 } + + registry.endSession(forTab: tabId) + try await waitUntil { controller.hasEnded } + + #expect(debugger.startWasCancelled) + #expect(drivers.closedCount == 2) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + try await waitUntil { !registry.hasActiveSessions } + } + + @Test("A start racing a connection's stop is refused and opens no driver; after the reconnect a fresh start works") + func admissionFence() async throws { + let registry = RoutineDebugSessions(isModuleEnabled: { true }) + let connectionId = UUID() + let staleGeneration = registry.admissionGeneration(for: connectionId) + let staleDrivers = DebugDriverLog() + let (stale, _) = RoutineDebugFixtures.controller(drivers: staleDrivers, admits: { + await registry.admits(connectionId, generation: staleGeneration) + }) + + await registry.stopAll(connectionId: connectionId) + #expect(!registry.register(stale, tabId: UUID(), connectionId: connectionId, generation: staleGeneration)) + stale.start() + try await waitUntil { stale.hasEnded } + #expect(staleDrivers.openedCount == 0) + guard case .failed(let message) = stale.state else { + Issue.record("expected failed, got \(stale.state)") + return + } + #expect(message == RoutineDebugError.connectionChanged.localizedDescription) + + registry.reopenAdmission(for: connectionId) + #expect(!registry.admits(connectionId, generation: staleGeneration)) + let freshGeneration = registry.admissionGeneration(for: connectionId) + let freshDrivers = DebugDriverLog() + let (fresh, _) = RoutineDebugFixtures.controller(drivers: freshDrivers, admits: { + await registry.admits(connectionId, generation: freshGeneration) + }) + #expect(registry.register(fresh, tabId: UUID(), connectionId: connectionId, generation: freshGeneration)) + fresh.start() + try await waitUntil { fresh.state.pausedLocation != nil } + #expect(freshDrivers.openedCount == 2) + fresh.stop() + try await waitUntil { fresh.hasEnded } + } + + @Test("A run counts as active until its tab's stop has finished") + func activeUntilStopped() async throws { + let registry = RoutineDebugSessions(isModuleEnabled: { true }) + let (controller, _) = RoutineDebugFixtures.controller() + let tabId = UUID() + #expect(!registry.hasActiveSessions) + + registry.register(controller, tabId: tabId, connectionId: UUID()) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + #expect(registry.hasActiveSessions) + + registry.endSession(forTab: tabId) + #expect(registry.controller(forTab: tabId) == nil) + try await waitUntil { !registry.hasActiveSessions } + #expect(controller.hasEnded) + } + + @Test("Turning the module off during a preparation statement closes its driver, and the session never starts") + func moduleOffDuringPreparationStatement() async throws { + let center = NotificationCenter() + let registry = RoutineDebugSessions(isModuleEnabled: { false }, notificationCenter: center) + let drivers = DebugDriverLog(statements: .untilCancelled) + let preparation = PluginDebugPreparation( + message: "Compile for debugging?", + informativeText: nil, + actionTitle: "Compile for Debug", + statements: ["ALTER PACKAGE p COMPILE BODY"] + ) + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: preparation) + ) + registry.register(controller, tabId: UUID(), connectionId: UUID()) + controller.start() + try await waitUntil { drivers.executedCount == 1 } + + center.post(name: .appModulesDidChange, object: AppModule.routineDebugger) + try await waitUntil { controller.hasEnded } + + #expect(drivers.closedCount == 1) + #expect(debugger.startRequests.isEmpty) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + try await waitUntil { !registry.hasActiveSessions } + } + + @Test("Turning the module off while the gate is still asking withdraws it, ends the run and drops the observer") + func moduleOffWhileTheGateAsks() async throws { + let center = NotificationCenter() + let registry = RoutineDebugSessions(isModuleEnabled: { false }, notificationCenter: center) + let gate = WaitingGate() + let (controller, debugger) = RoutineDebugFixtures.controller(gate: gate) + registry.register(controller, tabId: UUID(), connectionId: UUID()) + controller.start() + try await waitUntil { gate.askedCount == 1 } + + center.post(name: .appModulesDidChange, object: AppModule.routineDebugger) + try await waitUntil { controller.hasEnded } + + #expect(gate.sawCancellation) + #expect(debugger.startRequests.isEmpty) + try await waitUntil { !registry.hasActiveSessions } + } + + @Test("Turning the module off ends every run the registry holds") + func moduleOffEndsRuns() async throws { + let center = NotificationCenter() + let registry = RoutineDebugSessions(isModuleEnabled: { false }, notificationCenter: center) + let session = FakeDebugSession() + let tabId = UUID() + let (controller, _) = RoutineDebugFixtures.controller(session: session) + registry.register(controller, tabId: tabId, connectionId: UUID()) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + center.post(name: .appModulesDidChange, object: AppModule.routineDebugger) + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + #expect(registry.controller(forTab: tabId) == nil) + try await waitUntil { !registry.hasActiveSessions } + } + + @Test("A stop the plugin cannot confirm after its tab closed is reported on the window that held the tab") + func unconfirmedStopOfClosedTabIsReported() async throws { + let recorder = UnconfirmedStopRecorder() + let registry = RoutineDebugSessions(isModuleEnabled: { true }, reportUnconfirmedStop: { recorder.record($0, $1) }) + let session = FakeDebugSession(stopOutcome: .unconfirmed( + message: "Server process 4242 may still be running.", + detail: "End it with pg_terminate_backend(4242)." + )) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + let tabId = UUID() + registry.register(controller, tabId: tabId, connectionId: UUID()) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + let window = NSWindow(contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + window.isReleasedWhenClosed = false + + registry.endSession(forTab: tabId, window: window) + try await waitUntil { !registry.hasActiveSessions } + + #expect(recorder.texts == ["Server process 4242 may still be running.\nEnd it with pg_terminate_backend(4242)."]) + #expect(recorder.windows.count == 1 && recorder.windows[0] === window) + } + + @Test("A confirmed stop after the tab closed reports nothing") + func confirmedStopOfClosedTabIsNotReported() async throws { + let recorder = UnconfirmedStopRecorder() + let registry = RoutineDebugSessions(isModuleEnabled: { true }, reportUnconfirmedStop: { recorder.record($0, $1) }) + let session = FakeDebugSession() + let (controller, _) = RoutineDebugFixtures.controller(session: session) + let tabId = UUID() + registry.register(controller, tabId: tabId, connectionId: UUID()) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + registry.endSession(forTab: tabId) + try await waitUntil { !registry.hasActiveSessions } + + #expect(session.stopCount == 1) + #expect(recorder.texts.isEmpty) + } + + @Test("An unconfirmed stop that ended while its tab still showed it is not reported again when the tab closes") + func unconfirmedStopShownInItsTabIsNotReported() async throws { + let recorder = UnconfirmedStopRecorder() + let registry = RoutineDebugSessions(isModuleEnabled: { true }, reportUnconfirmedStop: { recorder.record($0, $1) }) + let session = FakeDebugSession(stopOutcome: .unconfirmed(message: "Server process 4242 may still be running.", detail: nil)) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + let tabId = UUID() + let connectionId = UUID() + registry.register(controller, tabId: tabId, connectionId: connectionId) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + await registry.stopAll(connectionId: connectionId) + registry.reopenAdmission(for: connectionId) + try await waitUntil { controller.hasEnded } + registry.endSession(forTab: tabId) + try await waitUntil { !registry.hasActiveSessions } + + #expect(controller.unconfirmedStop != nil) + #expect(recorder.texts.isEmpty) + } + + @Test("An unconfirmed stop a disconnect caused is reported once its tab goes with the connection") + func unconfirmedStopOfDisconnectIsReportedWhenItsTabCloses() async throws { + let recorder = UnconfirmedStopRecorder() + let registry = RoutineDebugSessions(isModuleEnabled: { true }, reportUnconfirmedStop: { recorder.record($0, $1) }) + let session = FakeDebugSession(stopOutcome: .unconfirmed(message: "Server process 4242 may still be running.", detail: nil)) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + let tabId = UUID() + let connectionId = UUID() + registry.register(controller, tabId: tabId, connectionId: connectionId) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + await registry.stopAll(connectionId: connectionId, tabsCloseAfter: true) + registry.reopenAdmission(for: connectionId) + try await waitUntil { controller.hasEnded } + #expect(recorder.texts.isEmpty) + registry.endSession(forTab: tabId) + try await waitUntil { recorder.texts.count == 1 } + + #expect(recorder.texts == ["Server process 4242 may still be running."]) + } + + @Test("Two overlapping transport replacements keep the fence closed until the second one ends") + func nestedFencesReopenOnTheLast() async { + let registry = RoutineDebugSessions(isModuleEnabled: { true }) + let connectionId = UUID() + + await registry.stopAll(connectionId: connectionId) + await registry.stopAll(connectionId: connectionId) + let generation = registry.admissionGeneration(for: connectionId) + + registry.reopenAdmission(for: connectionId) + #expect(!registry.admits(connectionId, generation: generation)) + + registry.reopenAdmission(for: connectionId) + #expect(registry.admits(connectionId, generation: generation)) + + registry.reopenAdmission(for: connectionId) + await registry.stopAll(connectionId: connectionId) + #expect(!registry.admits(connectionId, generation: registry.admissionGeneration(for: connectionId))) + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSheetTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSheetTests.swift new file mode 100644 index 0000000000..5068b87827 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSheetTests.swift @@ -0,0 +1,58 @@ +// +// RoutineDebugSheetTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProTestSupport +import Testing + +@MainActor +struct RoutineDebugSheetTests { + private static func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: true + ) + window.isReleasedWhenClosed = false + return window + } + + private static func present(on window: NSWindow, setup: RoutineDebugSetup) -> Task { + Task { + await RoutineDebugSheet.present(on: window, setup: setup) { (_: @escaping @MainActor (Int?) -> Void) in + Text(verbatim: "Sheet") + } + } + } + + @Test("Closing the window under the sheet answers it as cancelled") + func parentCloseAnswersTheSheet() async throws { + let window = Self.makeWindow() + let setup = RoutineDebugSetup() + let answer = Self.present(on: window, setup: setup) + try await waitUntil { setup.dismissPresentation != nil } + + window.close() + + #expect(await answer.value == nil) + #expect(setup.dismissPresentation == nil) + } + + @Test("A sheet already answered ignores its window closing afterwards") + func parentCloseAfterAnswerResumesNothing() async throws { + let window = Self.makeWindow() + let setup = RoutineDebugSetup() + let answer = Self.present(on: window, setup: setup) + try await waitUntil { setup.dismissPresentation != nil } + + setup.cancel() + #expect(await answer.value == nil) + window.close() + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartFlowTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartFlowTests.swift new file mode 100644 index 0000000000..c941d9bfbc --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartFlowTests.swift @@ -0,0 +1,132 @@ +// +// RoutineDebugStartFlowTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +@MainActor +private final class FlowRecorder { + var moduleEnabled = true + var prepared: [RoutineDebugPick] = [] + var sheets = 0 + var openedScopes: [DatabaseScope] = [] + var failures = 0 + var opened: Int { openedScopes.count } + + func flow( + onPrepare: @escaping @MainActor () -> Void = {}, + onSheet: @escaping @MainActor () -> Void = {} + ) -> RoutineDebugStartFlow { + RoutineDebugStartFlow( + isModuleEnabled: { self.moduleEnabled }, + prepare: { pick in + self.prepared.append(pick) + onPrepare() + return RoutineDebugFixtures.plan() + }, + presentStartSheet: { _, _ in + self.sheets += 1 + onSheet() + return RoutineDebugStartChoice(entryPoint: RoutineDebugFixtures.entryPoint, arguments: [], optionValues: [:]) + }, + reportFailure: { _, _ in self.failures += 1 }, + open: { _, _, scope in self.openedScopes.append(scope) } + ) + } +} + +private let connectionId = UUID() +private let salesPick = RoutineDebugPick( + target: RoutineDebugFixtures.target, + scope: DatabaseScope(connectionId: connectionId, database: "sales", schema: "public") +) + +@MainActor +struct RoutineDebugStartFlowTests { + @Test("A routine chosen after the module went off reads nothing and shows no start sheet") + func chooserAnsweredAfterModuleOff() async { + let recorder = FlowRecorder() + let setup = RoutineDebugSetup() + + await recorder.flow().choose(setup: setup) { _ in + recorder.moduleEnabled = false + return salesPick + } + + #expect(recorder.prepared.isEmpty) + #expect(recorder.sheets == 0) + #expect(recorder.opened == 0) + } + + @Test("A setup cancelled while the plan loads shows no start sheet and opens no tab") + func cancelledWhilePreparing() async { + let recorder = FlowRecorder() + let setup = RoutineDebugSetup() + + await recorder.flow(onPrepare: { setup.cancel() }).run(salesPick, setup: setup) + + #expect(recorder.prepared.count == 1) + #expect(recorder.sheets == 0) + #expect(recorder.opened == 0) + } + + @Test("With the module on, a chosen routine reaches its start sheet and its tab") + func completeFlowOpensTheTab() async { + let recorder = FlowRecorder() + + await recorder.flow().choose(setup: RoutineDebugSetup()) { _ in salesPick } + + #expect(recorder.prepared == [salesPick]) + #expect(recorder.sheets == 1) + #expect(recorder.opened == 1) + } + + @Test("A routine keeps the database it was chosen in when the browse database moves before it starts") + func chosenDatabaseIsFrozen() async { + let recorder = FlowRecorder() + var browseDatabase = "sales" + + await recorder.flow(onSheet: { browseDatabase = "hr" }).choose(setup: RoutineDebugSetup()) { _ in + RoutineDebugPick( + RoutineDebugFixtures.target, + listedIn: DatabaseScope(connectionId: connectionId, database: browseDatabase, schema: "public") + ) + } + + #expect(browseDatabase == "hr") + #expect(recorder.prepared.map { $0.scope.database } == ["sales"]) + #expect(recorder.openedScopes.map { $0.database } == ["sales"]) + } + + @Test("A chooser pick takes the listed database and the routine's own schema") + func pickTakesTheListedDatabase() { + let listed = DatabaseScope(connectionId: connectionId, database: "sales", schema: "reporting") + let target = PluginDebugTarget(identity: "1", name: "f", schema: "public", kindDisplayName: "Function") + let unqualified = PluginDebugTarget(identity: "2", name: "g", schema: nil, kindDisplayName: "Function") + + #expect(RoutineDebugPick(target, listedIn: listed).scope + == DatabaseScope(connectionId: connectionId, database: "sales", schema: "public")) + #expect(RoutineDebugPick(unqualified, listedIn: listed).scope == listed) + } + + @Test("Turning the module off dismisses every chooser and start sheet still on screen") + func moduleOffCancelsPendingSetups() async throws { + let center = NotificationCenter() + let registry = RoutineDebugSessions(isModuleEnabled: { false }, notificationCenter: center) + let setup = RoutineDebugSetup() + var dismissed = false + setup.dismissPresentation = { dismissed = true } + registry.beginSetup(setup) + + center.post(name: .appModulesDidChange, object: AppModule.routineDebugger) + try await waitUntil { setup.isCancelled } + + #expect(dismissed) + registry.endSetup(setup) + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartModelTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartModelTests.swift new file mode 100644 index 0000000000..1207107d44 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartModelTests.swift @@ -0,0 +1,60 @@ +// +// RoutineDebugStartModelTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct RoutineDebugStartModelTests { + private func plan(options: [ConnectionField]) -> PluginDebugPlan { + PluginDebugPlan( + target: PluginDebugTarget(identity: "1", name: "f", schema: nil, kindDisplayName: "Function"), + entryPoints: [PluginDebugEntryPoint(identity: "1", displayName: "f()", parameters: [])], + mayWrite: false, + options: options + ) + } + + private var protocolField: ConnectionField { + ConnectionField( + id: "protocol", + label: "Protocol", + defaultValue: "jdwp", + fieldType: .dropdown(options: [ + .init(value: "jdwp", label: "JDWP"), + .init(value: "dbms", label: "DBMS_DEBUG"), + ]) + ) + } + + private var hostField: ConnectionField { + ConnectionField( + id: "host", + label: "Debugger Host", + defaultValue: "10.0.0.5", + visibleWhen: FieldVisibilityRule(fieldId: "protocol", values: ["jdwp"]) + ) + } + + @Test("A start option shows only while the option it depends on has a matching value") + func optionFollowsItsVisibilityRule() { + let model = RoutineDebugStartModel(plan: plan(options: [protocolField, hostField]), rememberedOption: { _ in nil }) + + #expect(model.isVisible(hostField)) + model.setOption("dbms", for: "protocol") + #expect(!model.isVisible(hostField)) + } + + @Test("A hidden start option is not sent with the request") + func hiddenOptionIsNotSent() { + let model = RoutineDebugStartModel(plan: plan(options: [protocolField, hostField]), rememberedOption: { _ in nil }) + + model.setOption("dbms", for: "protocol") + + #expect(model.choice()?.optionValues == ["protocol": "dbms"]) + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugTabTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugTabTests.swift new file mode 100644 index 0000000000..7006795fd8 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugTabTests.swift @@ -0,0 +1,292 @@ +// +// RoutineDebugTabTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +// A debug tab holds a live session: every way it can go away stops that session first, and none of the app's outside surfaces see it. +@MainActor +@Suite(.serialized) +struct RoutineDebugTabTests { + private func openDebugTab( + in fixture: CoordinatorFixture, + session: FakeDebugSession = FakeDebugSession(), + drivers: DebugDriverLog = DebugDriverLog(), + startBehavior: FakeStartBehavior = .immediate + ) -> (tabId: UUID, controller: RoutineDebugController) { + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers, startBehavior: startBehavior) + let tabId = UUID() + RoutineDebugSessions.shared.register(controller, tabId: tabId, connectionId: fixture.connection.id) + fixture.tabManager.addRoutineDebugTab( + id: tabId, + title: QueryTabManager.routineDebugTitle(for: "order_total"), + databaseName: "app", + schemaName: "public" + ) + return (tabId, controller) + } + + @Test("A debug tab is titled for its routine from the moment it opens") + func titleIsRightFromCreation() { + let fixture = CoordinatorFixture() + let (tabId, controller) = openDebugTab(in: fixture) + defer { RoutineDebugSessions.shared.endSession(forTab: tabId) } + + let tab = fixture.tabManager.tabs.first { $0.id == tabId } + #expect(tab?.tabType == .routineDebug) + #expect(tab?.title == String(format: String(localized: "Debug %@"), "order_total")) + #expect(fixture.tabManager.selectedTabId == tabId) + #expect(fixture.coordinator.frontRoutineDebugController === controller) + let payload = EditorTabPayload(connectionId: UUID(), tabType: .routineDebug, tabTitle: " ") + #expect(!WindowTitleResolver.resolveTitle(payload: payload, databaseType: .postgresql, queryLanguageName: nil).isBlank) + } + + @Test("Closing a debug tab stops its session and closes every driver it opened") + func closingTheTabStopsTheSession() async throws { + let fixture = CoordinatorFixture() + let session = FakeDebugSession() + let drivers = DebugDriverLog() + let (tabId, controller) = openDebugTab(in: fixture, session: session, drivers: drivers) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + fixture.coordinator.closeTabsByUser(ids: [tabId]) + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + #expect(drivers.openedCount == 2) + #expect(drivers.closedCount == 2) + #expect(RoutineDebugSessions.shared.controller(forTab: tabId) == nil) + #expect(!RecentlyClosedTabStore.shared.entries.contains { $0.tab.id == tabId }) + } + + @Test("Closing a debug tab while its run is still starting cancels the start and closes its drivers") + func closingTheTabWhileStarting() async throws { + let fixture = CoordinatorFixture() + let drivers = DebugDriverLog() + let (tabId, controller) = openDebugTab(in: fixture, drivers: drivers, startBehavior: .untilCancelled) + controller.start() + try await waitUntil { drivers.openedCount == 2 } + + fixture.coordinator.closeTabsByUser(ids: [tabId]) + try await waitUntil { controller.hasEnded } + + #expect(drivers.closedCount == 2) + #expect(!fixture.tabManager.tabs.contains { $0.id == tabId }) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("Tearing the window down stops the sessions of its debug tabs") + func teardownStopsTheSession() async throws { + let fixture = CoordinatorFixture() + let session = FakeDebugSession() + let (_, controller) = openDebugTab(in: fixture, session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + fixture.coordinator.teardown() + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + } + + @Test("Stopping a connection's sessions waits for each target to end") + func stopAllForConnection() async throws { + let fixture = CoordinatorFixture() + let session = FakeDebugSession() + let (tabId, controller) = openDebugTab(in: fixture, session: session) + defer { RoutineDebugSessions.shared.endSession(forTab: tabId) } + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + let other = UUID() + await RoutineDebugSessions.shared.stopAll(connectionId: other) + RoutineDebugSessions.shared.reopenAdmission(for: other) + #expect(session.stopCount == 0) + await RoutineDebugSessions.shared.stopAll(connectionId: fixture.connection.id) + RoutineDebugSessions.shared.reopenAdmission(for: fixture.connection.id) + + #expect(session.stopCount == 1) + #expect(controller.hasEnded) + } + + @Test("Turning the module off closes every debug tab and stops its session") + func moduleOffClosesDebugTabs() async throws { + let fixture = CoordinatorFixture() + fixture.coordinator.registerEagerly() + let session = FakeDebugSession() + let (tabId, controller) = openDebugTab(in: fixture, session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + try #require(!AppModuleSettings.shared.isEnabled(.routineDebugger)) + + NotificationCenter.default.post(name: .appModulesDidChange, object: AppModule.routineDebugger) + try await waitUntil { !fixture.tabManager.tabs.contains { $0.id == tabId } } + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + #expect(RoutineDebugSessions.shared.controller(forTab: tabId) == nil) + } + + @Test("A debug tab refuses a move to another connection") + func debugTabRefusesAMove() { + let fixture = CoordinatorFixture() + let (tabId, _) = openDebugTab(in: fixture) + defer { RoutineDebugSessions.shared.endSession(forTab: tabId) } + + #expect(!fixture.coordinator.canMoveTabToConnection(tabId)) + } + + @Test("A debug tab is never written with the connection's tabs") + func debugTabIsNotPersisted() async { + let persistence = TabPersistenceCoordinator(connectionId: UUID()) + persistence.markObservedTabs() + defer { TabDiskActor.clearSync(connectionId: persistence.connectionId) } + let query = QueryTab(title: "Query 1", query: "SELECT 1", tabType: .query) + let debug = QueryTab(title: "Debug order_total", tabType: .routineDebug) + + persistence.saveNowSync(tabs: [query, debug], selectedTabId: debug.id) + let state = await TabDiskActor.shared.load(connectionId: persistence.connectionId) + + #expect(state?.tabs.map { $0.id } == [query.id]) + #expect(state?.selectedTabId == query.id) + } + + @Test("A window holding only a debug tab writes nothing over the saved tabs") + func onlyDebugTabWritesNothing() async { + let persistence = TabPersistenceCoordinator(connectionId: UUID()) + persistence.markObservedTabs() + defer { TabDiskActor.clearSync(connectionId: persistence.connectionId) } + + persistence.saveNowSync(tabs: [QueryTab(title: "Debug f", tabType: .routineDebug)], selectedTabId: nil) + + #expect(await TabDiskActor.shared.load(connectionId: persistence.connectionId) == nil) + } + + @Test("Closing the last saved tab while a debug tab stays open clears the saved tabs") + func closingLastSavedTabBesideDebugTabClears() async { + let fixture = CoordinatorFixture() + fixture.coordinator.registerEagerly() + fixture.coordinator.persistence.markObservedTabs() + defer { TabDiskActor.clearSync(connectionId: fixture.connection.id) } + let query = fixture.addTab(QueryTab(title: "Query 1", query: "SELECT 1", tabType: .query)) + fixture.coordinator.persistence.saveNowSync(tabs: [query], selectedTabId: query.id) + let (tabId, _) = openDebugTab(in: fixture) + defer { RoutineDebugSessions.shared.endSession(forTab: tabId) } + + fixture.coordinator.closeTabsByUser(ids: [query.id]) + + #expect(await TabDiskActor.shared.load(connectionId: fixture.connection.id) == nil) + } + + @Test("MCP clients never see a debug tab") + func debugTabIsLeftOutOfMCPSnapshots() { + let fixture = CoordinatorFixture() + fixture.coordinator.registerEagerly() + let query = fixture.addTab(QueryTab(title: "Query 1", query: "SELECT 1", tabType: .query)) + let (tabId, _) = openDebugTab(in: fixture) + defer { RoutineDebugSessions.shared.endSession(forTab: tabId) } + + let snapshots = MCPTabSnapshotProvider.collectTabSnapshots() + + #expect(snapshots.contains { $0.tabId == query.id }) + #expect(!snapshots.contains { $0.tabId == tabId }) + } + + @Test("AppleScript has no tab kind for a debug tab") + func debugTabHasNoScriptingKind() { + let tab = QueryTab(title: "Debug f", tabType: .routineDebug) + + #expect(ScriptEnumerations.code(for: .routineDebug) == nil) + #expect(ScriptTab(tab: tab, connectionId: UUID(), container: nil) == nil) + } + + @Test("The debugger dials its dedicated drivers on the frozen database with no query timeout") + func liveSourceOpensWithoutQueryTimeout() async throws { + let requested = TimeoutLog() + let plugin = FakeDebugDriver() + let connectionId = UUID() + let source = RoutineDebugSessionSource.live( + scope: DatabaseScope(connectionId: connectionId, database: "sales", schema: nil), + generation: RoutineDebugSessions.shared.admissionGeneration(for: connectionId) + ) { scope, timeout in + requested.record(timeout, database: scope.database) + return PluginDriverAdapter(connection: TestFixtures.makeConnection(), pluginDriver: plugin) + } + + let driver = try await source.openSession() + await source.closeAll() + + #expect(requested.values == [0]) + #expect(requested.databases == ["sales"]) + #expect(driver === plugin) + } + + @Test("Debug… from a sidebar row keeps that row's database and schema, not the one being browsed") + func sidebarPickKeepsTheRowsDatabase() { + let fixture = CoordinatorFixture() + let routine = RoutineInfo(name: "order_total", kind: .function, schema: "public", identity: "16431") + + let pick = fixture.coordinator.routineDebugPick( + for: RoutineDebugFixtures.target, + routine: routine, + database: "sales", + schema: "reporting" + ) + + #expect(fixture.coordinator.browseDatabaseName != "sales") + #expect(pick?.scope.database == "sales") + #expect(pick?.scope.schema == "reporting") + #expect(pick?.scope.connectionId == fixture.connection.id) + } + + @Test("A debug tab stays in sight when the sidebar moves to another database, and keeps the database it runs in") + func debugTabStaysVisibleAcrossContainers() { + let manager = QueryTabManager(containerTarget: .database, visibleContainer: "sales") + var query = QueryTab(title: "Query 1", query: "SELECT 1", tabType: .query) + query.tableContext.databaseName = "sales" + manager.tabs.append(query) + let debugId = manager.addRoutineDebugTab(title: "Debug order_total", databaseName: "sales", schemaName: "public") + + manager.show(container: "hr") + + #expect(manager.visibleTabs.map { $0.id } == [debugId]) + #expect(manager.tabs.first { $0.id == debugId }?.tableContext.databaseName == "sales") + manager.show(container: "sales") + #expect(Set(manager.visibleTabs.map { $0.id }) == [query.id, debugId]) + } + + @Test("A payload never opens a debug tab, which needs a live session") + func payloadOpensNoDebugTab() { + let manager = QueryTabManager() + let payload = EditorTabPayload(connectionId: UUID(), tabType: .routineDebug, tabTitle: "Debug f") + + EditorTabOpener.apply(payload, to: manager, connection: TestFixtures.makeConnection(), toolbarState: nil) + + #expect(manager.tabs.isEmpty) + } +} + +private final class TimeoutLog: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [Int] = [] + private var dialed: [String] = [] + + var values: [Int] { lock.withLock { recorded } } + var databases: [String] { lock.withLock { dialed } } + + func record(_ timeout: Int, database: String) { + lock.withLock { + recorded.append(timeout) + dialed.append(database) + } + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugToolbarTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugToolbarTests.swift new file mode 100644 index 0000000000..07a89bd7c0 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugToolbarTests.swift @@ -0,0 +1,187 @@ +// +// RoutineDebugToolbarTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct RoutineDebugToolbarTests { + private static let pair = Set(MainWindowToolbar.debugDefaultItemIdentifiers) + + private static func hidden( + tabKind: TabType?, + contentMode: ConnectionWorkspaceContentMode = .browse, + showsDebugItems: Bool = true + ) -> Set { + let context = ToolbarContext( + tabKind: tabKind, + contentMode: contentMode, + pane: .content, + isConnected: true, + hasSelectedWorkspace: true, + supportsContainerSwitching: true, + showsDebugItems: showsDebugItems + ) + return ToolbarContextResolver.visibility(for: context.visibilityKey).hidden + } + + @Test("Continue and Stop sit ahead of the Actions pull-down, and only while the module is on") + func defaultSetPlacesThePair() throws { + let withDebug = MainWindowToolbar.defaultItems(includingDebugItems: true) + let actions = try #require(withDebug.firstIndex(of: MainWindowToolbar.actions)) + + #expect(Array(withDebug[(actions - 2).. + + '' + Newest version launched + The newest TablePro release that has run, so the first launch of a later one can point to its release notes. Empty before the first launch. + + false Run the MCP server diff --git a/linux/data/licenses/THIRD-PARTY-NOTICE.md b/linux/data/licenses/THIRD-PARTY-NOTICE.md index c2555623ea..3815fa2845 100644 --- a/linux/data/licenses/THIRD-PARTY-NOTICE.md +++ b/linux/data/licenses/THIRD-PARTY-NOTICE.md @@ -11,6 +11,7 @@ TablePro is licensed under the GNU Affero General Public License, version 3 or l - Apache License 2.0 (Apache-2.0) - BSD 3-Clause "New" or "Revised" License (BSD-3-Clause) - ISC License (ISC) +- Mozilla Public License 2.0 (MPL-2.0) - zlib License (Zlib) - Boost Software License 1.0 (BSL-1.0) - Creative Commons Zero v1.0 Universal (CC0-1.0) @@ -2918,6 +2919,28 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI Used by: +- libloading 0.8.9 + +~~~~text +Copyright © 2015, Simonas Kazlauskas + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without +fee is hereby granted, provided that the above copyright notice and this permission notice appear +in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS +SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + +~~~~ + +### ISC License + +Used by: + - rustls-webpki 0.103.16 ~~~~text @@ -3715,6 +3738,8 @@ DEALINGS IN THE SOFTWARE. Used by: - lazy_static 1.5.0 +- rayon-core 1.13.0 +- rayon 1.12.0 ~~~~text Copyright (c) 2010 The Rust Project Developers @@ -4505,6 +4530,34 @@ SOFTWARE. Used by: +- dlib 0.5.3 + +~~~~text +Copyright (c) 2015 Victor Berger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + - resolv-conf 0.7.6 ~~~~text @@ -5169,6 +5222,37 @@ DEALINGS IN THE SOFTWARE. Used by: +- foreign-types-macros 0.2.4 +- foreign-types-shared 0.3.1 +- foreign-types 0.5.0 + +~~~~text +Copyright (c) 2017 The foreign-types Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - stringprep 0.1.5 ~~~~text @@ -6379,6 +6463,42 @@ DEALINGS IN THE SOFTWARE. Used by: +- memmap2 0.9.12 + +~~~~text +Copyright (c) 2020 Yevhenii Reizner +Copyright (c) 2015 Dan Burkert + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - const-oid 0.9.6 - der_derive 0.7.3 @@ -7172,6 +7292,8 @@ DEALINGS IN THE SOFTWARE. Used by: +- env_filter 2.0.0 +- env_logger 0.11.11 - toml_datetime 1.1.1+spec-1.1.0 - toml_edit 0.25.15+spec-1.1.0 - toml_parser 1.1.3+spec-1.1.0 @@ -7257,6 +7379,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- lcms2 6.2.0 + +~~~~text +Copyright (c) Kornel Lesiński + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ~~~~ ### MIT License @@ -7603,6 +7755,32 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- memfd 0.6.6 + +~~~~text +Copyright + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT +OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ~~~~ ### MIT License @@ -7739,6 +7917,69 @@ SOFTWARE. Used by: +- rmp-serde 1.3.1 +- rmp 0.8.15 + +~~~~text +MIT License + +Copyright (c) 2017 Evgeny Safronov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- static_assertions 1.1.0 + +~~~~text +MIT License + +Copyright (c) 2017 Nikolai Vazquez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - darling 0.24.1 - darling_core 0.24.1 - darling_macro 0.24.1 @@ -8022,6 +8263,25 @@ SOFTWARE. Used by: +- bytemuck 1.25.2 + +~~~~text +MIT License + +Copyright (c) 2019 Daniel "Lokathor" Gee. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - schemars 1.2.2 - schemars_derive 1.2.2 @@ -8366,6 +8626,37 @@ SOFTWARE. Used by: +- libseccomp-sys 0.3.0 +- libseccomp 0.4.0 + +~~~~text +MIT License + +Copyright (c) 2021 Sony Group Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + - rustls-platform-verifier 0.7.0 ~~~~text @@ -8458,6 +8749,38 @@ SOFTWARE. Used by: +- lcms2-sys 4.0.7 + +~~~~text +MIT License + +Copyright (c) 2023 Marti Maria Saguer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject +to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - sse-stream 0.2.6 ~~~~text @@ -8615,7 +8938,7 @@ SOFTWARE. Used by: -- rust_xlsxwriter 0.99.1 +- rust_xlsxwriter 0.102.0 ~~~~text MIT License @@ -8771,12 +9094,16 @@ DEALINGS IN THE SOFTWARE. Used by: +- async-channel 2.5.0 - async-lock 3.4.2 +- async-task 4.7.1 - async-trait 0.1.92 - atomic-waker 1.1.2 +- blocking 1.7.0 - clickhouse-macros 0.3.0 - clickhouse-types 0.1.3 - clickhouse 0.15.2 +- concurrent-queue 2.5.0 - derive-syn-parse 0.2.0 - displaydoc 0.2.7 - dyn-clone 1.0.20 @@ -8797,8 +9124,10 @@ Used by: - once_cell 1.21.4 - ordered-stream 0.2.0 - parking 2.2.1 +- paste 1.0.15 - pastey 0.2.3 - pin-project-lite 0.2.17 +- piper 0.2.5 - portable-atomic 1.15.0 - proc-macro-crate 3.5.0 - proc-macro2 1.0.107 @@ -9082,7 +9411,7 @@ SOFTWARE. Used by: - tracing-core 0.1.36 -- zip 8.6.0 +- zip 9.0.5 ~~~~text The MIT License (MIT) @@ -9240,6 +9569,8 @@ Used by: - byteorder 1.5.0 - csv-core 0.1.13 - csv 1.4.0 +- jiff-core 0.1.1 +- jiff 0.2.37 - memchr 2.8.3 ~~~~text @@ -9271,6 +9602,37 @@ THE SOFTWARE. Used by: +- nix 0.30.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2015 Carl Lerche + nix-rust Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - strsim 0.11.1 ~~~~text @@ -9660,6 +10022,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Used by: - crossbeam-channel 0.5.17 +- crossbeam-deque 0.8.8 - crossbeam-epoch 0.9.21 - crossbeam-queue 0.3.14 - crossbeam-utils 0.8.23 @@ -9817,6 +10180,41 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- yeslogic-fontconfig-sys 6.0.1 + +~~~~text +The MIT License (MIT) + +Copyright 2019 YesLogic Pty. Ltd. +Copyright 2016 Manuel Reinhardt +Copyright 2014 Austin Bonander +Copyright 2013 The Servo Project Developers. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ~~~~ ### MIT License @@ -10217,6 +10615,393 @@ Copyright notices are retained in src/* files where relevant. ~~~~ +### Mozilla Public License 2.0 + +Used by: + +- glycin-common 1.0.4 +- glycin-utils 4.1.0 +- glycin 3.1.0 +- gufo-common 1.1.0 +- gufo-exif 0.4.0 + +~~~~text +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + +~~~~ + ### Unicode License v3 Used by: diff --git a/linux/data/licenses/third-party.json b/linux/data/licenses/third-party.json index 6c14ece54b..4fc842561c 100644 --- a/linux/data/licenses/third-party.json +++ b/linux/data/licenses/third-party.json @@ -165,6 +165,14 @@ { "crate": { "name": "aws-lc-sys", "version": "0.45.0" } } ] }, + { + "id": "ISC", + "name": "ISC License", + "text": "Copyright © 2015, Simonas Kazlauskas\n\nPermission to use, copy, modify, and/or distribute this software for any purpose with or without\nfee is hereby granted, provided that the above copyright notice and this permission notice appear\nin all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS\nSOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE\nAUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,\nNEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "libloading", "version": "0.8.9" } } + ] + }, { "id": "ISC", "name": "ISC License", @@ -241,7 +249,9 @@ "name": "MIT License", "text": "Copyright (c) 2010 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", "used_by": [ - { "crate": { "name": "lazy_static", "version": "1.5.0" } } + { "crate": { "name": "lazy_static", "version": "1.5.0" } }, + { "crate": { "name": "rayon-core", "version": "1.13.0" } }, + { "crate": { "name": "rayon", "version": "1.12.0" } } ] }, { @@ -449,6 +459,14 @@ { "crate": { "name": "unicode_categories", "version": "0.1.1" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015 Victor Berger\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.", + "used_by": [ + { "crate": { "name": "dlib", "version": "0.5.3" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -625,6 +643,16 @@ { "crate": { "name": "stable_deref_trait", "version": "1.2.1" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 The foreign-types Developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "foreign-types-macros", "version": "0.2.4" } }, + { "crate": { "name": "foreign-types-shared", "version": "0.3.1" } }, + { "crate": { "name": "foreign-types", "version": "0.5.0" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -913,6 +941,14 @@ { "crate": { "name": "arcstr", "version": "1.2.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2020 Yevhenii Reizner\nCopyright (c) 2015 Dan Burkert\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "memmap2", "version": "0.9.12" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1114,6 +1150,8 @@ "name": "MIT License", "text": "Copyright (c) Individual contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", "used_by": [ + { "crate": { "name": "env_filter", "version": "2.0.0" } }, + { "crate": { "name": "env_logger", "version": "0.11.11" } }, { "crate": { "name": "toml_datetime", "version": "1.1.1+spec-1.1.0" } }, { "crate": { "name": "toml_edit", "version": "0.25.15+spec-1.1.0" } }, { "crate": { "name": "toml_parser", "version": "1.1.3+spec-1.1.0" } } @@ -1137,6 +1175,14 @@ { "crate": { "name": "time", "version": "0.3.55" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) Kornel Lesiński\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "lcms2", "version": "6.2.0" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1253,6 +1299,14 @@ { "crate": { "name": "zerocopy", "version": "0.8.57" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright \n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and\nassociated documentation files (the \"Software\"), to deal in the Software without restriction,\nincluding without limitation the rights to use, copy, modify, merge, publish, distribute,\nsublicense, and/or sell copies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or\nsubstantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT\nNOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\nDAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT\nOF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "memfd", "version": "0.6.6" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1287,6 +1341,23 @@ { "crate": { "name": "tap", "version": "1.0.1" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017 Evgeny Safronov\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "rmp-serde", "version": "1.3.1" } }, + { "crate": { "name": "rmp", "version": "0.8.15" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017 Nikolai Vazquez\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "static_assertions", "version": "1.1.0" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1362,6 +1433,14 @@ { "crate": { "name": "comfy-table", "version": "7.1.4" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 Daniel \"Lokathor\" Gee.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "bytemuck", "version": "1.25.2" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1452,6 +1531,15 @@ { "crate": { "name": "derive-where", "version": "1.7.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2021 Sony Group Corporation\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "libseccomp-sys", "version": "0.3.0" } }, + { "crate": { "name": "libseccomp", "version": "0.4.0" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1476,6 +1564,14 @@ { "crate": { "name": "bnum", "version": "0.13.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2023 Marti Maria Saguer\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the \n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to \npermit persons to whom the Software is furnished to do so, subject\nto the following conditions:\n\nThe above copyright notice and this permission notice shall be \nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF \nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,\nTORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE \nSOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "lcms2-sys", "version": "4.0.7" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1524,7 +1620,7 @@ "name": "MIT License", "text": "MIT License\n\nCopyright 2022-2026 John McNamara \n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n", "used_by": [ - { "crate": { "name": "rust_xlsxwriter", "version": "0.99.1" } } + { "crate": { "name": "rust_xlsxwriter", "version": "0.102.0" } } ] }, { @@ -1568,12 +1664,16 @@ "name": "MIT License", "text": "Permission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", "used_by": [ + { "crate": { "name": "async-channel", "version": "2.5.0" } }, { "crate": { "name": "async-lock", "version": "3.4.2" } }, + { "crate": { "name": "async-task", "version": "4.7.1" } }, { "crate": { "name": "async-trait", "version": "0.1.92" } }, { "crate": { "name": "atomic-waker", "version": "1.1.2" } }, + { "crate": { "name": "blocking", "version": "1.7.0" } }, { "crate": { "name": "clickhouse-macros", "version": "0.3.0" } }, { "crate": { "name": "clickhouse-types", "version": "0.1.3" } }, { "crate": { "name": "clickhouse", "version": "0.15.2" } }, + { "crate": { "name": "concurrent-queue", "version": "2.5.0" } }, { "crate": { "name": "derive-syn-parse", "version": "0.2.0" } }, { "crate": { "name": "displaydoc", "version": "0.2.7" } }, { "crate": { "name": "dyn-clone", "version": "1.0.20" } }, @@ -1594,8 +1694,10 @@ { "crate": { "name": "once_cell", "version": "1.21.4" } }, { "crate": { "name": "ordered-stream", "version": "0.2.0" } }, { "crate": { "name": "parking", "version": "2.2.1" } }, + { "crate": { "name": "paste", "version": "1.0.15" } }, { "crate": { "name": "pastey", "version": "0.2.3" } }, { "crate": { "name": "pin-project-lite", "version": "0.2.17" } }, + { "crate": { "name": "piper", "version": "0.2.5" } }, { "crate": { "name": "portable-atomic", "version": "1.15.0" } }, { "crate": { "name": "proc-macro-crate", "version": "3.5.0" } }, { "crate": { "name": "proc-macro2", "version": "1.0.107" } }, @@ -1718,7 +1820,7 @@ "text": "The MIT License (MIT)\n\nCopyright (c) 2014 Mathijs van de Nes\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", "used_by": [ { "crate": { "name": "tracing-core", "version": "0.1.36" } }, - { "crate": { "name": "zip", "version": "8.6.0" } } + { "crate": { "name": "zip", "version": "9.0.5" } } ] }, { @@ -1763,9 +1865,19 @@ { "crate": { "name": "byteorder", "version": "1.5.0" } }, { "crate": { "name": "csv-core", "version": "0.1.13" } }, { "crate": { "name": "csv", "version": "1.4.0" } }, + { "crate": { "name": "jiff-core", "version": "0.1.1" } }, + { "crate": { "name": "jiff", "version": "0.2.37" } }, { "crate": { "name": "memchr", "version": "2.8.3" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2015 Carl Lerche + nix-rust Authors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "nix", "version": "0.30.1" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1874,6 +1986,7 @@ "text": "The MIT License (MIT)\n\nCopyright (c) 2019 The Crossbeam Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", "used_by": [ { "crate": { "name": "crossbeam-channel", "version": "0.5.17" } }, + { "crate": { "name": "crossbeam-deque", "version": "0.8.8" } }, { "crate": { "name": "crossbeam-epoch", "version": "0.9.21" } }, { "crate": { "name": "crossbeam-queue", "version": "0.3.14" } }, { "crate": { "name": "crossbeam-utils", "version": "0.8.23" } } @@ -1912,6 +2025,14 @@ { "crate": { "name": "crunchy", "version": "0.2.4" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright 2019 YesLogic Pty. Ltd.\nCopyright 2016 Manuel Reinhardt\nCopyright 2014 Austin Bonander\nCopyright 2013 The Servo Project Developers.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "yeslogic-fontconfig-sys", "version": "6.0.1" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1953,6 +2074,18 @@ { "crate": { "name": "libm", "version": "0.2.16" } } ] }, + { + "id": "MPL-2.0", + "name": "Mozilla Public License 2.0", + "text": "Mozilla Public License Version 2.0\n==================================\n\n1. Definitions\n--------------\n\n1.1. \"Contributor\"\n means each individual or legal entity that creates, contributes to\n the creation of, or owns Covered Software.\n\n1.2. \"Contributor Version\"\n means the combination of the Contributions of others (if any) used\n by a Contributor and that particular Contributor's Contribution.\n\n1.3. \"Contribution\"\n means Covered Software of a particular Contributor.\n\n1.4. \"Covered Software\"\n means Source Code Form to which the initial Contributor has attached\n the notice in Exhibit A, the Executable Form of such Source Code\n Form, and Modifications of such Source Code Form, in each case\n including portions thereof.\n\n1.5. \"Incompatible With Secondary Licenses\"\n means\n\n (a) that the initial Contributor has attached the notice described\n in Exhibit B to the Covered Software; or\n\n (b) that the Covered Software was made available under the terms of\n version 1.1 or earlier of the License, but not also under the\n terms of a Secondary License.\n\n1.6. \"Executable Form\"\n means any form of the work other than Source Code Form.\n\n1.7. \"Larger Work\"\n means a work that combines Covered Software with other material, in\n a separate file or files, that is not Covered Software.\n\n1.8. \"License\"\n means this document.\n\n1.9. \"Licensable\"\n means having the right to grant, to the maximum extent possible,\n whether at the time of the initial grant or subsequently, any and\n all of the rights conveyed by this License.\n\n1.10. \"Modifications\"\n means any of the following:\n\n (a) any file in Source Code Form that results from an addition to,\n deletion from, or modification of the contents of Covered\n Software; or\n\n (b) any new file in Source Code Form that contains any Covered\n Software.\n\n1.11. \"Patent Claims\" of a Contributor\n means any patent claim(s), including without limitation, method,\n process, and apparatus claims, in any patent Licensable by such\n Contributor that would be infringed, but for the grant of the\n License, by the making, using, selling, offering for sale, having\n made, import, or transfer of either its Contributions or its\n Contributor Version.\n\n1.12. \"Secondary License\"\n means either the GNU General Public License, Version 2.0, the GNU\n Lesser General Public License, Version 2.1, the GNU Affero General\n Public License, Version 3.0, or any later versions of those\n licenses.\n\n1.13. \"Source Code Form\"\n means the form of the work preferred for making modifications.\n\n1.14. \"You\" (or \"Your\")\n means an individual or a legal entity exercising rights under this\n License. For legal entities, \"You\" includes any entity that\n controls, is controlled by, or is under common control with You. For\n purposes of this definition, \"control\" means (a) the power, direct\n or indirect, to cause the direction or management of such entity,\n whether by contract or otherwise, or (b) ownership of more than\n fifty percent (50%) of the outstanding shares or beneficial\n ownership of such entity.\n\n2. License Grants and Conditions\n--------------------------------\n\n2.1. Grants\n\nEach Contributor hereby grants You a world-wide, royalty-free,\nnon-exclusive license:\n\n(a) under intellectual property rights (other than patent or trademark)\n Licensable by such Contributor to use, reproduce, make available,\n modify, display, perform, distribute, and otherwise exploit its\n Contributions, either on an unmodified basis, with Modifications, or\n as part of a Larger Work; and\n\n(b) under Patent Claims of such Contributor to make, use, sell, offer\n for sale, have made, import, and otherwise transfer either its\n Contributions or its Contributor Version.\n\n2.2. Effective Date\n\nThe licenses granted in Section 2.1 with respect to any Contribution\nbecome effective for each Contribution on the date the Contributor first\ndistributes such Contribution.\n\n2.3. Limitations on Grant Scope\n\nThe licenses granted in this Section 2 are the only rights granted under\nthis License. No additional rights or licenses will be implied from the\ndistribution or licensing of Covered Software under this License.\nNotwithstanding Section 2.1(b) above, no patent license is granted by a\nContributor:\n\n(a) for any code that a Contributor has removed from Covered Software;\n or\n\n(b) for infringements caused by: (i) Your and any other third party's\n modifications of Covered Software, or (ii) the combination of its\n Contributions with other software (except as part of its Contributor\n Version); or\n\n(c) under Patent Claims infringed by Covered Software in the absence of\n its Contributions.\n\nThis License does not grant any rights in the trademarks, service marks,\nor logos of any Contributor (except as may be necessary to comply with\nthe notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\nNo Contributor makes additional grants as a result of Your choice to\ndistribute the Covered Software under a subsequent version of this\nLicense (see Section 10.2) or under the terms of a Secondary License (if\npermitted under the terms of Section 3.3).\n\n2.5. Representation\n\nEach Contributor represents that the Contributor believes its\nContributions are its original creation(s) or it has sufficient rights\nto grant the rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\nThis License is not intended to limit any rights You have under\napplicable copyright doctrines of fair use, fair dealing, or other\nequivalents.\n\n2.7. Conditions\n\nSections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted\nin Section 2.1.\n\n3. Responsibilities\n-------------------\n\n3.1. Distribution of Source Form\n\nAll distribution of Covered Software in Source Code Form, including any\nModifications that You create or to which You contribute, must be under\nthe terms of this License. You must inform recipients that the Source\nCode Form of the Covered Software is governed by the terms of this\nLicense, and how they can obtain a copy of this License. You may not\nattempt to alter or restrict the recipients' rights in the Source Code\nForm.\n\n3.2. Distribution of Executable Form\n\nIf You distribute Covered Software in Executable Form then:\n\n(a) such Covered Software must also be made available in Source Code\n Form, as described in Section 3.1, and You must inform recipients of\n the Executable Form how they can obtain a copy of such Source Code\n Form by reasonable means in a timely manner, at a charge no more\n than the cost of distribution to the recipient; and\n\n(b) You may distribute such Executable Form under the terms of this\n License, or sublicense it under different terms, provided that the\n license for the Executable Form does not attempt to limit or alter\n the recipients' rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\nYou may create and distribute a Larger Work under terms of Your choice,\nprovided that You also comply with the requirements of this License for\nthe Covered Software. If the Larger Work is a combination of Covered\nSoftware with a work governed by one or more Secondary Licenses, and the\nCovered Software is not Incompatible With Secondary Licenses, this\nLicense permits You to additionally distribute such Covered Software\nunder the terms of such Secondary License(s), so that the recipient of\nthe Larger Work may, at their option, further distribute the Covered\nSoftware under the terms of either this License or such Secondary\nLicense(s).\n\n3.4. Notices\n\nYou may not remove or alter the substance of any license notices\n(including copyright notices, patent notices, disclaimers of warranty,\nor limitations of liability) contained within the Source Code Form of\nthe Covered Software, except that You may alter any license notices to\nthe extent required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\nYou may choose to offer, and to charge a fee for, warranty, support,\nindemnity or liability obligations to one or more recipients of Covered\nSoftware. However, You may do so only on Your own behalf, and not on\nbehalf of any Contributor. You must make it absolutely clear that any\nsuch warranty, support, indemnity, or liability obligation is offered by\nYou alone, and You hereby agree to indemnify every Contributor for any\nliability incurred by such Contributor as a result of warranty, support,\nindemnity or liability terms You offer. You may include additional\ndisclaimers of warranty and limitations of liability specific to any\njurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n---------------------------------------------------\n\nIf it is impossible for You to comply with any of the terms of this\nLicense with respect to some or all of the Covered Software due to\nstatute, judicial order, or regulation then You must: (a) comply with\nthe terms of this License to the maximum extent possible; and (b)\ndescribe the limitations and the code they affect. Such description must\nbe placed in a text file included with all distributions of the Covered\nSoftware under this License. Except to the extent prohibited by statute\nor regulation, such description must be sufficiently detailed for a\nrecipient of ordinary skill to be able to understand it.\n\n5. Termination\n--------------\n\n5.1. The rights granted under this License will terminate automatically\nif You fail to comply with any of its terms. However, if You become\ncompliant, then the rights granted under this License from a particular\nContributor are reinstated (a) provisionally, unless and until such\nContributor explicitly and finally terminates Your grants, and (b) on an\nongoing basis, if such Contributor fails to notify You of the\nnon-compliance by some reasonable means prior to 60 days after You have\ncome back into compliance. Moreover, Your grants from a particular\nContributor are reinstated on an ongoing basis if such Contributor\nnotifies You of the non-compliance by some reasonable means, this is the\nfirst time You have received notice of non-compliance with this License\nfrom such Contributor, and You become compliant prior to 30 days after\nYour receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\ninfringement claim (excluding declaratory judgment actions,\ncounter-claims, and cross-claims) alleging that a Contributor Version\ndirectly or indirectly infringes any patent, then the rights granted to\nYou by any and all Contributors for the Covered Software under Section\n2.1 of this License shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all\nend user license agreements (excluding distributors and resellers) which\nhave been validly granted by You or Your distributors under this License\nprior to termination shall survive termination.\n\n************************************************************************\n* *\n* 6. Disclaimer of Warranty *\n* ------------------------- *\n* *\n* Covered Software is provided under this License on an \"as is\" *\n* basis, without warranty of any kind, either expressed, implied, or *\n* statutory, including, without limitation, warranties that the *\n* Covered Software is free of defects, merchantable, fit for a *\n* particular purpose or non-infringing. The entire risk as to the *\n* quality and performance of the Covered Software is with You. *\n* Should any Covered Software prove defective in any respect, You *\n* (not any Contributor) assume the cost of any necessary servicing, *\n* repair, or correction. This disclaimer of warranty constitutes an *\n* essential part of this License. No use of any Covered Software is *\n* authorized under this License except under this disclaimer. *\n* *\n************************************************************************\n\n************************************************************************\n* *\n* 7. Limitation of Liability *\n* -------------------------- *\n* *\n* Under no circumstances and under no legal theory, whether tort *\n* (including negligence), contract, or otherwise, shall any *\n* Contributor, or anyone who distributes Covered Software as *\n* permitted above, be liable to You for any direct, indirect, *\n* special, incidental, or consequential damages of any character *\n* including, without limitation, damages for lost profits, loss of *\n* goodwill, work stoppage, computer failure or malfunction, or any *\n* and all other commercial damages or losses, even if such party *\n* shall have been informed of the possibility of such damages. This *\n* limitation of liability shall not apply to liability for death or *\n* personal injury resulting from such party's negligence to the *\n* extent applicable law prohibits such limitation. Some *\n* jurisdictions do not allow the exclusion or limitation of *\n* incidental or consequential damages, so this exclusion and *\n* limitation may not apply to You. *\n* *\n************************************************************************\n\n8. Litigation\n-------------\n\nAny litigation relating to this License may be brought only in the\ncourts of a jurisdiction where the defendant maintains its principal\nplace of business and such litigation shall be governed by laws of that\njurisdiction, without reference to its conflict-of-law provisions.\nNothing in this Section shall prevent a party's ability to bring\ncross-claims or counter-claims.\n\n9. Miscellaneous\n----------------\n\nThis License represents the complete agreement concerning the subject\nmatter hereof. If any provision of this License is held to be\nunenforceable, such provision shall be reformed only to the extent\nnecessary to make it enforceable. Any law or regulation which provides\nthat the language of a contract shall be construed against the drafter\nshall not be used to construe this License against a Contributor.\n\n10. Versions of the License\n---------------------------\n\n10.1. New Versions\n\nMozilla Foundation is the license steward. Except as provided in Section\n10.3, no one other than the license steward has the right to modify or\npublish new versions of this License. Each version will be given a\ndistinguishing version number.\n\n10.2. Effect of New Versions\n\nYou may distribute the Covered Software under the terms of the version\nof the License under which You originally received the Covered Software,\nor under the terms of any subsequent version published by the license\nsteward.\n\n10.3. Modified Versions\n\nIf you create software not governed by this License, and you want to\ncreate a new license for such software, you may create and use a\nmodified version of this License if you rename the license and remove\nany references to the name of the license steward (except to note that\nsuch modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary\nLicenses\n\nIf You choose to distribute Source Code Form that is Incompatible With\nSecondary Licenses under the terms of this version of the License, the\nnotice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n-------------------------------------------\n\n This Source Code Form is subject to the terms of the Mozilla Public\n License, v. 2.0. If a copy of the MPL was not distributed with this\n file, You can obtain one at https://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular\nfile, then You may include the notice in a location (such as a LICENSE\nfile in a relevant directory) where a recipient would be likely to look\nfor such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - \"Incompatible With Secondary Licenses\" Notice\n---------------------------------------------------------\n\n This Source Code Form is \"Incompatible With Secondary Licenses\", as\n defined by the Mozilla Public License, v. 2.0.\n", + "used_by": [ + { "crate": { "name": "glycin-common", "version": "1.0.4" } }, + { "crate": { "name": "glycin-utils", "version": "4.1.0" } }, + { "crate": { "name": "glycin", "version": "3.1.0" } }, + { "crate": { "name": "gufo-common", "version": "1.1.0" } }, + { "crate": { "name": "gufo-exif", "version": "0.4.0" } } + ] + }, { "id": "Unicode-3.0", "name": "Unicode License v3", @@ -2272,8 +2405,10 @@ { "name": "arrow-string", "version": "58.4.0", "license": "Apache-2.0" }, { "name": "ashpd", "version": "0.13.13", "license": "MIT" }, { "name": "async-broadcast", "version": "0.7.2", "license": "MIT OR Apache-2.0" }, + { "name": "async-channel", "version": "2.5.0", "license": "Apache-2.0 OR MIT" }, { "name": "async-lock", "version": "3.4.2", "license": "Apache-2.0 OR MIT" }, { "name": "async-recursion", "version": "1.1.1", "license": "MIT OR Apache-2.0" }, + { "name": "async-task", "version": "4.7.1", "license": "Apache-2.0 OR MIT" }, { "name": "async-trait", "version": "0.1.92", "license": "MIT OR Apache-2.0" }, { "name": "asynchronous-codec", "version": "0.7.0", "license": "MIT" }, { "name": "atoi", "version": "2.0.0", "license": "MIT" }, @@ -2288,10 +2423,12 @@ { "name": "block-buffer", "version": "0.10.4", "license": "MIT OR Apache-2.0" }, { "name": "block-buffer", "version": "0.12.1", "license": "MIT OR Apache-2.0" }, { "name": "block-padding", "version": "0.3.3", "license": "MIT OR Apache-2.0" }, + { "name": "blocking", "version": "1.7.0", "license": "Apache-2.0 OR MIT" }, { "name": "bnum", "version": "0.13.0", "license": "MIT OR Apache-2.0" }, { "name": "bson", "version": "3.1.0", "license": "MIT" }, { "name": "bstr", "version": "1.13.1", "license": "MIT OR Apache-2.0" }, { "name": "bumpalo", "version": "3.20.3", "license": "MIT OR Apache-2.0" }, + { "name": "bytemuck", "version": "1.25.2", "license": "Zlib OR Apache-2.0 OR MIT" }, { "name": "byteorder", "version": "1.5.0", "license": "Unlicense OR MIT" }, { "name": "bytes", "version": "1.12.1", "license": "MIT" }, { "name": "cairo-rs", "version": "0.22.9", "license": "MIT" }, @@ -2308,6 +2445,7 @@ { "name": "cmov", "version": "0.5.4", "license": "Apache-2.0 OR MIT" }, { "name": "combine", "version": "4.6.8", "license": "MIT" }, { "name": "comfy-table", "version": "7.1.4", "license": "MIT" }, + { "name": "concurrent-queue", "version": "2.5.0", "license": "Apache-2.0 OR MIT" }, { "name": "connection-string", "version": "0.2.0", "license": "MIT OR Apache-2.0" }, { "name": "const-oid", "version": "0.9.6", "license": "Apache-2.0 OR MIT" }, { "name": "const-oid", "version": "0.10.2", "license": "Apache-2.0 OR MIT" }, @@ -2323,6 +2461,7 @@ { "name": "crc32fast", "version": "1.5.2", "license": "MIT OR Apache-2.0" }, { "name": "critical-section", "version": "1.2.0", "license": "MIT OR Apache-2.0" }, { "name": "crossbeam-channel", "version": "0.5.17", "license": "MIT OR Apache-2.0" }, + { "name": "crossbeam-deque", "version": "0.8.8", "license": "MIT OR Apache-2.0" }, { "name": "crossbeam-epoch", "version": "0.9.21", "license": "MIT OR Apache-2.0" }, { "name": "crossbeam-queue", "version": "0.3.14", "license": "MIT OR Apache-2.0" }, { "name": "crossbeam-utils", "version": "0.8.23", "license": "MIT OR Apache-2.0" }, @@ -2354,6 +2493,7 @@ { "name": "digest", "version": "0.10.7", "license": "MIT OR Apache-2.0" }, { "name": "digest", "version": "0.11.3", "license": "MIT OR Apache-2.0" }, { "name": "displaydoc", "version": "0.2.7", "license": "MIT OR Apache-2.0" }, + { "name": "dlib", "version": "0.5.3", "license": "MIT" }, { "name": "dotenvy", "version": "0.15.7", "license": "MIT" }, { "name": "duckdb", "version": "1.10506.1", "license": "MIT" }, { "name": "dyn-clone", "version": "1.0.20", "license": "MIT OR Apache-2.0" }, @@ -2362,6 +2502,8 @@ { "name": "endi", "version": "1.1.1", "license": "MIT" }, { "name": "enumflags2", "version": "0.7.12", "license": "MIT OR Apache-2.0" }, { "name": "enumflags2_derive", "version": "0.7.12", "license": "MIT OR Apache-2.0" }, + { "name": "env_filter", "version": "2.0.0", "license": "MIT OR Apache-2.0" }, + { "name": "env_logger", "version": "0.11.11", "license": "MIT OR Apache-2.0" }, { "name": "equivalent", "version": "1.0.2", "license": "Apache-2.0 OR MIT" }, { "name": "errno", "version": "0.3.14", "license": "MIT OR Apache-2.0" }, { "name": "event-listener", "version": "5.4.2", "license": "Apache-2.0 OR MIT" }, @@ -2377,6 +2519,9 @@ { "name": "flume", "version": "0.12.0", "license": "Apache-2.0 OR MIT" }, { "name": "foldhash", "version": "0.1.5", "license": "Zlib" }, { "name": "foldhash", "version": "0.2.0", "license": "Zlib" }, + { "name": "foreign-types", "version": "0.5.0", "license": "MIT OR Apache-2.0" }, + { "name": "foreign-types-macros", "version": "0.2.4", "license": "MIT OR Apache-2.0" }, + { "name": "foreign-types-shared", "version": "0.3.1", "license": "MIT OR Apache-2.0" }, { "name": "form_urlencoded", "version": "1.2.2", "license": "MIT OR Apache-2.0" }, { "name": "fragile", "version": "2.1.0", "license": "Apache-2.0" }, { "name": "funty", "version": "2.0.0", "license": "MIT" }, @@ -2409,6 +2554,9 @@ { "name": "glib", "version": "0.22.9", "license": "MIT" }, { "name": "glib-macros", "version": "0.22.9", "license": "MIT" }, { "name": "glib-sys", "version": "0.22.9", "license": "MIT" }, + { "name": "glycin", "version": "3.1.0", "license": "MPL-2.0 OR LGPL-2.1-or-later" }, + { "name": "glycin-common", "version": "1.0.4", "license": "MPL-2.0 OR LGPL-2.1-or-later" }, + { "name": "glycin-utils", "version": "4.1.0", "license": "MPL-2.0 OR LGPL-2.1-or-later" }, { "name": "gobject-sys", "version": "0.22.9", "license": "MIT" }, { "name": "graphene-rs", "version": "0.22.8", "license": "MIT" }, { "name": "graphene-sys", "version": "0.22.9", "license": "MIT" }, @@ -2417,6 +2565,8 @@ { "name": "gtk4", "version": "0.11.4", "license": "MIT" }, { "name": "gtk4-macros", "version": "0.11.4", "license": "MIT" }, { "name": "gtk4-sys", "version": "0.11.4", "license": "MIT" }, + { "name": "gufo-common", "version": "1.1.0", "license": "MPL-2.0 OR LGPL-2.1-or-later" }, + { "name": "gufo-exif", "version": "0.4.0", "license": "MPL-2.0 OR LGPL-2.1-or-later" }, { "name": "half", "version": "2.7.1", "license": "MIT OR Apache-2.0" }, { "name": "hashbrown", "version": "0.14.5", "license": "MIT OR Apache-2.0" }, { "name": "hashbrown", "version": "0.15.5", "license": "MIT OR Apache-2.0" }, @@ -2461,7 +2611,11 @@ { "name": "ipnet", "version": "2.12.2", "license": "MIT OR Apache-2.0" }, { "name": "itertools", "version": "0.15.0", "license": "MIT OR Apache-2.0" }, { "name": "itoa", "version": "1.0.18", "license": "MIT OR Apache-2.0" }, + { "name": "jiff", "version": "0.2.37", "license": "Unlicense OR MIT" }, + { "name": "jiff-core", "version": "0.1.1", "license": "Unlicense OR MIT" }, { "name": "lazy_static", "version": "1.5.0", "license": "MIT OR Apache-2.0" }, + { "name": "lcms2", "version": "6.2.0", "license": "MIT" }, + { "name": "lcms2-sys", "version": "4.0.7", "license": "MIT" }, { "name": "lexical-core", "version": "1.0.6", "license": "MIT OR Apache-2.0" }, { "name": "lexical-parse-float", "version": "1.0.6", "license": "MIT OR Apache-2.0" }, { "name": "lexical-parse-integer", "version": "1.0.6", "license": "MIT OR Apache-2.0" }, @@ -2474,7 +2628,10 @@ { "name": "libduckdb-sys", "version": "1.10506.1", "license": "Apache-2.0 AND (Apache-2.0 OR GPL-2.0-or-later) AND BSD-2-Clause AND (BSD-2-Clause OR BSL-1.0) AND BSD-3-Clause AND BSL-1.0 AND MIT AND Unicode-DFS-2015 AND Zlib" }, { "name": "libgssapi", "version": "0.11.0", "license": "MIT" }, { "name": "libgssapi-sys", "version": "0.3.4", "license": "MIT" }, + { "name": "libloading", "version": "0.8.9", "license": "ISC" }, { "name": "libm", "version": "0.2.16", "license": "MIT" }, + { "name": "libseccomp", "version": "0.4.0", "license": "MIT OR Apache-2.0" }, + { "name": "libseccomp-sys", "version": "0.3.0", "license": "MIT OR Apache-2.0" }, { "name": "libsqlite3-sys", "version": "0.37.0", "license": "MIT" }, { "name": "linux-raw-sys", "version": "0.4.15", "license": "Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT" }, { "name": "linux-raw-sys", "version": "0.12.1", "license": "Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT" }, @@ -2491,6 +2648,8 @@ { "name": "md-5", "version": "0.10.6", "license": "MIT OR Apache-2.0" }, { "name": "md-5", "version": "0.11.0", "license": "MIT OR Apache-2.0" }, { "name": "memchr", "version": "2.8.3", "license": "Unlicense OR MIT" }, + { "name": "memfd", "version": "0.6.6", "license": "MIT OR Apache-2.0" }, + { "name": "memmap2", "version": "0.9.12", "license": "MIT OR Apache-2.0" }, { "name": "memoffset", "version": "0.9.1", "license": "MIT" }, { "name": "mio", "version": "1.2.3", "license": "MIT" }, { "name": "moka", "version": "0.12.16", "license": "(MIT OR Apache-2.0) AND Apache-2.0" }, @@ -2499,6 +2658,7 @@ { "name": "multiversion", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, { "name": "multiversion-macros", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, { "name": "multiversion_no_op", "version": "1.0.0", "license": "Apache-2.0 OR MIT" }, + { "name": "nix", "version": "0.30.1", "license": "MIT" }, { "name": "nu-ansi-term", "version": "0.50.3", "license": "MIT" }, { "name": "num", "version": "0.4.3", "license": "MIT OR Apache-2.0" }, { "name": "num-bigint", "version": "0.4.8", "license": "MIT OR Apache-2.0" }, @@ -2518,6 +2678,7 @@ { "name": "parking", "version": "2.2.1", "license": "Apache-2.0 OR MIT" }, { "name": "parking_lot", "version": "0.12.5", "license": "MIT OR Apache-2.0" }, { "name": "parking_lot_core", "version": "0.9.12", "license": "MIT OR Apache-2.0" }, + { "name": "paste", "version": "1.0.15", "license": "MIT OR Apache-2.0" }, { "name": "pastey", "version": "0.2.3", "license": "MIT OR Apache-2.0" }, { "name": "pbkdf2", "version": "0.12.2", "license": "MIT OR Apache-2.0" }, { "name": "pbkdf2", "version": "0.13.0", "license": "MIT OR Apache-2.0" }, @@ -2526,6 +2687,7 @@ { "name": "phf", "version": "0.13.1", "license": "MIT" }, { "name": "phf_shared", "version": "0.13.1", "license": "MIT" }, { "name": "pin-project-lite", "version": "0.2.17", "license": "Apache-2.0 OR MIT" }, + { "name": "piper", "version": "0.2.5", "license": "MIT OR Apache-2.0" }, { "name": "pkcs1", "version": "0.8.0-rc.4", "license": "Apache-2.0 OR MIT" }, { "name": "pkcs8", "version": "0.11.0", "license": "Apache-2.0 OR MIT" }, { "name": "portable-atomic", "version": "1.15.0", "license": "Apache-2.0 OR MIT" }, @@ -2547,6 +2709,8 @@ { "name": "rand_core", "version": "0.9.5", "license": "MIT OR Apache-2.0" }, { "name": "rand_core", "version": "0.10.1", "license": "MIT OR Apache-2.0" }, { "name": "rand_pcg", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, + { "name": "rayon", "version": "1.12.0", "license": "MIT OR Apache-2.0" }, + { "name": "rayon-core", "version": "1.13.0", "license": "MIT OR Apache-2.0" }, { "name": "redis", "version": "1.7.1", "license": "BSD-3-Clause" }, { "name": "ref-cast", "version": "1.0.27", "license": "MIT OR Apache-2.0" }, { "name": "ref-cast-impl", "version": "1.0.27", "license": "MIT OR Apache-2.0" }, @@ -2557,8 +2721,10 @@ { "name": "relm4-macros", "version": "0.11.0", "license": "Apache-2.0 OR MIT" }, { "name": "resolv-conf", "version": "0.7.6", "license": "MIT OR Apache-2.0" }, { "name": "rmcp", "version": "3.6.0", "license": "Apache-2.0 AND MIT" }, + { "name": "rmp", "version": "0.8.15", "license": "MIT" }, + { "name": "rmp-serde", "version": "1.3.1", "license": "MIT" }, { "name": "rsa", "version": "0.10.0-rc.18", "license": "MIT OR Apache-2.0" }, - { "name": "rust_xlsxwriter", "version": "0.99.1", "license": "MIT OR Apache-2.0" }, + { "name": "rust_xlsxwriter", "version": "0.102.0", "license": "MIT OR Apache-2.0" }, { "name": "rustc_version", "version": "0.4.1", "license": "MIT OR Apache-2.0" }, { "name": "rustc_version_runtime", "version": "0.3.0", "license": "MIT" }, { "name": "rustix", "version": "0.38.44", "license": "Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT" }, @@ -2617,6 +2783,7 @@ { "name": "sqlx-sqlite", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, { "name": "sse-stream", "version": "0.2.6", "license": "MIT OR Apache-2.0" }, { "name": "stable_deref_trait", "version": "1.2.1", "license": "MIT OR Apache-2.0" }, + { "name": "static_assertions", "version": "1.1.0", "license": "MIT OR Apache-2.0" }, { "name": "stringprep", "version": "0.1.5", "license": "MIT OR Apache-2.0" }, { "name": "strsim", "version": "0.11.1", "license": "MIT" }, { "name": "strum", "version": "0.27.2", "license": "MIT" }, @@ -2685,6 +2852,7 @@ { "name": "wyz", "version": "0.5.1", "license": "MIT" }, { "name": "x509-cert", "version": "0.2.5", "license": "Apache-2.0 OR MIT" }, { "name": "xxhash-rust", "version": "0.8.19", "license": "BSL-1.0" }, + { "name": "yeslogic-fontconfig-sys", "version": "6.0.1", "license": "MIT" }, { "name": "yoke", "version": "0.8.3", "license": "Unicode-3.0" }, { "name": "yoke-derive", "version": "0.8.2", "license": "Unicode-3.0" }, { "name": "zbus", "version": "5.19.0", "license": "MIT" }, @@ -2700,7 +2868,7 @@ { "name": "zerotrie", "version": "0.2.5", "license": "Unicode-3.0" }, { "name": "zerovec", "version": "0.11.8", "license": "Unicode-3.0" }, { "name": "zerovec-derive", "version": "0.11.6", "license": "Unicode-3.0" }, - { "name": "zip", "version": "8.6.0", "license": "MIT" }, + { "name": "zip", "version": "9.0.5", "license": "MIT" }, { "name": "zlib-rs", "version": "0.6.7", "license": "Zlib" }, { "name": "zmij", "version": "1.0.23", "license": "MIT" }, { "name": "zopfli", "version": "0.8.3", "license": "Apache-2.0" }, diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index 875e51f7f2..b8c8f52bed 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -43,6 +43,7 @@ ui/tls-section.ui ui/welcome-view.ui ui/workspace.ui + ui/workspace-shell.ui icons/scalable/actions/table-relation-symbolic.svg icons/scalable/actions/statement-pending-symbolic.svg icons/scalable/actions/document-modified-symbolic.svg diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index 63bef131a5..083348479f 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -86,6 +86,19 @@ win.reopen-closed-tab + + + Switch to Recent Tab + Hold Ctrl and press E again to go further back + win.switch-recent-tab + + + + + View Open Tabs + win.show-tabs + + Next Tab diff --git a/linux/data/resources/style.css b/linux/data/resources/style.css index 4660c5ee3f..f9f58bcffe 100644 --- a/linux/data/resources/style.css +++ b/linux/data/resources/style.css @@ -70,6 +70,43 @@ columnview > listview > row > cell > tp-grid-cell > text:focus-within { background: #6f8396; } +/* A connection's icon in the window's tree, in the connection's colour. */ +.tp-connection-icon.tp-tag-blue { + color: #3584e4; +} + +.tp-connection-icon.tp-tag-teal { + color: #2190a4; +} + +.tp-connection-icon.tp-tag-green { + color: #3a944a; +} + +.tp-connection-icon.tp-tag-yellow { + color: #c88800; +} + +.tp-connection-icon.tp-tag-orange { + color: #ed5b00; +} + +.tp-connection-icon.tp-tag-red { + color: #e62d42; +} + +.tp-connection-icon.tp-tag-pink { + color: #d56199; +} + +.tp-connection-icon.tp-tag-purple { + color: #9141ac; +} + +.tp-connection-icon.tp-tag-slate { + color: #6f8396; +} + /* A tag beside a connection's name: tinted with its colour, the name in the text colour, so it reads in both styles. */ .tp-tag-pill { diff --git a/linux/data/resources/ui/browse-tab.ui b/linux/data/resources/ui/browse-tab.ui index a997e126c5..53d43a0eb7 100644 --- a/linux/data/resources/ui/browse-tab.ui +++ b/linux/data/resources/ui/browse-tab.ui @@ -95,7 +95,7 @@ - + list-add-symbolic diff --git a/linux/data/resources/ui/row-detail-pane.ui b/linux/data/resources/ui/row-detail-pane.ui index 6c49dfaf69..cc1c2f8c1d 100644 --- a/linux/data/resources/ui/row-detail-pane.ui +++ b/linux/data/resources/ui/row-detail-pane.ui @@ -160,6 +160,19 @@ + + + + false + Pick a Value + + Pick a Value + + + + x-office-calendar-symbolic @@ -184,6 +197,110 @@ + + + preferences-system-time-symbolic + false + Pick a Time + + Pick a Time + + + + + clock_set + + + 6 + 6 + 6 + 6 + 6 + + + vertical + true + true + 0.5 + true + + + 23 + 1 + 6 + + + + Hour + + + + + + : + + + + + vertical + true + true + 0.5 + true + + + 59 + 1 + 10 + + + + Minute + + + + + + : + + + + + vertical + true + true + 0.5 + true + + + 59 + 1 + 10 + + + + Second + + + + + + Set + center + 6 + + + + + + + + + view-more-symbolic @@ -199,13 +316,23 @@ + + + false + scale-down + 6 + 12 + 12 + Image preview + + true - + never 120 diff --git a/linux/data/resources/ui/structure-tab.ui b/linux/data/resources/ui/structure-tab.ui index 54dc15c5c6..1acac84b13 100644 --- a/linux/data/resources/ui/structure-tab.ui +++ b/linux/data/resources/ui/structure-tab.ui @@ -239,6 +239,64 @@ + + + + options + Options + preferences-system-symbolic + false + + + true + + + vertical + 18 + 12 + 12 + 6 + 12 + + + Defaults + Columns added later take these. Existing columns keep their own unless they are converted. + + + Character Set + + + + + Collation + + + + + Convert existing columns + Rewrites the text in every column. TEXT can grow to MEDIUMTEXT, and the table is copied while it runs. + + + + + + + + A value at or below the highest one in use becomes that value plus one. + + + Next Auto Increment + digits + + + + + + + + + + sql diff --git a/linux/data/resources/ui/workspace-shell.ui b/linux/data/resources/ui/workspace-shell.ui new file mode 100644 index 0000000000..040ecb5aee --- /dev/null +++ b/linux/data/resources/ui/workspace-shell.ui @@ -0,0 +1,242 @@ + + + + + + diff --git a/linux/data/resources/ui/workspace.ui b/linux/data/resources/ui/workspace.ui index 68b7df06eb..0d52f95192 100644 --- a/linux/data/resources/ui/workspace.ui +++ b/linux/data/resources/ui/workspace.ui @@ -2,261 +2,60 @@ + + + + vertical + + + + false + Retry + win.refresh-tables + + + + + + false + Not every key is listed. Search to narrow the list. + + + diff --git a/linux/deny.toml b/linux/deny.toml index a0809ee733..fee74fc4d7 100644 --- a/linux/deny.toml +++ b/linux/deny.toml @@ -9,9 +9,6 @@ yanked = "deny" unmaintained = "workspace" ignore = [ { id = "RUSTSEC-2023-0071", reason = "rsa is reached only through sqlx-mysql, which encrypts the MySQL password with the server's public key; no private-key operation runs in process, and OpenSSH signs SSH identities. The rsa ban below holds that scope. See docs/decisions/0009-crypto-provider-and-sources.md." }, - { id = "RUSTSEC-2026-0367", reason = "zip 8.6.0 comes only through rust_xlsxwriter 0.99.1, which writes archives and never reads one; the advisory is in ZipArchive::extract and ZipStreamReader::extract, which nothing here calls (core's export test reads entries by name). Drop it once a rust_xlsxwriter release takes zip 9 (https://github.com/jmcnamara/rust_xlsxwriter/issues/205)." }, - { id = "RUSTSEC-2026-0368", reason = "zip 8.6.0 comes only through rust_xlsxwriter 0.99.1, which writes archives and never reads one; the advisory is in ZipArchive::extract and ZipStreamReader::extract, which nothing here calls (core's export test reads entries by name). Drop it once a rust_xlsxwriter release takes zip 9 (https://github.com/jmcnamara/rust_xlsxwriter/issues/205)." }, - { id = "RUSTSEC-2026-0369", reason = "zip 8.6.0 comes only through rust_xlsxwriter 0.99.1, which writes archives and never reads one; the advisory is in ZipArchive::extract and ZipStreamReader::extract, which nothing here calls (core's export test reads entries by name). Drop it once a rust_xlsxwriter release takes zip 9 (https://github.com/jmcnamara/rust_xlsxwriter/issues/205)." }, ] [licenses] diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index fae5507267..b61ee2f9ed 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -165,6 +165,7 @@ The `Connection` is a workspace's link to one server: the pool behind the sideba | `link_state`, `lost` | A `LinkWatch` shared by every call. `lost()` resolves when the link is gone: a failed probe, or a failed call that a check outside the pool confirmed. One dead backend in the pool does not resolve it, so the session monitor can act on it. | | `probe` | Whether the server answers. It never waits for a pool slot: with every slot busy it opens a connection of its own. | | `list_schemas`, `list_objects`, `fetch_columns`, `fetch_indexes`, `fetch_foreign_keys`, `fetch_outbound_foreign_keys`, `fetch_inbound_foreign_keys`, `fetch_table_definition`, `row_identity`, `table_estimate` | Catalogue reads. `fetch_table_definition` reads columns, keys and dependents in one call so they come from the same moment. `fetch_outbound_foreign_keys` and `fetch_inbound_foreign_keys` return the keys that definition lists, read alone for a table tab's cell menu. Each key names its other table as the sidebar does, and its columns as declared. `parent_compares_alike` and `child_compares_alike` are false when a value of one side, as text read under the other side's column type, can find other rows than the key matches: the two sides differ in type or collation and the engine compares them through a conversion. | +| `fetch_character_sets` | The character sets the server offers, each with its default collation and every collation it takes, for the Structure tab's pickers. A read of its own after the definition, so its failure leaves the rest of the tab working. The default answers `Unsupported` (`UnsupportedFeature::CharacterSets`). | | `query_bound` | A generated read that keeps at most `limit` rows and reads one more to set `truncated`. | | `count_rows` | A generated count. | | `stream_read` | Every row of a read into a `ResultSink`, with no row limit, on a connection of its own, held read-only for its whole length. | @@ -338,13 +339,13 @@ A table whose scan has no order to page by, as a CQL scan in token order has non | Method | What it does | |---|---| -| `capabilities` | The table-level `DdlCapabilities`. The tab offers only what is on. `table_capabilities` narrows them for one table, and defaults to `capabilities`. | +| `capabilities` | The table-level `DdlCapabilities`. The tab offers only what is on. `table_capabilities` narrows them for one table, and defaults to `capabilities`. `column_collations` offers a column's character set and collation from `fetch_character_sets`, and `table_options` the table's default pair, a convert of every column to it, and the next AUTO_INCREMENT value, for a table whose definition carries `TableOptions`. Both are off by default. | | `sql_dialect` | The `SqlDialect` that quotes, binds and parses for the plans. Return `self`. | | `column_capabilities` | A `Permission` per change to one column. Start from `ColumnCapabilities::standard`, which refuses a restatement that would drop a generated expression or an unmodeled attribute, a default on a numbered column, a drop of a column another table's foreign key points at, and any primary-key change while a foreign key points at the key. Add the engine's own refusals on top, each with its `RefusalReason`. An engine that writes the whole column again, as MySQL does for every change and SQL Server for a type or nullability change, refuses those changes where it cannot write the column back as it is. One that restates a generated column's expression checks `ColumnCapabilities::modeled` in place of `restatement`. | | `index_removal` | Whether one index of the table can be dropped where `drop_index` allows it. The default allows every one; DuckDB refuses the index a UNIQUE key owns, which no statement drops. | | `type_suggestions`, `default_column_type`, `referential_actions`, `references_other_schemas` | What the tab's type menu, new columns and foreign-key dialog offer. Every suggestion has to parse through `parse_type`. The last two have defaults: every action, and keys across schemas. | | `plan_create_table(&TableDraft)` | Starts with `draft.check_create(self)?`. Foreign keys go inside CREATE TABLE. | -| `plan_alter_table(&TableDefinition, &TableChanges)` | Starts with `changes.check(self, original)?`, then writes the changes in the order the engine needs: one combined ALTER TABLE where the engine takes one, and every statement after a table rename against the new name. A table rename keeps the table in its schema. An engine that renames one column per statement takes them from `changes.ordered_renames(original)`, which runs a swap through a temporary name. It ends with `DdlPlan::for_changes`, which refuses a change that came to no statement. | +| `plan_alter_table(&TableDefinition, &TableChanges)` | Starts with `changes.check(self, original)?`, then writes the changes in the order the engine needs: one combined ALTER TABLE where the engine takes one, and every statement after a table rename against the new name. A table rename keeps the table in its schema. An engine that renames one column per statement takes them from `changes.ordered_renames(original)`, which runs a swap through a temporary name. A `TableOptionsChange` names the character set and collation together, and a convert runs after every drop and before every added column. `changes.check` refuses a convert beside a change to an existing column or to the primary key ([MySQL](drivers/mysql.md#table-options)). It ends with `DdlPlan::for_changes`, which refuses a change that came to no statement. | | `plan_drop_table` | The default writes `DROP TABLE [IF EXISTS]
[CASCADE]`. | | `create_table_script` | The server's own CREATE statement where the engine keeps one, and `NoTableSource` when the definition carries none; the default writes it from the definition. | diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md index 9f48b95a78..a5dd5ed84c 100644 --- a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -29,6 +29,7 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` - The structure tab's controls follow the capabilities, so a change the engine cannot make is never offered. - MySQL writes a column again whole for any change to it, its comment included, and SQL Server's ALTER COLUMN carries nothing forward either, so both restate every attribute the definition models where the new type still holds it. On MySQL that is the character set and collation, a generated column's expression, ON UPDATE, INVISIBLE and the SRID; MariaDB adds its REF_SYSTEM_ID and the column's own CHECK, which it keeps apart from the table's. On SQL Server it is the collation, SPARSE, the nullability and a data mask, which goes back on in a statement of its own. - A PostgreSQL type change names the column's collation when the new type holds text, since SET DATA TYPE otherwise takes the type's default. +- MySQL's table options join the combined ALTER: the default character set and collation, always named together, and the next AUTO_INCREMENT value, which a MODIFY in the same ALTER that turns numbering on keeps. A convert of every column runs as a statement of its own after every drop and before the added columns. It never shares a save with a change to an existing column or to the primary key (`RefusalReason::ColumnsConverted`): a MODIFY after it would restate a type it widened, and a key it converts before the key moves can outgrow the index limit. - A column with an attribute this build does not model, or one whose name the restatement cannot write, refuses every change that would restate it. - MariaDB names a column's CHECK constraint after the column it was written on and keeps that name through a rename, so the check is placed by the column line of SHOW CREATE TABLE it ends. A check no line ends with refuses the restatement of every column without one. - The server reads a restated expression against the names the same ALTER gives, so a MySQL restatement whose CHECK, generated expression or default names a column renamed in the same change is refused, and the rename is saved on its own first. diff --git a/linux/docs/decisions/0012-window-per-session.md b/linux/docs/decisions/0012-window-per-session.md index 72de86b1ca..e1f6fdafc8 100644 --- a/linux/docs/decisions/0012-window-per-session.md +++ b/linux/docs/decisions/0012-window-per-session.md @@ -1,6 +1,6 @@ # 0012: One window per connection session, built from gtk-rs subclasses -- **Status**: Accepted +- **Status**: Accepted; what a window holds superseded by 0028 - **Date**: 2026-10-14 ## Context diff --git a/linux/docs/decisions/0028-sessions-share-a-window.md b/linux/docs/decisions/0028-sessions-share-a-window.md new file mode 100644 index 0000000000..ca1ea99162 --- /dev/null +++ b/linux/docs/decisions/0028-sessions-share-a-window.md @@ -0,0 +1,41 @@ +# 0028: A window holds several sessions in one tab view + +- **Status**: Accepted +- **Date**: 2026-10-28 + +## Context + +Under [0012](0012-window-per-session.md) a window holds at most one connection session. Working on two databases means two windows, and the saved connections and their groups show only on the picker, which goes once a window connects. Users who come from clients that list every connection in one tree, with tabs of several connections side by side, ask for that flow. + +0012 rejected a window that swaps one session in place, because every part that read the session had to be reset at each switch and a late reply could land in the next session. It also rejected one session service shared by every window. + +## Decision + +A `SessionWindow` holds a list of open sessions, each under its own `SessionClaim`, and a list of connects in progress, each with its own cancel token. The page it shows follows from those two lists: the `WorkspaceShell` while any session is open, else the first connect's progress, else the last failure, else the picker. Every connect, from the picker, the sidebar, the connect dialog, a file, a restore or MCP, takes the same path. + +The shell is what a window has once: the header bars, the sidebar, the one `AdwTabView` every session's tabs share, the assistant's column and the toasts. Each session keeps its own `Workspace`, which holds what it held before, and a tab never changes session. The sidebar is a tree of the saved connections in their groups, with the schemas and tables of each open session under its connection. + +## Rationale + +- What 0012 protects still holds one level down. A workspace never changes session, so it has nothing to reset, and its teardown order is the same. Tabs already took their session when they were built. +- Sessions are not shared across windows. The registry still keeps a key in one window, and a connection open elsewhere brings that window and that session forward. +- The session of the tab in front speaks for the window. The shell's `front` property follows the tab view's selection, and the header bars, the banners, the assistant's pane and the window's actions read it through bindings, so a change of tab never enters a workspace that is handling input. +- A failure while other sessions are open is an event in this window, so it is a toast with a Details button rather than a page or a new window. The GNOME HIG: "Never pop up a dialog window unexpectedly." +- A closed connection is a leaf in the tree, and activating it connects, as a GNOME Files sidebar row mounts a volume. A connection gets its schemas once its session joins, so neither a drag hovering over a row nor an expander starts a network connect. +- A search is worked out off the GTK thread and shows what it finds as a flat list, one section per schema in a `GtkFlattenListModel`, so typing over 50,000 tables makes objects only for the rows on screen. Opening a schema in the tree makes one light object per table, since `GtkTreeListModel` reads every child of a row it opens. + +## Consequences + +- This supersedes 0012 for what a window holds. The application, the subclasses and the input queues it decided stay. +- A disconnect asks only that session's prompts and closes its tabs. When the last session goes, a connect still running shows on the connecting page and joins when it lands; with none, the window returns to the picker. The window's close asks one Save Changes? across every session, and Cancel keeps them all. +- libadwaita 1.9 has no tab groups, so any `AdwTabBar` in the process accepts a dropped tab. The shell sends a tab dropped from another window back to its own, and a tab dropped outside every window stays in its window. +- Each tab names its connection, as "orders (prod)", while another connection has a tab in the same bar. +- The restore record groups the keys by window, so a restore opens each window with the sessions it held. + +## Alternatives considered + +**A window per session with a strip to switch between them.** Keeps 0012 whole, but the tabs of two connections are never side by side. + +**A tab bar per session in one window.** Smaller, but switching connection hides the other connection's tabs. + +**A failed connect beside the sessions on a failed page in a window of its own.** Keeps the page's remedies, but a window opens seconds after the user moved on. The remedies are dialogs over this window already. diff --git a/linux/docs/decisions/0029-images-decode-in-glycin.md b/linux/docs/decisions/0029-images-decode-in-glycin.md new file mode 100644 index 0000000000..270ea82b05 --- /dev/null +++ b/linux/docs/decisions/0029-images-decode-in-glycin.md @@ -0,0 +1,32 @@ +# 0029: Images from the database decode in glycin's sandbox + +- **Status**: Accepted +- **Date**: 2026-10-29 + +## Context + +Row Details shows the picture of a binary value that holds an image. Those bytes are written by whoever else uses the database, so they are untrusted input (`.oss-scanner/threat_model.md`). GTK's own loaders run in the app's process, and `gdk_texture_new_from_bytes` says it "should not be used with untrusted data. Use a proper image loading framework such as libglycin" (GTK 4.22, `gdk/gdktexture.c`). GNOME 50 ships glycin: gdk-pixbuf 2.44 loads through it, and Loupe and Fractal decode untrusted images with it. + +## Decision + +The app decodes a value's picture with the glycin crate 3.x, the GNOME 50 release, which runs each decode in a sandboxed loader process. + +## Rationale + +- **Out of process.** A loader that a crafted image crashes or takes over runs under bubblewrap, or `flatpak-spawn --sandbox` in Flatpak, with seccomp filters, a memory limit and no network. +- **The platform's loaders.** The formats are the ones the installed glycin loaders read, the same set the rest of the desktop shows. +- **Fits the workspace.** The crate's `tokio` feature runs on the app's runtime and the zbus it already links; `gdk4` hands back a `gdk::Texture`. + +## Consequences + +- The build links libseccomp and lcms2 (`meson.build`, `build-aux/debian/control`, `build-aux/rpm/tablepro.spec`). The GNOME SDK has both, since it builds libglycin. +- The picture needs the glycin loaders at run time: in the GNOME runtime, a Recommends of the .deb and the .rpm, and a test dependency of both, since the picture tests decode. Without them Row Details shows the bytes and no picture. +- Where no sandbox can be made, as in a container that blocks namespaces or a Flatpak run by `flatpak-builder`, glycin's own default decodes without one. The pane shows no picture there instead. The tests allow it, since CI runs them in containers. +- glycin 3.x speaks the loaders' `2+` protocol. Moving to glycin 4 follows the platform floor ([0015](0015-gnome-50-platform-floor.md)). + +## Alternatives considered + +- **`gdk::Texture::from_bytes`**: decodes in process, which GTK documents against for untrusted data. +- **gdk-pixbuf**: sandboxed only where the distribution builds it with glycin, and its texture conversion is deprecated since GTK 4.20. +- **The image crate in process**: memory-safe decoders, but no process boundary or memory limit, and a second set of formats beside the desktop's. +- **No picture**: leaves the bytes of an image unreadable in Row Details. diff --git a/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md b/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md new file mode 100644 index 0000000000..f2a1d5d0b0 --- /dev/null +++ b/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md @@ -0,0 +1,38 @@ +# 0030: A query tab's database, and another database as a session of its own + +- **Status**: Accepted +- **Date**: 2026-10-30 + +## Context + +A session is one connection to one database ([0028](0028-sessions-share-a-window.md)). A query tab can run in another of the server's databases, through the editor bar or a `USE` the user types. Engines get there in different ways: MySQL, MariaDB and SQL Server move a live session with `USE`, ClickHouse names the database in every request, Cassandra moves its keyspace, MongoDB's shell keeps a current database, and PostgreSQL binds the database when the connection opens. The window's sidebar and the completion catalog read the database the connection opened. + +[0012](0012-window-per-session.md) rejected a session that changes in place under its parts, and 0028 keeps a tab on the session it was built with. + +## Decision + +A query tab keeps its database, and its own editor session goes there. `EditorSession::use_database` moves a live session by the engine's own means; where the engine binds the database at connect, the driver refuses it and the tab gets an editor session opened in that database (`EditorSessionOptions::database`). A session reports the database a `USE` left it in, and the tab follows. The tab never changes session: its workspace, Safe Mode and teardown stay its connection's. + +What the window shows of another database is a sibling session, `Session::open_in_database`: the same server and login, opened as a reconnect is. It joins the window with a workspace and a sidebar row of its own, from Open Database… on a connection's row, or when a tab moves to that database. Completion in a moved tab reads the sibling's catalog. + +## Rationale + +- **A tab keeps what it built.** A `USE` keeps the tab's temporary tables and settings. The database menu waits while the tab holds a transaction, so a move never rolls one back. +- **The tab's session does not wait on the window's.** A PostgreSQL tab opens in its database over its own connection, with the secret the connection already holds, so a restored tab or a lost connection reopens there without a sibling. +- **The catalog is the database's own.** A sibling lists its own database, so the sidebar and completion are right on engines whose databases hold schemas. Until it opens, completion reads the connection's catalog where the server's databases are its schemas (MySQL, ClickHouse, Cassandra, MongoDB) and offers no tables where they are not. +- **No session changes under its parts.** A sibling is a session like any other, so 0012 and 0028 hold. + +## Consequences + +- A sibling opens with the stored secret and never prompts; one that cannot open says so in a toast and the tab still runs in its database. +- The registry keys a sibling by its connection and database, so a database opens once per window. It goes when its connection disconnects. +- A tab names its database in its title when it is not the connection's. +- The workspace record keeps a tab's database, and a restore reopens a connection's open databases beside it once it has joined. + +## Alternatives considered + +**One session that follows the tab in front.** Every part reading the session would reset at each tab switch, which 0012 rejected. + +**Every move opens a new editor session.** One path for every engine, but a `USE` engine would lose its temporary tables and settings at each move. + +**A PostgreSQL tab runs on its sibling's connection.** The tab would depend on a window session the user can close, and a password typed only for the connection could not open it. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 7a5952ffd1..734ee04abe 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -59,7 +59,7 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0008](0008-flatpak-sandbox-permissions.md) | Flatpak sandbox permissions | Accepted; the Flathub submission deferred by 0018 | Network, display, home and removable drives for SQLite, the SSH agent and the Kerberos KCM socket; portals for the rest; two Flathub lint exceptions. | | [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted; sources and the PostgreSQL and ClickHouse TLS setup superseded by 0016, the ring ban amended by 0023 | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | | [0010](0010-ddl-plans-and-table-definitions.md) | DDL plans against table definitions | Accepted | Each engine plans structure changes through its `DdlDialect` against the table's definition; the UI offers only what the plan accepts. | -| [0012](0012-window-per-session.md) | One window per connection session, built from gtk-rs subclasses | Accepted | One `TableProApplication` per profile through D-Bus uniqueness; a window holds at most one session and returns to the picker on disconnect; the application, the window and its workspace are subclasses, not Relm4 components. | +| [0012](0012-window-per-session.md) | One window per connection session, built from gtk-rs subclasses | Accepted; what a window holds superseded by 0028 | One `TableProApplication` per profile through D-Bus uniqueness; a window holds at most one session and returns to the picker on disconnect; the application, the window and its workspace are subclasses, not Relm4 components. | | [0013](0013-openssh-client-transport.md) | SSH through the system OpenSSH client | Accepted; the forced options amended by 0019, the TCP port amended by 0023 | ControlMaster per connection, Unix-socket forwards, prompts through an askpass helper. | | [0014](0014-lossless-values-and-dialects.md) | Lossless values and engine dialects | Accepted | A value reaches the user and goes back without loss; every statement is spelled by the dialect of the connection that runs it. | | [0015](0015-gnome-50-platform-floor.md) | GNOME 50 platform floor | Accepted; the runtime row amended by 0018 | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88; AdwShortcutsDialog replaces the shortcuts window. | @@ -75,6 +75,9 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0025](0025-bundled-duckdb.md) | DuckDB built from the duckdb crate's bundled sources | Accepted | No PPA or COPR target but Ubuntu 26.10 ships libduckdb, so DuckDB 1.5.6 is compiled from the crate on every channel, with no extension fetched or loaded on its own. | | [0026](0026-documents-in-the-table-tab.md) | Document collections open in the table tab, and their statements are shell calls | Accepted | A document engine states `DataModel::Document`, lists collections as tables, opens them in the table tab, and every builder writes a mongosh call with bound values through a document arm chosen by the grammar. | | [0027](0027-one-data-grid.md) | One data grid on GtkColumnView, with rows and a focused cell | Accepted | Every surface shows rows in one `DataGrid`: row selection plus a focused cell, stable row slots, a row number column, values as text and a row detail pane. | +| [0028](0028-sessions-share-a-window.md) | A window holds several sessions in one tab view | Accepted | A `WorkspaceShell` per window shares one tab view among its sessions, each with its own workspace; the sidebar is a tree of the saved connections with each open session's schemas and tables. | +| [0029](0029-images-decode-in-glycin.md) | Images from the database decode in glycin's sandbox | Accepted | Row Details decodes a value's picture with the glycin crate 3.x, in a sandboxed loader process, never with GTK's in-process loaders. | +| [0030](0030-a-database-is-a-session-of-its-own.md) | A query tab's database, and another database as a session of its own | Accepted | A query tab's own editor session moves to its database through the driver, or opens there where the engine binds it at connect; another database the window shows is a sibling session with its own workspace and sidebar row. | 0011 is not used. It was reserved for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index cb1f415978..3009052af2 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -96,7 +96,7 @@ A call that timed out or lost its connection is checked before the link is decla `ReadOnlyEnforcement::SessionDefault`: `SET SESSION TRANSACTION READ ONLY` on every session. Saves are refused before anything is sent, and a write through generated SQL fails with 1792. At Safe Mode Read-Only the app refuses every editor statement that does not read before it is sent, `SET TRANSACTION READ WRITE` included; a function a `SELECT` calls is the server's to refuse. For a guarantee, connect with a login that has no write privileges. -An export, on any connection, runs in `START TRANSACTION READ ONLY` and refuses user SQL that Safe Mode would not count as a read (`tablepro_core::sql_syntax::statement_access`). +An export, on any connection, runs in `START TRANSACTION READ ONLY` and refuses user SQL that Safe Mode would not count as a read (`tablepro_core::sql_syntax::statement_access`). The text protocol runs every statement a text holds, so user SQL is first prepared, which the server parses with multiple statements off: a text it reads as two fails there, and one it parses but does not prepare (1295, such as some `SHOW`) still runs. Safe Mode reads text every way the client or a server may read it, and the strictest reading stands (`tablepro_core::sql_syntax::script::ScriptPlan::server_readings`): @@ -126,6 +126,27 @@ Whether a write can be undone depends on the table's storage engine, not on the - MySQL cannot suspend foreign keys for one batch, so a batch that asks for it is refused before anything is sent. - There is nothing to ask the server about a lost `COMMIT` (`CommitResolutionSupport::Unavailable`). `CommitOutcomeUnknown` carries no token, and the user reloads to see what landed. +## Table options + +The structure tab edits a table's default character set and collation, converts every column to them, sets the next AUTO_INCREMENT value, and picks a column's own character set and collation (`column_collations`, `table_options`). + +| Read | Source | +|---|---| +| Default character set and collation | `information_schema.TABLES.TABLE_COLLATION`, which is live on both servers. The character set is the collation's first word. | +| Next AUTO_INCREMENT, MySQL | The options line of SHOW CREATE TABLE. `TABLES.AUTO_INCREMENT` is cached for up to a day (`information_schema_stats_expiry`). | +| Next AUTO_INCREMENT, MariaDB | `TABLES.AUTO_INCREMENT`, which is live. `NO_TABLE_OPTIONS`, part of `sql_mode = ORACLE`, prints SHOW CREATE TABLE without its options line. | +| Character sets | `information_schema.CHARACTER_SETS`, with collations from `COLLATIONS`, or on MariaDB 10.10 and later from `COLLATION_CHARACTER_SET_APPLICABILITY.FULL_COLLATION_NAME`, told apart by that column. MariaDB lists a UCA 14 collation such as `utf8mb4_uca1400_ai_ci` in `COLLATIONS` once, with no character set. | + +- Neither server prints AUTO_INCREMENT= while the counter is 1, so a table with an AUTO_INCREMENT column and no clause reads as 1, and one without such a column has no counter. +- A view and an NDB table carry no options, so `table_capabilities` turns them off. +- A new default is `DEFAULT CHARACTER SET = cs COLLATE = coll` in the combined ALTER. Both clauses always go: a character set alone resets the collation to its default. Existing columns keep theirs, and a column added in the same ALTER takes the new one. +- A convert, `CONVERT TO CHARACTER SET cs COLLATE coll`, is a statement of its own after the `DROP FOREIGN KEY` and before the combined ALTER. A foreign key on a converted column refuses it (MySQL 3780, MariaDB 1832), and so can an index the wider characters make too long (1071), so a save that drops indexes or columns runs those drops in an ALTER ahead of the convert. +- A convert can widen a type, as a latin1 `TEXT` becomes `MEDIUMTEXT` in utf8mb4, and a MODIFY after it restates the old one. It also converts a primary key before a change in the same save moves it, which can push the key past the 3072-byte index limit. So a convert refuses a change to an existing column or to the primary key in the same save, and the tab says so before Save. Columns added in the same save come after it and take its pair unless the user picked one. +- The review lists a convert, and a column given another character set, as destructive. +- The counter is `AUTO_INCREMENT = n` in the combined ALTER. A counter set before a MODIFY that turns AUTO_INCREMENT on is lost, in the same ALTER it is kept. The server raises a value at or below the largest key to that key plus one, without a warning, so the tab reads the table again after a save. +- A column takes CHARACTER SET and COLLATE only when its type holds text: `CHAR`, `VARCHAR`, the `TEXT` types, `ENUM` and `SET`, in any spelling the type parser takes, such as `character varying(64)` or `VARCHAR (64)`. A national spelling (`NCHAR`, `NVARCHAR`) refuses CHARACTER SET, so none is written for it. +- A restated `ON UPDATE CURRENT_TIMESTAMP`, and a `CURRENT_TIMESTAMP` default as the catalogue read it, take the precision of the column's type, since MySQL refuses any other (1294, 1067). A default the user typed goes as written. + ## Editor sessions - The editor sends one statement per run. Text with several statements is split where the mysql client splits it, at each `;` or at the delimiter a `DELIMITER` line sets, and goes one statement per round trip. Sent whole, the server starts each next statement as soon as the last one's result is written, read or not, so a stop could not keep it from running. A `/*!` or `/*M!` comment is code to the split, as it is to the mysql client, so each `/*!40101 ... */;` line of a dump is a statement of its own and a `/*!50100 PARTITION BY ... */` stays with its table. diff --git a/linux/docs/drivers/postgresql.md b/linux/docs/drivers/postgresql.md index a8bca11a92..e65ccd2687 100644 --- a/linux/docs/drivers/postgresql.md +++ b/linux/docs/drivers/postgresql.md @@ -34,13 +34,14 @@ COMMIT - Reads always run in a `READ ONLY` transaction, also on a read-write connection. - `statement_timeout` is the nearer of the call's deadline and the workspace's 60 s. `lock_timeout` is 10 s, so a blocked edit reports the lock instead of a generic timeout. - `SET LOCAL` ends with the transaction, so nothing is left on a server connection a pooler shares. +- A catalog read runs under the session's own search_path, so names print as the user's session sees them, and resolves nothing through it: every function is written `pg_catalog.`, and every operator compares two values of one built-in type, cast where they differ (`attrelid = $1::text::regclass::oid`), or is written `OPERATOR(pg_catalog.)`. A lookalike another user creates on the path can then only tie with the built-in, and `pg_catalog`, searched first, wins the tie. `bare_calls` in `metadata/mod.rs` checks every catalog statement for an unqualified call. - Generated SQL runs on the unnamed statement (`query_typed_raw`, `execute_typed`) with typed parameters. Nothing is prepared under a name. - A connection whose flight did not end with an acknowledged COMMIT or ROLLBACK is taken out of the pool, because deadpool's recycle check (`RecyclingMethod::Verified`) passes inside a half-finished transaction. - A read that returns no rows still has its columns: the statement runs once more as a subquery right-joined to one row on a false condition, in a read-only transaction of its own. - A column of a type tokio-postgres does not know (an enum, `citext`, a domain) would make it prepare a type lookup under a name. Pages project such columns as text, so a read never does. - chrono ends at 262143 AD, and the server keeps dates to 5874897 AD and timestamps to 294276 AD. A value past chrono's end reads as the text the server would print for it, so the cell shows it rather than saying it cannot. A cell edit or a CSV import takes such text back, and it binds as text cast to the column's type. -`probe` takes a pool slot without waiting; when every slot is busy it opens a connection of its own and runs `SELECT 1`. `stream_read` opens a connection of its own, runs `START TRANSACTION READ ONLY` with no statement timeout, and refuses user SQL that would end that transaction (transaction control, `CALL`, `DO`) before sending anything. +`probe` takes a pool slot without waiting; when every slot is busy it opens a connection of its own and runs `SELECT 1`. `stream_read` opens a connection of its own, runs `START TRANSACTION READ ONLY` with no statement timeout, and refuses user SQL that would end that transaction (transaction control, `CALL`, `DO`) before sending anything. User SQL is first parsed with the extended protocol, whose Parse refuses a text that holds more than one statement, and then runs as a simple query, which splits text with the same parser and keeps every value as the server's text, so nothing can follow the read inside its transaction. The Parse prepares under a name, which is safe because the connection is the call's own. ## Stops and timeouts diff --git a/linux/docs/drivers/sqlite.md b/linux/docs/drivers/sqlite.md index a989064003..f5db580130 100644 --- a/linux/docs/drivers/sqlite.md +++ b/linux/docs/drivers/sqlite.md @@ -65,6 +65,8 @@ The workspace statement timeout (60 s) becomes a deadline the flag enforces. The `ReadOnlyEnforcement::EngineEnforced`: the file is opened read-only, so SQLite refuses every write, whatever the SQL (`SQLITE_READONLY`, reported as `ReadOnly(ServerRejected)`). Saves on a read-only connection are refused before anything is sent. Exports always open their connection read-only. +A read-only open still lets SQLite attach another file and write a copy with `VACUUM INTO`, and a pragma such as `foreign_keys` or `hard_heap_limit` changes the connection while SQLite compiles it, `EXPLAIN PRAGMA` included. The connection `stream_read` opens for user SQL (exports and MCP reads) therefore allows no attached database (`SQLITE_LIMIT_ATTACHED` 0), its authorizer denies ATTACH and every pragma but the reporting ones, and the text must prepare to one statement with nothing but comments after it, since sqlx runs every statement a text holds. + ## Writes `WriteAtomicity::Transactional` for row and schema changes. A save is one transaction that starts with `BEGIN IMMEDIATE`, which takes the write lock before the first probe reads anything. Grid saves, structure changes and imports all go through it. Another connection holding the lock shows up in one of two places, each as `Busy(FileLocked)` after the lock timeout, and the file keeps none of the save: diff --git a/linux/docs/regression-tests.md b/linux/docs/regression-tests.md index 587294c209..551251ee5c 100644 --- a/linux/docs/regression-tests.md +++ b/linux/docs/regression-tests.md @@ -39,6 +39,8 @@ Kinds: `unit` runs in the Meson unit suite, `gtk` in the gtk suite, `docker` in | F-002 | unit | `f002_late_reconnect_after_cancel_is_discarded_and_closed` | [crates/session/src/health/health_monitor.rs](../crates/session/src/health/health_monitor.rs) | | F-002 | gtk | `f002_cancel_connect_discards_late_attempt` | [crates/app/src/ui/session_window/tests/mod.rs](../crates/app/src/ui/session_window/tests/mod.rs) | | F-002 | gtk | `f002_late_show_create_reply_after_disconnect_is_dropped` | [crates/app/src/ui/session_window/tests/mod.rs](../crates/app/src/ui/session_window/tests/mod.rs) | +| F-002 | gtk | `f002_a_connect_beside_the_last_session_adopts_from_the_connecting_page_without_connecting_again` | [crates/app/src/ui/session_window/tests/sessions.rs](../crates/app/src/ui/session_window/tests/sessions.rs) | +| F-002 | gtk | `f002_stop_connecting_a_dialog_connect_beside_the_sessions_closes_its_late_connection` | [crates/app/src/ui/session_window/tests/sessions.rs](../crates/app/src/ui/session_window/tests/sessions.rs) | | F-002 | gtk | `f002_a_late_catalog_commit_after_the_watch_drops_is_not_delivered` | [crates/app/src/ui/workspace/sidebar/catalog_watch.rs](../crates/app/src/ui/workspace/sidebar/catalog_watch.rs) | | F-003 | unit | `f003_truncated_file_is_unavailable_and_upsert_leaves_bytes_identical` | [crates/storage/src/connections.rs](../crates/storage/src/connections.rs) | | F-003 | unit | `f003_a_newer_version_is_never_written` | [crates/storage/src/connections.rs](../crates/storage/src/connections.rs) | diff --git a/linux/docs/state-management.md b/linux/docs/state-management.md index 31e09303c8..34932f98a0 100644 --- a/linux/docs/state-management.md +++ b/linux/docs/state-management.md @@ -7,13 +7,13 @@ The `app` crate's application, windows, workspace and tabs are gtk-rs subclasses | State | Owner | Lives as long as | |---|---|---| | Driver registry, connector, deadlines, settings, storage, `Tasks`, `SessionRegistry`, query history, the open-sessions record | `AppServices`, built by `run()` and handed to every window | the process | -| The claim on a session's key | the window's `SessionPage` | the page | +| The claim on a session's key | the window's open session, connect in progress or failed page | that entry | | The connection | `Session`, shared by the window's page and its workspace | the page, until the teardown closes it | | Schemas, objects and columns | the session's `SchemaCatalog` | the session | | Tabs, pending edits, running saves | the `Workspace`: `ChangeTrackerRegistry`, `StructureTrackerRegistry`, `WorkspaceHandle` with its `SaveLedger` | the workspace | | Running exports | the workspace's `ExportLedger` | the workspace | | A tab's rows, filter and requests | the tab's model, in its `imp` struct | the tab | -| An editor tab's own connection | the tab's `EditorSessionSlot` | the tab, or until that connection is lost or the window's session is replaced | +| An editor tab's own connection | the tab's `EditorSessionSlot` | the tab, or until that connection is lost or its session closes | | What the last run returned | the editor tab's `RunLog`, shown by its `ResultsPane` | the next run | | The history list, its search and the pages read | the `HistoryDialog` and its `HistoryPaging` | the dialog | @@ -54,7 +54,7 @@ Windows share nothing but `AppServices`. CI fails on a `thread_local!` under `cr - Actions, signal handlers and background work send it a message, and `pump_inputs` hands the messages to the widget one at a time on the GTK thread, never inside the signal or action that sent one. - Shutdown aborts the pump, which closes the queue. A queued message that owns something, such as a connection that finished connecting, goes to the pump's leftover handler (`pump_inputs_with_leftovers`) and is closed rather than dropped. -- Window actions go on the window's own action map with `add_action_entries`, which GTK exports as `win`; CI fails on a group inserted as `win`. The workspace removes the actions it added when it shuts down. +- Window actions go on the window's own action map with `add_action_entries`, which GTK exports as `win`; CI fails on a group inserted as `win`. The `WorkspaceShell` adds the workspace actions once, sends each to the session in front, and removes them when it shuts down. - The workspace reports to the window through signals (`title-changed`, `dirty-changed`, `saves-in-flight-changed`, `work-completed`, `tabs-changed`), and the window turns each into one of its own inputs. A background read follows this shape, trimmed from `SessionWindow::on_restore`, which looks up the saved connection `id` names: diff --git a/linux/docs/storage.md b/linux/docs/storage.md index 34170f5dd2..d83da66845 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -17,7 +17,7 @@ The app's data goes to the store its shape calls for. The `storage` crate owns t `ConnectionStore` holds the saved-connection list, one store per storage root: -- `load_blocking`, `upsert_blocking`, `update_blocking`, `remove_blocking`, `touch_last_opened_blocking`, `import_blocking` and `export_blocking` run off the GTK thread. A change holds the store's lock across read, change and write, so two writers never lose an entry. `update_blocking` changes one entry as the list holds it at the write, so a last-opened stamp from another window in between is kept. +- `load_blocking`, `upsert_blocking`, `update_blocking`, `remove_blocking`, `touch_last_opened_blocking`, `preview_import_blocking`, `apply_import_blocking` and `export_blocking` run off the GTK thread. A change holds the store's lock across read, change and write, so two writers never lose an entry. `update_blocking` changes one entry as the list holds it at the write, so a last-opened stamp from another window in between is kept. - A write goes through `write_private_blocking` first and the cached list changes only once it landed, so a failed write leaves both as they were. - `subscribe` hands out a watch of `ConnectionListSnapshot`, so every picker sees every writer's change. - A file that cannot be read, or that another version wrote, leaves the store `Unavailable`, and it refuses every write until the user picks Reset (`reset_unreadable_blocking`), which moves the file aside. @@ -29,6 +29,8 @@ The app's data goes to the store its shape calls for. The `storage` crate owns t - The file's `tags` list holds every `ConnectionTag`: an `id`, a `name` (`ConnectionTagName`: trimmed, 1 to 32 characters, no control characters, cleaned on read as a group's is) and a `color` from the same palette as `color`. No two tags share a name in any case: `create_tag_blocking` and `update_tag_blocking` refuse one with `TagNameTaken`. - A connection's `tags` are ids from that list. Every write drops an id the list lacks and a repeated one, so `delete_tag_blocking` takes the tag off every connection in the same write, and a connection saved with a tag deleted meanwhile loses it. - `icon` is one of the `ConnectionIcon` names. The app draws each with a symbolic icon from the theme (`crates/app/src/ui/icons.rs`). +- A connection's `name` is stored and compared as the list shows it: control characters as spaces, characters that show as nothing (U+200B, U+FEFF) dropped, no blanks around it, so a name that looks like a saved one is the same name. +- An import is previewed first: `preview_import_blocking` works out every entry as it will be saved and writes nothing, the app shows each one with its host, and `apply_import_blocking` writes that result, or refuses with `ImportOutdated` when the list changed in between. An imported entry never takes a name another entry holds or held before the import, whatever order the file lists them in. - On import, a tag is matched by id, then by name in any case, and added only when neither matches, so importing a file twice leaves one tag of each name. A tag matched by id takes the file's colour, and its name unless another tag has it. A connection updated by an import takes the file's tags and icon. - An edit, a copy or a move to another group goes through the app's `SavedConnectionEditor`, which writes the keyring first. A refused secret stops before the list changes, and the secrets of a copy whose entry did not land are deleted again. Edits and copies run one at a time, so two windows editing one connection never leave the host of one with the password of the other. @@ -123,7 +125,7 @@ nothing reaches outside it. | `column_widths_file()` | `$XDG_CONFIG_HOME//column_widths.json`: each table's column layout (order, hidden columns, widths the user set), version 2; a version 1 file of widths alone is upgraded on read | | `filter_settings_file()` | `$XDG_CONFIG_HOME//filter_settings.json` | | `history_database()` | `$XDG_STATE_HOME//history.db` | -| `workspaces_dir()` | `$XDG_STATE_HOME//workspaces`: one file per session, and `open-sessions.json` | +| `workspaces_dir()` | `$XDG_STATE_HOME//workspaces`: one file per session, and `open-sessions.json`, version 2: the sessions open at the last quit as `windows`, one list of keys per window, windows in the order of their first recorded session and keys in the order each window adopted them; a version 1 file of `sessions` alone is upgraded on read to one window per session | | `drafts_dir()` | `$XDG_DATA_HOME//drafts` | | `snippets_dir()` | `$XDG_DATA_HOME//snippets`: the user's SQL editor snippets, made when Preferences opens it ([README](../README.md#snippets)) | | `crashes_dir()` | `$XDG_STATE_HOME//crashes`: crash reports ([error-handling.md](error-handling.md#crashes)) | @@ -248,7 +250,12 @@ script lexer: only with a given word (`GRANT ... IDENTIFIED`), and functions or engines that take a password (`dblink`, `OPENROWSET`, `s3`). A routine body is read for the statements inside it, whether dollar-quoted or, on - PostgreSQL, a plain string after `DO` or `AS`. The text is read every + PostgreSQL, a plain string after `DO` or `AS`. So is every string of a + script that hands the server text to run (T-SQL `EXEC` and + `sp_executesql`, MySQL `PREPARE ... FROM` and `EXECUTE IMMEDIATE`, + PL/pgSQL `EXECUTE`), since the text may reach them through a variable. + On SQL Server a named argument whose name holds `password` or `pwd` + withholds the entry whatever the procedure. The text is read every way the server may read it: with and without backslash escapes, and with each MySQL `/*!` comment run or skipped as each server version would. @@ -271,7 +278,7 @@ Turning the setting off keeps what is already there; Clear removes it. ## App preferences with `gio::Settings` -`data/app.tablepro.TablePro.gschema.xml.in` holds every app-wide preference and the window geometry. Meson installs it to `$datadir/glib-2.0/schemas` and compiles it; see [ADR 0007](decisions/0007-gsettings-preferences.md). +`data/app.tablepro.TablePro.gschema.xml.in` holds every app-wide preference, the window geometry and the newest release that has run. Meson installs it to `$datadir/glib-2.0/schemas` and compiles it; see [ADR 0007](decisions/0007-gsettings-preferences.md). The schema id is the application id and the path follows it: `app.tablepro.TablePro` at `/app/tablepro/TablePro/` for an installed build, `app.tablepro.TablePro.Devel` at `/app/tablepro/TablePro/Devel/` for a development one. Neither reads the other's settings. @@ -283,6 +290,7 @@ The schema id is the application id and the path follows it: `app.tablepro.Table | `default-page-size` | `u` | `1000` | 100 to 100000 | | `confirm-bulk-delete` | `b` | `true` | | | `restore-session` | `b` | `true` | | +| `last-seen-version` | `s` | empty | | | `mcp-server-enabled` | `b` | `false` | | | `mcp-server-port` | `u` | `23508` | 1024 to 65535 | | `ai-provider` | enum | `none` | `none`, `anthropic`, `openai`, `openai-compatible` | diff --git a/linux/docs/testing.md b/linux/docs/testing.md index fbdb149c0f..a0980d6b6d 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -190,9 +190,9 @@ SQLite and DuckDB pass `docker: false`, so the case runs in the unit suite, wher ### Large-catalog case -`crates/app/src/ui/workspace/tests/large_catalog.rs` fills PostgreSQL with 100 schemas of 500 tables, the default schema among them, and MySQL with one database of 50,000. It puts the sidebar and a SQL editor on screen over a session the app's own connector opens: +`crates/app/src/ui/workspace/tests/large_catalog.rs` fills PostgreSQL with 100 schemas of 500 tables, the default schema among them, and MySQL with one database of 50,000. It puts the connection tree and a SQL editor on screen over a session the app's own connector opens: -- The sidebar lists every schema's tables, read and indexed the way the workspace does, then its search takes `i`, `in` and an empty search. +- The tree lists every schema's tables under the open connection, read and indexed the way the workspace does, then its search takes `i`, `in` and an empty search with a schema of the catalog open. - The editor's completion opens after `FROM `, narrows as `i` is typed, and opens again after a schema's dot. - Each step has a time bound, from the action to its result on screen, and none may stall the main loop past 100 ms, which `StallProbe` (`crates/app/src/test_support/stall_probe.rs`) measures as the longest gap between runs of a high-priority timeout. The catalog may not raise the resident peak (`ResidentPeak`) past a memory bound. The bounds are constants at the top of the file. diff --git a/linux/meson.build b/linux/meson.build index f75a9b87ae..02fa04de3a 100644 --- a/linux/meson.build +++ b/linux/meson.build @@ -40,6 +40,9 @@ dependency('gtk4', version: '>= 4.22') dependency('libadwaita-1', version: '>= 1.9') dependency('gtksourceview-5', version: '>= 5.18') dependency('sqlite3', version: '>= 3.46') +# glycin, which decodes a value's picture in a sandbox. +dependency('libseccomp') +dependency('lcms2') if get_option('kerberos') dependency('mit-krb5-gssapi') diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 99f7eed284..a3af478f42 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -5,9 +5,9 @@ crates/app/src/application/accelerators.rs crates/app/src/application/app_actions.rs crates/app/src/application/app_services.rs crates/app/src/application/app_window_host.rs -crates/app/src/application/crash_notice.rs crates/app/src/application/debug_info.rs crates/app/src/application/imp.rs +crates/app/src/application/launch_notice.rs crates/app/src/application/legal_section.rs crates/app/src/application/mcp_bridge.rs crates/app/src/application/metainfo_release.rs @@ -88,9 +88,11 @@ crates/app/src/services/grid/edit_policy.rs crates/app/src/services/grid/find_matches.rs crates/app/src/services/grid/grid_column.rs crates/app/src/services/grid/grid_row_id.rs +crates/app/src/services/grid/hex_dump.rs crates/app/src/services/grid/mod.rs crates/app/src/services/grid/row_key_builder.rs crates/app/src/services/grid/row_snapshot.rs +crates/app/src/services/grid/selection_summary.rs crates/app/src/services/grid/test_columns.rs crates/app/src/services/grid/value_order.rs crates/app/src/services/history_availability.rs @@ -236,10 +238,12 @@ crates/app/src/ui/data_grid/copy/mod.rs crates/app/src/ui/data_grid/copy/tests.rs crates/app/src/ui/data_grid/edit/actions.rs crates/app/src/ui/data_grid/edit/cell_editor.rs +crates/app/src/ui/data_grid/edit/choice_menu.rs crates/app/src/ui/data_grid/edit/deletion.rs crates/app/src/ui/data_grid/edit/mod.rs crates/app/src/ui/data_grid/edit/paste.rs crates/app/src/ui/data_grid/edit/row_edits.rs +crates/app/src/ui/data_grid/edit/tests/choices.rs crates/app/src/ui/data_grid/edit/tests/mod.rs crates/app/src/ui/data_grid/edit/tests/paste.rs crates/app/src/ui/data_grid/edit/tests/rows.rs @@ -258,15 +262,19 @@ crates/app/src/ui/data_grid/mod.rs crates/app/src/ui/data_grid/pointer.rs crates/app/src/ui/data_grid/references/mod.rs crates/app/src/ui/data_grid/references/tests.rs +crates/app/src/ui/data_grid/reversed_list.rs crates/app/src/ui/data_grid/row_detail/calendar.rs +crates/app/src/ui/data_grid/row_detail/clock.rs crates/app/src/ui/data_grid/row_detail/fields.rs crates/app/src/ui/data_grid/row_detail/imp.rs crates/app/src/ui/data_grid/row_detail/mod.rs +crates/app/src/ui/data_grid/row_detail/picture.rs crates/app/src/ui/data_grid/row_detail/tests.rs crates/app/src/ui/data_grid/row_detail/value_editor.rs crates/app/src/ui/data_grid/row_json_dialog.rs crates/app/src/ui/data_grid/row_number_column.rs crates/app/src/ui/data_grid/selection.rs +crates/app/src/ui/data_grid/selection_summary.rs crates/app/src/ui/data_grid/server_sorter.rs crates/app/src/ui/data_grid/table_keys.rs crates/app/src/ui/data_grid/test_grid.rs @@ -279,6 +287,7 @@ crates/app/src/ui/data_grid/tests/menus.rs crates/app/src/ui/data_grid/tests/mod.rs crates/app/src/ui/data_grid/tests/selection.rs crates/app/src/ui/data_grid/tests/sorting.rs +crates/app/src/ui/data_grid/tests/summary.rs crates/app/src/ui/data_grid/tests/values.rs crates/app/src/ui/data_grid/tests/view.rs crates/app/src/ui/data_grid/tracked_values.rs @@ -421,6 +430,7 @@ crates/app/src/ui/host_key_failure.rs crates/app/src/ui/host_key_review.rs crates/app/src/ui/icons.rs crates/app/src/ui/import_dialog.rs +crates/app/src/ui/import_preview.rs crates/app/src/ui/key_tab/key_summary.rs crates/app/src/ui/key_tab/mod.rs crates/app/src/ui/key_tab/tests.rs @@ -448,15 +458,17 @@ crates/app/src/ui/result_pane/result_set_view.rs crates/app/src/ui/result_pane/result_summary.rs crates/app/src/ui/safe_mode.rs crates/app/src/ui/saved_queries_dialog.rs +crates/app/src/ui/session_target.rs crates/app/src/ui/session_window/application_notification_sink.rs crates/app/src/ui/session_window/connect_failed_page.rs +crates/app/src/ui/session_window/connect_failure_details.rs crates/app/src/ui/session_window/connect_failure_view.rs crates/app/src/ui/session_window/connect_flow.rs crates/app/src/ui/session_window/connecting_page.rs crates/app/src/ui/session_window/connections.rs -crates/app/src/ui/session_window/crash_notice.rs crates/app/src/ui/session_window/file_location_dialog.rs crates/app/src/ui/session_window/imp.rs +crates/app/src/ui/session_window/launch_notice.rs crates/app/src/ui/session_window/locate_file.rs crates/app/src/ui/session_window/mod.rs crates/app/src/ui/session_window/notification_request.rs @@ -473,12 +485,15 @@ crates/app/src/ui/session_window/session_window_input.rs crates/app/src/ui/session_window/tests/mod.rs crates/app/src/ui/session_window/tests/notification.rs crates/app/src/ui/session_window/tests/open_file.rs +crates/app/src/ui/session_window/tests/sessions.rs crates/app/src/ui/session_window/window_actions.rs crates/app/src/ui/session_window/work_notifier.rs crates/app/src/ui/shell/mod.rs crates/app/src/ui/shell/primary_menu_button.rs crates/app/src/ui/ssh_inputs.rs crates/app/src/ui/ssh_section.rs +crates/app/src/ui/structure_tab/character_set_choices.rs +crates/app/src/ui/structure_tab/character_set_fetch.rs crates/app/src/ui/structure_tab/column_handlers.rs crates/app/src/ui/structure_tab/columns.rs crates/app/src/ui/structure_tab/columns_input.rs @@ -517,6 +532,7 @@ crates/app/src/ui/structure_tab/structure_tab_widgets.rs crates/app/src/ui/structure_tab/table_choice_object.rs crates/app/src/ui/structure_tab/table_handlers.rs crates/app/src/ui/structure_tab/table_input.rs +crates/app/src/ui/structure_tab/table_options.rs crates/app/src/ui/structure_tab/tests.rs crates/app/src/ui/style_scheme_grid.rs crates/app/src/ui/style_scheme_resolver.rs @@ -541,18 +557,17 @@ crates/app/src/ui/workspace/imp.rs crates/app/src/ui/workspace/import.rs crates/app/src/ui/workspace/import_ledger.rs crates/app/src/ui/workspace/mod.rs +crates/app/src/ui/workspace/recent_tabs.rs crates/app/src/ui/workspace/references.rs -crates/app/src/ui/workspace/row_ops.rs crates/app/src/ui/workspace/saves.rs crates/app/src/ui/workspace/session_watches.rs -crates/app/src/ui/workspace/shell.rs crates/app/src/ui/workspace/sidebar/catalog_watch.rs +crates/app/src/ui/workspace/sidebar/connection_tree.rs crates/app/src/ui/workspace/sidebar/key_listing.rs crates/app/src/ui/workspace/sidebar/mod.rs crates/app/src/ui/workspace/sidebar/object_key.rs crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs crates/app/src/ui/workspace/sidebar/sidebar_event.rs -crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs crates/app/src/ui/workspace/sidebar/sidebar_index.rs crates/app/src/ui/workspace/sidebar/sidebar_list.rs crates/app/src/ui/workspace/sidebar/sidebar_list_imp.rs @@ -560,9 +575,12 @@ crates/app/src/ui/workspace/sidebar/sidebar_menu.rs crates/app/src/ui/workspace/sidebar/sidebar_model.rs crates/app/src/ui/workspace/sidebar/sidebar_object.rs crates/app/src/ui/workspace/sidebar/sidebar_object_imp.rs -crates/app/src/ui/workspace/sidebar/sidebar_row_factory.rs crates/app/src/ui/workspace/sidebar/sidebar_view.rs +crates/app/src/ui/workspace/sidebar/store_sync.rs crates/app/src/ui/workspace/sidebar/tests.rs +crates/app/src/ui/workspace/sidebar/tree_menu.rs +crates/app/src/ui/workspace/sidebar/tree_node.rs +crates/app/src/ui/workspace/sidebar/tree_row.rs crates/app/src/ui/workspace/status_pages.rs crates/app/src/ui/workspace/structure.rs crates/app/src/ui/workspace/structure_target.rs @@ -579,6 +597,8 @@ crates/app/src/ui/workspace/tests/keys.rs crates/app/src/ui/workspace/tests/large_catalog.rs crates/app/src/ui/workspace/tests/mod.rs crates/app/src/ui/workspace/tests/references.rs +crates/app/src/ui/workspace/tests/sessions.rs +crates/app/src/ui/workspace/tests/shell.rs crates/app/src/ui/workspace/tests/tab_title.rs crates/app/src/ui/workspace/transaction_banner.rs crates/app/src/ui/workspace/unsaved_icon.rs @@ -586,6 +606,8 @@ crates/app/src/ui/workspace/window_tabs.rs crates/app/src/ui/workspace/window_title.rs crates/app/src/ui/workspace/workspace_actions.rs crates/app/src/ui/workspace/workspace_persistence.rs +crates/app/src/ui/workspace/workspace_shell.rs +crates/app/src/ui/workspace/workspace_shell_imp.rs crates/app/src/ui/workspace/workspace_tab_view.rs crates/app/src/ui/workspace/workspace_tabs.rs crates/app/src/ui/write_confirmation.rs @@ -691,4 +713,5 @@ data/resources/ui/ssh-section.ui data/resources/ui/structure-tab.ui data/resources/ui/tls-section.ui data/resources/ui/welcome-view.ui +data/resources/ui/workspace-shell.ui data/resources/ui/workspace.ui