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).
+
+
+
+
+
+
+
+
-**[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).
+
+
+
+
+
+
+
+
-**[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)。
+
+
+
+
+
+
+
+
-**[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