Compare commits

...
Author SHA1 Message Date
github-actions[bot] 966d444867 chore(packages): release 0.4.89 [skip node-packages-release] 2026-09-16 18:04:15 +00:00
zipg 5f5a78a044 fix(grid): show real save error message instead of [object Object] 2026-09-17 01:38:13 +08:00
t8y2 2dfd4e0058 fix(iris): keep ordinary schema names unquoted in qualified queries 2026-09-17 01:37:59 +08:00
github-actions[bot] e4d7113a18 chore: bump module versions [skip ci] 2026-09-16 17:37:50 +00:00
t8y2 ab5aba9497 fix(editor): stop per-keystroke full-document statement reparse
Closes #8600
2026-09-17 01:30:02 +08:00
t8y2 84ae93aaa8 fix(connection): hide production protection for plugin connections 2026-09-17 00:57:42 +08:00
Hand Sonic 13a209d4c2 feat(transfer): support h2 table data transfer 2026-09-17 00:36:11 +08:00
t8y2 1c937f0cfc chore(build): line-tables-only debuginfo for dev dependencies 2026-09-17 00:23:15 +08:00
zipg 1a7b44f8fc fix(sqlserver): jtds prelogin fallback and legacy 2000 support
Closes #9333
2026-09-17 00:18:07 +08:00
t8y2 d35d578b03 fix(web): avoid crypto.randomUUID outside secure contexts
Closes #9306
2026-09-17 00:13:29 +08:00
二丫讲梵 2c97e44d82 feat(updater): decouple update notice from auto download
Closes #9329
2026-09-16 23:50:18 +08:00
t8y2 b891bf80fd fix(tabs): allow saving a saved sql tab after clearing content
Closes #9247
2026-09-16 23:24:10 +08:00
二丫讲梵 57e98b6d86 feat(ai): session-level prompt cache key for openai responses
Closes #9232
2026-09-16 23:23:32 +08:00
zipg b969a86d38 fix(ai): isolate provider test results between edits 2026-09-16 23:22:42 +08:00
zipg 1ed06f270b fix(query): stop doubling global query timeout 2026-09-16 23:21:57 +08:00
github-actions[bot] e8affe9436 chore(docs): refresh contributor activity 2026-09-16 15:12:28 +00:00
t8y2 c6ed3b6d11 fix(ci): use an owner PAT to push the contributor snapshot 2026-09-16 23:11:11 +08:00
t8y2 d766228bb6 feat(site): polish dark theme, typography, and docs nav 2026-09-16 23:11:10 +08:00
Hand Sonic f5673ca0a1 feat(mongodb): bson dumps and database backup/restore 2026-09-16 22:46:48 +08:00
zipg ecc1135de3 fix(db): route search-path reset through dialect-aware helper
Closes #9304
2026-09-16 22:41:35 +08:00
zipg 81292760e2 fix(sqlserver): isolate legacy metadata pool from work pools
Closes #9183
2026-09-16 22:40:46 +08:00
t8y2 81a0ed922a feat(site): center sponsors intro line and drop the visible heading 2026-09-16 22:26:56 +08:00
t8y2 2a318a296b feat(site): slim plugin cards and lay them out in three columns 2026-09-16 22:26:55 +08:00
弈秋 1d19c8b273 feat(gbase8s): pick locale and charset when creating database 2026-09-16 21:37:34 +08:00
Abeautifulsnow ea1de14517 feat(editor): group shortcuts by scope with conflict tiers
Closes #9075
2026-09-16 21:36:55 +08:00
二丫讲梵 b2d56625e0 feat(grid): temporary page size with optional set-as-default
Refs: #9167
2026-09-16 21:25:00 +08:00
九日 85653427ef fix(editor): place caret on click after unfocused sql scroll
Closes #9296
2026-09-16 21:23:58 +08:00
Tara 9cb6b28a0f fix(redis): keep user wildcards in fuzzy key search
Refs: #9012
2026-09-16 20:41:50 +08:00
邹超 5b60c2efcd fix(redis): numeric db index and cli flag salvage on url import 2026-09-16 20:31:14 +08:00
弈秋 94a8e63fde fix(gbase8s): harden locale probe with retry, timeout and whitelist 2026-09-16 20:18:46 +08:00
Abeautifulsnow e9be6d0419 fix(editor): hide commit/rollback for clean mysql/pg manual transactions
Closes #9018
2026-09-16 20:17:55 +08:00
Loc Nguyen 91b8fa0aa8 fix(mongodb): reject unknown update options instead of dropping 2026-09-16 20:17:14 +08:00
onenewcode a57906ed7e feat(driver-store): filter built-in drivers by install status 2026-09-16 20:16:29 +08:00
t8y2 888f37c3c8 feat(plugins): dedupe marketplace counters and fix aggregation dataset 2026-09-16 17:24:22 +08:00
t8y2 69d3f02843 ci(plugin-cli): auto-refresh pnpm lockfile after release 2026-09-16 16:59:12 +08:00
t8y2 44bb44a9d9 chore(plugin-cli): refresh pnpm lockfile for 0.1.9 2026-09-16 16:29:16 +08:00
t8y2 4a42a2bd29 chore(plugin-cli): release 0.1.9 2026-09-16 16:27:58 +08:00
t8y2 13ebba40c9 chore(plugin-cli): refresh pnpm lockfile for 0.1.8 2026-09-16 16:27:32 +08:00
t8y2 193e9e898d fix(dev-host): close debug pages without a confirmation modal
Closing a workbench page popped a centered confirm dialog that locked
the entire shell (busy gate) until answered. Developers who clicked the
tab's close button and immediately went for the connection list found
every button dead and reported the page as impossible to reopen. Debug
pages are stateless and rebuild on reopen, so the close now happens
immediately; destructive actions (delete connection, disconnect,
backend rebuild) keep their confirmations.
2026-09-16 16:26:58 +08:00
t8y2 ad3c5cf239 feat(community): add Feishu group entry and colorize WeChat badge 2026-09-16 16:23:17 +08:00
t8y2 b8ae066f8e fix(site): raise issue draft ai token budget for reasoning models 2026-09-16 16:04:41 +08:00
t8y2 dade7c39b5 chore(plugin-cli): release 0.1.8 2026-09-16 15:29:41 +08:00
t8y2 0239d2c56b feat(dev-host): persist the auto-reload preference across restarts
Auto-reload lived only in the dev host process, so every restart reset
it to off and re-enabling it cost a confirmation dialog each session.
The setting now persists to settings.json in the development data
directory (same atomic write pattern as connections.json) and is
restored — and re-armed — at startup.
2026-09-16 15:29:39 +08:00
t8y2 197ba97f73 fix(site): route worker API paths ahead of static 404 handling 2026-09-16 15:19:16 +08:00
t8y2 3496451ca4 chore(plugin-cli): refresh Cargo.lock for 0.1.7 2026-09-16 15:16:31 +08:00
t8y2 9fca87c744 chore(plugin-cli): release 0.1.7
Ships the dev-host manifest hot reload: manifest.json edits (version
bumps, field changes) no longer strand the running dev host on a stale
identity check.
2026-09-16 15:13:37 +08:00
t8y2 2c4f2091a6 fix(dev-host): reload manifest.json instead of failing the identity check
The dev host read manifest.json once at startup and froze that copy in
the Sidecar, while the backend watcher happily rebuilt the binary on any
source edit. Editing the manifest while the host ran — a routine version
bump during a release, for example — then made every restart compare the
fresh binary against the stale in-memory manifest and fail with "Sidecar
identity or protocol does not match manifest" until the whole dev host
was restarted.

Two layers of fixing: the project root now watches manifest.json and hot
swaps the in-memory copy (plus secret-key collection), and the sidecar
handshake re-reads the manifest from disk before declaring a mismatch, so
the check can no longer race the watcher. A genuinely disagreeing
manifest still fails the handshake.
2026-09-16 15:10:06 +08:00
t8y2 048b9ed291 feat(plugins): add host.copy bridge for plugin clipboard writes
Plugin workbenches run in a sandbox="allow-scripts" iframe with an opaque
origin, so every scripted clipboard path (async Clipboard API, offscreen
textarea + execCommand) is denied there — plugins could not copy share
links to the system clipboard. The bridge now exposes host.copy: the
workbench host reuses copyToClipboard (Tauri clipboard-manager on desktop,
Web Clipboard with a legacy fallback on web hosts), and the sandbox iframe
additionally gets allow="clipboard-write" so engines that honor the
delegation can write directly.

The dev host matches production: its debug page answers host.copy in the
top-level document and its plugin iframes carry the same allow attribute.
No new plugin permission is required, so old hosts keep installing new
plugins and old plugins simply never call the method.
2026-09-16 14:29:47 +08:00
КГБ Lieu b0d30e6f73 feat(sql-file): import split SQL ZIP packages with bounded extraction 2026-09-16 14:15:09 +08:00
t8y2 f43bf80015 fix(i18n): restore catalog indentation for format check 2026-09-16 13:56:56 +08:00
t8y2 1170bcaa1c fix(transfer): keep partitions when converting target table name case 2026-09-16 13:26:53 +08:00
onenewcode 7ab781ce19 feat(mongodb): stream legacy agent exports via find cursors 2026-09-16 13:20:08 +08:00
Loc Nguyen 9eafac706e feat(mongodb): support replaceOne 2026-09-16 12:18:54 +08:00
弈秋 72be4ad9eb fix(gbase8s): support CREATE DATABASE and open mixed-locale databases 2026-09-16 11:54:56 +08:00
二丫讲梵 4bf073d5d1 feat(db2): preview binary large object columns as hex 2026-09-16 11:46:48 +08:00
onenewcode 9bf52dab69 fix(completion): stop aliasing DELETE target tables 2026-09-16 11:28:31 +08:00
t8y2 e916e9e927 feat(mcp): add WorkBuddy client config template 2026-09-16 11:15:40 +08:00
二丫讲梵 c2f4c6e2be fix(sidebar): block all-databases export for plugin connections 2026-09-16 10:59:32 +08:00
t8y2 a8cf635292 chore(release): prepare v0.6.14 2026-09-16 01:39:53 +08:00
t8y2 43f9aa8916 fix(tests): align query messages assertion with shared helper 2026-09-16 01:30:07 +08:00
github-actions[bot] ee767bd64f chore(packages): release 0.4.88 [skip node-packages-release] 2026-09-15 17:25:46 +00:00
github-actions[bot] e2cc5786a6 chore: bump module versions [skip ci] 2026-09-15 17:09:59 +00:00
t8y2 15c38da8ea fix(redis): preserve full key snapshots during local search
Closes #9120
2026-09-16 00:47:22 +08:00
t8y2 dffd3025a7 fix(query): show SQL Server notices outside result-set tabs
Closes #9166
2026-09-16 00:47:21 +08:00
t8y2 8373f153a3 fix(grid): preserve result aliases in copied INSERT statements
Closes #9173
2026-09-16 00:47:16 +08:00
Loc Nguyen 69b138dc22 feat(mongodb): support estimatedDocumentCount, db.stats, db.serverStatus 2026-09-16 00:40:32 +08:00
zipg 7f12fecc08 fix(ui): route blank-focus ctrl+f to the sidebar search 2026-09-16 00:40:25 +08:00
二丫讲梵 1938c4a8aa feat(ai): rename conversations and improve database picker 2026-09-15 23:56:55 +08:00
二丫讲梵 2d5fc80c91 fix(grid): keep pending new rows visible under column filters 2026-09-15 23:52:37 +08:00
zipg 8dab022452 fix(ai): wrap long tokens in user chat messages 2026-09-15 23:40:38 +08:00
zipg e498fc3731 fix(postgres): recover applied create table after disconnect 2026-09-15 23:22:23 +08:00
zipg 3c8ae159fb feat(ui): route Ctrl+F search by focused surface 2026-09-15 23:20:05 +08:00
zipg 4c80d16e26 feat(plugins): polish plugin marketplace listing display 2026-09-15 22:13:13 +08:00
t8y2 300fc029a9 test(grid): align varbinary copy specs with GBK fallback behavior 2026-09-15 21:17:44 +08:00
t8y2 d19e43b42b feat(plugins): move marketplace counters to analytics engine 2026-09-15 20:58:00 +08:00
t8y2 6a6f7712c1 feat(plugins): report install beacon after successful installs 2026-09-15 20:57:58 +08:00
t8y2 f73424c3d3 feat(site): source homepage testimonials from real community coverage 2026-09-15 20:39:07 +08:00
zipg d0f8bd0f24 feat(data-compare): support recompare and endpoint distinction 2026-09-15 20:08:26 +08:00
zipg 7942c59aa7 feat(duckdb): improve SQL completion keywords 2026-09-15 19:44:32 +08:00
t8y2 7c5ffb1630 fix(ci): drop deprecated verified param from cask template
Homebrew deprecated the verified url parameter; stop regenerating
the cask with it so the tap no longer warns on every load.

Refs: t8y2/homebrew-tap#3
2026-09-15 19:28:07 +08:00
t8y2 041469269e fix(ci): brew dbx-cli formula from native release archives
The generated formula installed the npm tarball, which broke inside
Homebrew's --min-release-age cooldown: the platform binary is an npm
optionalDependency, so npm silently skipped it and brew shipped a
non-working CLI. Generate the formula from the prebuilt native
archives on the packages-v release instead, verified against
CLI-SHA256SUMS, with per-arch url/sha256 for macOS and Linux and no
node dependency.

Refs: t8y2/homebrew-tap#2
2026-09-15 19:28:06 +08:00
t8y2 464350d086 fix(grid): preview GBK-stored text in MySQL varbinary cells 2026-09-15 18:42:04 +08:00
zipg cc573615cf fix(oracle): preserve hash characters in SQL identifiers 2026-09-15 18:41:32 +08:00
bigdogcome df22dd49b9 fix(db2): tolerate malformed character data via decoder 2026-09-15 18:09:27 +08:00
zipg 28b795e7a9 fix(sql): keep GaussDB merge actions in statement range 2026-09-15 18:09:10 +08:00
zipg 168f854f8a fix(agents): import standalone JRE offline packages 2026-09-15 18:09:01 +08:00
t8y2 2fd8f42246 feat(site): launch plugin install via dbx deep link 2026-09-15 17:55:04 +08:00
t8y2 94bcbc1631 feat(plugins): install marketplace packages via dbx deep link 2026-09-15 17:55:04 +08:00
Abeautifulsnow 0437f952e0 fix(editor): measure shortcut capture input width to fit CJK 2026-09-15 17:52:59 +08:00
zipg b06e952ba0 fix(grid): restore column jump in transposed table view 2026-09-15 17:52:53 +08:00
amwps290 5a0e74eec6 feat(query): jump to PostgreSQL error position from result messages 2026-09-15 17:39:59 +08:00
zipg 571fc07d85 fix(export): qualify PG foreign key schema and cascade table drops 2026-09-15 17:35:03 +08:00
t8y2 62fab080e8 chore(site): add static export preview script 2026-09-15 17:03:14 +08:00
t8y2 3a74411c28 feat(site): add self-contained static 404 page 2026-09-15 17:03:14 +08:00
t8y2 1ae691fc70 docs(site): move deployment and dev sections near intro 2026-09-15 17:03:14 +08:00
t8y2 3d96ca39ae fix(site): remove inline-link hover nudge that caused jitter 2026-09-15 17:03:14 +08:00
t8y2 ab277e0fa2 feat(site): add plugin center listing and detail pages 2026-09-15 17:03:14 +08:00
zipg 256917d8cf fix(editor): stop toolbar flicker at resize threshold width 2026-09-15 17:00:04 +08:00
U1 e255c49e7d fix(sqlserver): wrap identity INSERTs in exported SQL scripts 2026-09-15 16:59:58 +08:00
弈秋 dd99786c01 fix(agents): sanitize backtick identifier quote for GBase 8s 2026-09-15 16:31:53 +08:00
Loc Nguyen aeafddfae7 fix(mongodb): say what failed instead of the generic command hint 2026-09-15 16:00:10 +08:00
soddygo fd4ca30707 fix(grid): refresh table metadata before reloading data 2026-09-15 16:00:03 +08:00
Abeautifulsnow 2ee04c51ec fix(editor): exit shortcut capture state when restoring defaults 2026-09-15 15:59:50 +08:00
zipg 2b1c5fa71e fix(oceanbase): support manual transaction control in oracle mode 2026-09-15 15:37:36 +08:00
t8y2 39042f7917 feat(update): auto-replace stale downloads and relocate update toggle
Closes #9140
2026-09-15 14:58:20 +08:00
onenewcode d79dc05cf5 fix(query): keep Oracle NUM column results editable 2026-09-15 14:51:44 +08:00
thanhthai3010 715d834c32 fix(cassandra): skip table alias completions for CQL 2026-09-15 14:42:46 +08:00
amwps290 20e026eea9 fix(query): preserve PG notices for statements without results 2026-09-15 14:30:59 +08:00
U1 9c22856285 fix(sidebar): respect identifier quote setting when creating views 2026-09-15 14:30:39 +08:00
U1 4a997fe63f feat(sidebar): match table comments in local table search 2026-09-15 14:29:34 +08:00
t8y2 08c163fe32 feat(plugins): count marketplace downloads and installs 2026-09-15 14:24:11 +08:00
onenewcode 83c8447b4d feat(mongodb): allow editing inferred CSV import column types 2026-09-15 13:39:59 +08:00
t8y2 99738c160c feat(plugins): add certified badge and polish marketplace card layout 2026-09-15 13:39:25 +08:00
U1 14bcde806f fix(sql): honor include-database-name setting for schema-aware engines 2026-09-15 13:28:40 +08:00
t8y2 1464d33677 test(layout): align tab bar resize assertion with pointerdown handle 2026-09-15 12:44:35 +08:00
t8y2 36b96ea32c fix(layout): fix plugin tab layout with vertical tab bar
Closes #9101
2026-09-15 12:27:03 +08:00
soddygo faf259fcc2 fix(metadata): await object cache invalidation on connection refresh 2026-09-15 12:21:18 +08:00
Loc Nguyen f95650f039 fix(mongodb): accept .toArray()/.pretty() after find/aggregate 2026-09-15 12:09:25 +08:00
Abeautifulsnow 3f74195835 fix(editor): keep Cmd+H for macOS hide instead of replace 2026-09-15 12:09:04 +08:00
t8y2 a9cf5822fa fix(layout): keep panel resizing smooth across plugin iframes 2026-09-15 12:03:11 +08:00
t8y2 c4952adb98 feat(plugins): add host.saveFile bridge for plugin downloads
Plugin iframes cannot trigger downloads: WKWebView cancels
shouldPerformDownload navigations when no host download handler is
registered, so blob-anchor downloads were silent no-ops on desktop.
The bridge now accepts a transferred ArrayBuffer (512 MiB cap, base64
fallback) and persists it through the native save dialog
(plugin-dialog + plugin-fs on Tauri, top-level anchor on web hosts),
resolving null when the user cancels.
2026-09-15 11:37:51 +08:00
t8y2 f852a57673 fix(plugins): remove static icons from marketplace action buttons 2026-09-15 11:15:43 +08:00
t8y2 f1c1785959 perf(query): avoid cloning result rows for JS serialization 2026-09-15 11:06:48 +08:00
github-actions[bot] 8e7d4c387f chore(packages): release 0.4.87 [skip node-packages-release] 2026-09-14 18:44:41 +00:00
t8y2 7855707b69 fix(ci): install dev-host dependencies before packing node packages 2026-09-15 02:18:10 +08:00
t8y2 c489dead10 chore(release): prepare v0.6.13 2026-09-15 01:41:24 +08:00
github-actions[bot] 62e81745ab chore: bump module versions [skip ci] 2026-09-14 17:35:35 +00:00
t8y2 1ea822b80e feat(plugins): add marketplace banner, docs link and card polish 2026-09-15 01:27:32 +08:00
t8y2 a81ae9f455 feat(plugins): add install-from-url for plugin packages 2026-09-15 01:16:04 +08:00
zipg 837ea07cf3 fix(mongodb): support numeric contains filters 2026-09-15 01:13:34 +08:00
t8y2 bd9e784d90 fix(plugin-cli): isolate Go backend module builds 2026-09-14 23:38:34 +08:00
t8y2 ace6173aa9 fix(ui): remove duplicate top borders from classic tabs 2026-09-14 23:31:48 +08:00
t8y2 a04b424e37 fix(plugin-cli): normalize Windows Go workspace paths 2026-09-14 23:01:04 +08:00
t8y2 400ff6dd02 fix(plugins): restore workbench refresh and bootstrap 2026-09-14 23:01:04 +08:00
U1 a5bb8cf1c1 fix(connection): strip invisible characters from saved credentials 2026-09-14 22:49:15 +08:00
t8y2 e84e49fa28 feat(plugin-cli): refresh bundled Go SDK 2026-09-14 22:37:57 +08:00
soddygo 5918414127 fix(query): invalidate persisted object metadata after DDL 2026-09-14 22:26:56 +08:00
zipg c6ecd9808f fix(export): enable ZIP64 for large XLSX exports 2026-09-14 22:26:38 +08:00
zipg 8240df143a fix(ui): allow horizontal scrolling of narrow execution summary 2026-09-14 22:26:22 +08:00
二丫讲梵 ad9d9cad8f fix(dataGrid): use single-char whitespace marker in cells 2026-09-14 22:26:07 +08:00
t8y2 b288a6b5f4 feat(plugin-sdk): support framed binary transfers 2026-09-14 22:11:20 +08:00
Jinpy 3d1b84831f fix(plugins): stabilize plugin integration 2026-09-14 21:03:47 +08:00
zipg b70d6da227 fix(export): stream JSON query exports and allow web TXT 2026-09-14 20:04:11 +08:00
Eve af424b782f fix(query): preserve Oracle star projection editability 2026-09-14 20:00:41 +08:00
zipg c63de19cbf fix(transfer): reuse existing PostgreSQL target schema 2026-09-14 20:00:24 +08:00
Eve 0854cc3e08 fix(schema-diff): load Oracle namespace options 2026-09-14 19:59:59 +08:00
Eve cabc7656de fix(mcp): wrap batch structured results in object 2026-09-14 19:59:45 +08:00
Abeautifulsnow 760d2c6409 fix(source): open pending tab while loading object source 2026-09-14 19:59:21 +08:00
zipg eba6853480 fix(agents): lower legacy native agents to Go 1.20.14 2026-09-14 19:08:39 +08:00
yimiaoxiehou 805d10346a feat(elasticsearch): support custom CA and mutual TLS 2026-09-14 18:31:27 +08:00
zipg 28e2568a8f fix(sql): keep required identifier quotes per dialect 2026-09-14 17:53:28 +08:00
Loc Nguyen 608633edff fix(mongodb): offer query operators by filter position 2026-09-14 17:53:17 +08:00
zipg 14aeb93081 fix(desktop): add --disable-gpu to Win7 WebView2 recovery 2026-09-14 17:52:59 +08:00
Mo-Moore a7a2e2f434 feat(oceanbase-oracle): append object grants in table DDL 2026-09-14 17:52:13 +08:00
zipg 2e3b7da533 perf(oracle): fetch view source from ALL_VIEWS before GET_DDL 2026-09-14 17:51:49 +08:00
t8y2 d9154ee2b7 feat(marketplace): prefer R2 catalog with fallback 2026-09-14 17:10:22 +08:00
amwps290 deb47df2eb feat(opengauss): support A-compatibility packages 2026-09-14 16:13:44 +08:00
t8y2 760cad6d1b fix(settings): move scrollbar into outer gutter 2026-09-14 16:04:13 +08:00
U1 a3aa50ded9 fix(generate): emit JSON literals for JSON columns 2026-09-14 15:23:34 +08:00
Eve 1ea6d2907b fix(layout): raise resizable panel minimum width to 240 2026-09-14 14:52:39 +08:00
New2Niu 1533064f1f fix(gbase8s): omit owner qualifier in table admin SQL 2026-09-14 14:52:13 +08:00
zccat 6d866905b5 perf(transfer): add COPY fast path for postgres-family transfer 2026-09-14 14:51:39 +08:00
t8y2 f1cc68a4ba fix(plugin): prevent theme flash in embedded workbenches 2026-09-14 14:43:02 +08:00
Abeautifulsnow 8d32cf9ffa fix(import): hide the data-tab import entry for unsupported engines 2026-09-14 14:31:40 +08:00
onenewcode 7cbd1418b3 fix(sql): keep queries editable when literals contain semicolons 2026-09-14 14:29:11 +08:00
Eve e561bd156f feat(query): name result tabs from SQL comments 2026-09-14 14:28:09 +08:00
二丫讲梵 193d875b8a feat(ai): cross-database query with multi database selection 2026-09-14 13:50:17 +08:00
zipg 58d902c07f fix(query): make oracle offset jump consume agent result session 2026-09-14 13:49:59 +08:00
t8y2 05534fce32 feat(plugin): add shared stream bridge 2026-09-14 13:38:00 +08:00
zipg 19365b09bb fix(export): emit CSV temporal values without formula wrapper 2026-09-14 13:33:23 +08:00
邹超 2249aa30c0 feat(editor): copy selected SQL as rich text with highlighting 2026-09-14 13:24:10 +08:00
二丫讲梵 94617a5923 fix(desktop): show and switch special page tabs in empty workspace 2026-09-14 13:23:35 +08:00
U1 9c973a2ee3 feat(grid): collapse MySQL batch deletes into IN clauses 2026-09-14 13:23:12 +08:00
二丫讲梵 8efb24f2c7 feat(redis): persist dangerous command interception setting 2026-09-14 12:41:01 +08:00
onenewcode b28374867a fix(settings): align about-page import/export icon semantics 2026-09-14 12:40:29 +08:00
onenewcode defc4dcfa5 chore: remove issue-assets from the repository 2026-09-14 12:40:20 +08:00
zipg ae1da14ddf fix(grid): keep hiding xugu __DBX_ROWID after metadata loads 2026-09-14 12:39:42 +08:00
Abeautifulsnow 638aa48f57 feat(data-grid): hide selected columns from header context menu 2026-09-14 12:39:06 +08:00
jearhe 974848d6e2 fix(data-compare): generate dialect-correct oracle sampling SQL 2026-09-14 12:38:52 +08:00
zipg ac2dcc1b79 fix(structure): unquote plain Oracle identifiers when quoting off 2026-09-14 12:37:57 +08:00
Mo-Moore 18e34bf33a feat(schema-diff): named config selector and mapping UX 2026-09-14 12:37:39 +08:00
t8y2 2808996526 fix(web): route plugin connections through plugin host
Refs: t8y2/dbx-plugin-s3#3
2026-09-14 12:36:23 +08:00
t8y2 ba65d4a0e0 docs(plugin): expand development guide and README links 2026-09-14 10:06:18 +08:00
t8y2 72b8b8f0ac docs: redirect removed turkish routes to english 2026-09-14 09:29:42 +08:00
t8y2 f28f050d97 docs: drop turkish locale from website 2026-09-14 09:29:19 +08:00
t8y2 28c0977a56 docs: bump app size claim to 25 MB 2026-09-14 09:29:11 +08:00
t8y2 e2dc7ce176 fix(docs): add missing turkish plugin-development page 2026-09-14 09:06:15 +08:00
github-actions[bot] f52be8f439 chore: bump module versions [skip ci] 2026-09-13 18:22:28 +00:00
t8y2 61b13826c5 chore(release): prepare v0.6.12 2026-09-14 02:22:14 +08:00
t8y2 f15d93deb2 fix(tests): move plugin tab assertion to EditorGroupTabBar and lift TreeItem computed budget 2026-09-14 02:04:44 +08:00
二丫讲梵 d3587719d2 fix(sidebar): refresh table tree before rebuilding search index 2026-09-14 01:51:55 +08:00
Thanadon Kongkanun 78f9740366 fix(clipboard): write CRLF line endings on Windows copies 2026-09-14 01:48:42 +08:00
dienaso 1b6ff2ba54 fix(sql): gate having alias visibility by dialect reject list 2026-09-14 01:44:58 +08:00
dienaso ac7fbaeb43 fix(sqlserver): cap and de-duplicate the clone PK constraint name 2026-09-14 01:41:47 +08:00
t8y2 933decc98b fix(plugin-cli): update package smoke version 2026-09-14 01:03:08 +08:00
yimiaoxiehou 56c0f8567d feat(zookeeper): support TLS-secured connections 2026-09-14 00:48:51 +08:00
t8y2 8cd5372e22 chore(plugin-cli): prepare 0.1.3 release 2026-09-14 00:44:03 +08:00
t8y2 e8e1ece80d feat(plugins): display manifest icons in dev host 2026-09-14 00:44:02 +08:00
zipg 83fdd78d5b fix(sqlserver): skip pagination rewrite for FOR JSON queries 2026-09-13 23:33:04 +08:00
Anand Hegde f6e5b3ab52 fix(linux): deliver deep links and opened files to the app 2026-09-13 23:32:07 +08:00
二丫讲梵 78a78cc209 fix(sidebar): keep table search pins across index rebuilds
Closes #8964
2026-09-13 23:30:58 +08:00
t8y2 91da8b2a9a fix(grid): keep tz-aware Now values and duckdb lockfile in sync 2026-09-13 19:54:53 +08:00
jearhe ba77d94815 fix(postgres): render temporal values in session timezone 2026-09-13 19:54:02 +08:00
zipg a33768ef6e fix(grid): dim null cells consistently without type colors 2026-09-13 19:46:12 +08:00
dienaso 45ed1047a8 fix(editor): keep table highlights while the parser catches up 2026-09-13 19:45:21 +08:00
dienaso 7afa87e849 fix(sqlserver): recreate primary key when cloning tables 2026-09-13 19:44:46 +08:00
zipg 832e08ae1f fix(mcp): route multi-statement scripts to batch execution 2026-09-13 19:40:40 +08:00
zipg a3e4a95400 fix(grid): stop double-escaping backslashes in dameng literals 2026-09-13 19:39:54 +08:00
zipg fd1f7f0adf fix(postgres): clean up default database pool on close 2026-09-13 19:39:02 +08:00
t8y2 68afe61693 fix(plugin-cli): release version 0.1.2 2026-09-13 18:37:29 +08:00
t8y2 729bb454e3 fix(ui): align tab bar spec with unified special tab ring color 2026-09-13 13:44:53 +08:00
t8y2 061d4009ee fix(ui): align apptabbar spec with unified special tab styles 2026-09-13 13:30:14 +08:00
dienaso 0459dfea12 fix(editor): apply default transaction mode when binding unbound tabs 2026-09-13 13:27:57 +08:00
jearhe 1137cab7f4 fix(editor): execute manually selected sql from the toolbar 2026-09-13 13:27:12 +08:00
zipg 28206f2963 fix(mongodb): null cell display and inline editing 2026-09-13 13:26:26 +08:00
zipg 17f184cd07 fix(oracle): paginate views with rownum on the first page 2026-09-13 13:25:44 +08:00
PanyangY 1842ae9de7 fix(postgres): retry table preview without byte-splitting wrappers 2026-09-13 13:24:38 +08:00
t8y2 5ed03ca2b2 fix(plugin): close plugin tabs safely and hide load flash 2026-09-13 13:08:15 +08:00
t8y2 bcd1aafb33 feat(plugins): support custom plugin icons 2026-09-13 12:49:18 +08:00
t8y2 7b7d21f54f fix(plugin): defer restored tabs until manual refresh 2026-09-13 12:35:04 +08:00
t8y2 f900357474 fix(ui): unify special tab activation styles 2026-09-13 11:07:17 +08:00
t8y2 84e8bab622 fix(plugin): restore marketplace card grid 2026-09-13 10:51:18 +08:00
t8y2 b5072f1a3e feat(plugin): add plugin framework workflow 2026-09-13 10:39:17 +08:00
二丫讲梵 3767f4a83b feat(sqlite): support autoincrement primary key in structure editor 2026-09-13 02:11:17 +08:00
zipg b8f39f9c24 feat(oracle): add dblink completion, tree view and management 2026-09-13 02:00:09 +08:00
zipg 6694e4382e fix(editor): keep SQL table names highlighted 2026-09-13 01:59:21 +08:00
zipg 579008ffbd fix(query): schema-qualify unqualified names for SQL Server 2026-09-13 01:51:42 +08:00
zipg 7472f16066 fix(editor): preserve SQL tab viewport state 2026-09-13 01:43:12 +08:00
zipg 80d314b711 fix(editor): slim SQL editor horizontal scrollbar 2026-09-13 01:42:27 +08:00
二丫讲梵 f26e373df9 docs: bilingualize issue and pull request templates 2026-09-13 01:41:47 +08:00
jearhe 859a004136 fix(transfer): support HighGo data transfer 2026-09-13 01:41:01 +08:00
dienaso 4fe04c8bb9 fix(sql): treat HAVING as alias-visible context 2026-09-13 01:40:05 +08:00
onenewcode 8ff56b4837 feat(mongodb): add Compass-style collection import/export 2026-09-12 21:59:29 +08:00
二丫讲梵 bf4874906c feat(desktop): unify tab organization menu and adaptive collapsed rail 2026-09-12 21:46:05 +08:00
二丫讲梵 177aba9979 feat(desktop): show zoom scale toast and lower UI scale limit to 70% 2026-09-12 21:44:42 +08:00
jearhe d36c094c4f fix(mq): show clear-backlog errors inside the confirm dialog 2026-09-12 21:43:31 +08:00
Thanadon Kongkanun 4bac6b56de fix(sql): treat GET DIAGNOSTICS targets as MySQL user variables
Refs: #8927
2026-09-12 21:42:32 +08:00
PanyangY e03046ba55 fix(structure): revalidate cached metadata on structure reopen 2026-09-12 17:45:59 +08:00
zipg 0e67027acf improve(settings): rework about support info and debug log layout 2026-09-12 17:45:11 +08:00
二丫讲梵 b6cfb3b728 feat(data-grid): add whitespace visualization toggle 2026-09-12 17:40:02 +08:00
Mo-Moore 1191118b5c feat(schema-diff): compare routines across same-structure databases 2026-09-12 17:39:14 +08:00
zipg bd2ac50ac1 fix(mongodb): preserve local column filters across tab switches 2026-09-12 17:37:57 +08:00
zipg f8ce8ab353 feat(sql): add Doris/SelectDB completion and semantic diagnostics 2026-09-12 17:36:58 +08:00
zipg 20f6092cf2 Merge pull request #8903 from dienaso/fix/object-browser-vertical-scrollbar-viewport
fix(objects): pin object list vertical scrollbar to the viewport
2026-09-12 16:21:39 +08:00
zipg f32b3928ea fix(objects): show horizontal scrollbar on hover 2026-09-12 15:24:44 +08:00
t8y2 7cb1e6502f fix(objects): unbreak list-view guard and drop dead header ref 2026-09-12 13:46:42 +08:00
zipg 90feaa43fb fix(postgres): preserve partition constraints in DDL 2026-09-12 10:41:08 +08:00
zipg e60b0f3083 fix(editor): reduce Vim arrow key lag 2026-09-12 09:29:27 +08:00
U1 0bfb1035e4 feat(ai): add Zhipu provider preset 2026-09-12 09:29:04 +08:00
jearhe 774f175c33 feat(redis): batch key expiry updates 2026-09-12 09:28:40 +08:00
Thanadon Kongkanun 076c39bc21 fix(csv): unwrap export force-text markers 2026-09-12 09:28:17 +08:00
zipg 083f81bc03 fix(postgres): avoid recreating identity sequences 2026-09-12 09:27:54 +08:00
zipg 058a67be0b fix(csv): unwrap temporal force-text values 2026-09-12 09:27:30 +08:00
zipg e43fd9c502 fix(oracle): generate compatible index drop SQL 2026-09-12 09:27:06 +08:00
zipg 575f71dfd8 fix(kingbase): skip incompatible schema object scans 2026-09-12 09:26:27 +08:00
zipg 83e1e293b0 fix(sqlserver): preserve identity values during transfer 2026-09-12 09:24:32 +08:00
github-actions[bot] 4e54b49961 chore(packages): release 0.4.86 [skip node-packages-release] 2026-09-11 17:01:21 +00:00
github-actions[bot] e68029d020 chore: bump module versions [skip ci] 2026-09-11 16:39:40 +00:00
t8y2 72847a69be chore(release): prepare v0.6.11 2026-09-12 00:20:03 +08:00
wxp 3affa544a9 fix(objects): pin object list vertical scrollbar to the viewport
When the object-browser columns exceeded the panel width, the vertical
scrollbar travelled with the content: the row scroller was stretched to
the content min-width inside the horizontally scrolling table container,
so its scrollbar sat at the far-right edge of the (off-screen) content
and users had to scroll all the way right to reach it (#8885).

The list scroller now stays viewport-width and owns the horizontal
scrolling itself (content min-width moves to the virtual-scroller item
wrapper), and the header becomes a clipped wrapper whose scrollLeft is
kept in sync with the scroller on scroll, resize, and re-attach.
2026-09-12 00:17:14 +08:00
Thanadon Kongkanun 35773ace52 fix(shortcuts): record shifted punctuation by its unshifted key 2026-09-11 23:26:30 +08:00
二丫讲梵 8b1379ff4f fix(editor): preserve snippet navigation after dismissing completion 2026-09-11 23:25:18 +08:00
CSXFanMeng 2d9521898f feat(backup): support restoring selected MySQL tables 2026-09-11 23:20:05 +08:00
二丫讲梵 e8b3c1457a fix(mq): prevent stale cursor resets from affecting subscriptions 2026-09-11 23:18:28 +08:00
zipg 48d6c94aad feat(tabs): polish tab groups color bars and object icons 2026-09-11 21:10:49 +08:00
Loc Nguyen 074216ed0f fix(mongo): suggest extended json values in completion 2026-09-11 21:10:04 +08:00
Abeautifulsnow 32e070f387 fix(editor): stop replaying stale format and compress requests 2026-09-11 19:58:45 +08:00
New2Niu 3ccf4c2fed fix(gbase8s): upgrade driver and map mysql-compat column types
Closes #8635
2026-09-11 18:08:23 +08:00
Abeautifulsnow 6f259a60c5 fix(ai): retry and diagnose transient provider failures 2026-09-11 18:04:59 +08:00
Tara 27f21d5cc4 feat(structure): geometry subtype and srid editor for postgres 2026-09-11 17:39:31 +08:00
Loc Nguyen 71cc734cdd fix(mongo): support bson shell literals in filters and queries 2026-09-11 17:26:05 +08:00
Conastin 1ceeaad8a4 fix(mcp): keep manual database scope entries saved and visible 2026-09-11 17:25:19 +08:00
zipg 84b02d6960 fix(sqlserver): stabilize legacy 2000 connections and column comments 2026-09-11 17:19:53 +08:00
Conastin 1268cd5615 fix(mcp): allow plsql blocks under selected database scope 2026-09-11 17:03:30 +08:00
onenewcode 8f29273653 fix(grid): persist visual table filters across restart 2026-09-11 16:56:50 +08:00
Thanadon Kongkanun 19be861c49 fix(kv): hide internal agent error data in browser messages 2026-09-11 16:49:23 +08:00
Conastin 5fa0c5a56d fix(mcp): connect before listing databases in scope picker 2026-09-11 16:48:46 +08:00
Eve f7b7b9baa6 fix(ai): recover inline fenced code blocks 2026-09-11 16:41:25 +08:00
U1 99b59cc094 feat(structure): rework triggers tab as master-detail layout 2026-09-11 16:40:48 +08:00
邹超 f9f9a28edf feat(sidebar): copy connection info as url jdbc or dsn 2026-09-11 16:40:11 +08:00
КГБ Lieu 16866293a3 feat(export): split large sql exports into zip parts 2026-09-11 15:41:28 +08:00
Thanadon Kongkanun 339c96cc81 fix(sqlserver): keep rows readable on collation decode failure 2026-09-11 15:03:59 +08:00
HolyeTyrone a04713001e perf(transfer): make large transfers progress-aware with keyset paging 2026-09-11 14:38:34 +08:00
一颗红心 5e111145d3 feat(redis): add key search history 2026-09-11 14:29:25 +08:00
cowking 72faf4887b feat(sidebar): show table metadata group counts 2026-09-11 14:14:30 +08:00
Thanadon Kongkanun c834421c49 fix(ssh): negotiate sha-1 group exchange with legacy servers 2026-09-11 14:13:54 +08:00
U1 a4eab4f73f fix(doris): add MATERIALIZED keyword for completion 2026-09-11 14:13:17 +08:00
Eve ed810d6c92 fix(completion): always offer custom sql snippets 2026-09-11 14:12:42 +08:00
zipg 0a6c10555a fix(redis): tolerate proxy cluster hash field ttl rejections 2026-09-11 13:26:30 +08:00
Loc Nguyen 7c2db2d142 fix(mongo): stop suggesting extended json keys as fields 2026-09-11 13:25:54 +08:00
二丫讲梵 5ba5813247 fix(i18n): add missing updates.checking locale strings 2026-09-11 13:25:17 +08:00
onenewcode 61bd3cdf62 feat(elasticsearch): show index aliases on sidebar rows 2026-09-11 13:24:41 +08:00
二丫讲梵 6299d62125 feat(grid): add cell value conversion previews
Refs: #7751
2026-09-11 12:07:36 +08:00
二丫讲梵 5cd4706d3d feat(editor): highlight sql table aliases and references 2026-09-11 12:06:59 +08:00
二丫讲梵 3840426128 fix(table): keep mysql DEFAULT_GENERATED columns copyable 2026-09-11 12:06:23 +08:00
zccat ee7ee0d572 fix(transfer): route openGauss transfer through postgres dialect
Refs: #8661
2026-09-11 12:05:44 +08:00
t8y2 8186c697d5 fix(tests): remove low-value source snapshots and define test standards 2026-09-11 11:51:24 +08:00
t8y2 ead906477f test(tabs): align cache assertions with bounded keepalive 2026-09-11 11:47:40 +08:00
t8y2 1c9846dc57 fix(ci): remove Cargo-backed docs export smoke 2026-09-11 11:28:50 +08:00
t8y2 ea44730c8a fix(redis): close pending pubsub connections on unmount 2026-09-11 11:24:44 +08:00
二丫讲梵 7b32836f27 feat(grid): support applying a single structured filter condition 2026-09-11 10:52:57 +08:00
jearhe a216e0938e fix(grid): restore long cell values in the detail editor 2026-09-11 10:52:19 +08:00
二丫讲梵 15d451d43a fix(editor): improve result navigation and scrolling performance 2026-09-11 10:51:41 +08:00
二丫讲梵 14298b31a8 fix(appimage): upgrade tauri cli to fix icon symlinks 2026-09-11 10:51:00 +08:00
Loc Nguyen e1e925fbab fix(mongo): accept db["collection"] bracket accessors 2026-09-11 10:50:44 +08:00
t8y2 bad696ed11 perf(tabs): retain bounded hot tab surfaces 2026-09-11 10:46:18 +08:00
t8y2 c89a2f4979 fix(ci): keep R2 updater manifest complete and self-healing
Validate latest.json platform completeness before syncing to R2 and
derive the R2 manifest from the GitHub release in sync-changelog.
2026-09-11 09:39:26 +08:00
t8y2 718b18655b chore(ci): silence new-PR group notice and add author to merge notice 2026-09-11 01:27:49 +08:00
github-actions[bot] 6f2bc68f1a chore(packages): release 0.4.85 [skip node-packages-release] 2026-09-10 16:05:32 +00:00
github-actions[bot] a1ede71ed5 chore: bump module versions [skip ci] 2026-09-10 15:44:21 +00:00
t8y2 6696af471d chore(release): prepare v0.6.10 2026-09-10 23:20:54 +08:00
t8y2 ec65e998bf fix(ci): keep PR label runs green on synced forks and stale heads 2026-09-10 23:04:14 +08:00
zipg be966a8465 fix(mongodb): accept sharded cluster floating-point counts 2026-09-10 21:07:40 +08:00
t8y2 6f6abf1d38 perf(tabs): debounce UI state mementos 2026-09-10 20:33:45 +08:00
t8y2 8f3afe0397 feat(tabs): persist inactive tab UI state 2026-09-10 19:45:07 +08:00
onenewcode 69098bf0d6 fix(mcp): restore custom connection scope after switching to all 2026-09-10 19:30:22 +08:00
xiangwenhai 065da1a829 fix(core): pin etcd metrics and consul clients to rustls 2026-09-10 19:23:28 +08:00
zipg 77323715e7 feat(redis): support MONITOR live command log 2026-09-10 19:23:18 +08:00
onenewcode 7b7ed6f7b1 fix(postgres): suggest CURRENT_DATE in SQL completion 2026-09-10 18:59:32 +08:00
onenewcode 16f41d06bf fix(kafka): add missing common.refresh locale key 2026-09-10 18:59:19 +08:00
zipg d0f85555f6 fix(ui): repair SQL editor overflow menu toggles 2026-09-10 18:59:05 +08:00
zipg 61e9902690 fix(rabbitmq): reuse SSH tunnel for matching amqp endpoint 2026-09-10 18:58:52 +08:00
C.J.S 5c95f2c00a feat(mcp): support read-only Kafka message browsing 2026-09-10 18:04:16 +08:00
Abeautifulsnow a9b4470944 fix(mcp): persist query timeout edits reliably 2026-09-10 18:04:05 +08:00
zipg 4fce108247 fix(postgres): preserve selected user schema in search path 2026-09-10 17:44:33 +08:00
zipg dcc0b20a34 fix(structure): focus search shortcut in table structure editor 2026-09-10 17:44:19 +08:00
zipg e7ea2e92f2 fix(object-browser): avoid refresh on tab restore 2026-09-10 17:44:06 +08:00
zccat f7afa8dca7 perf(transfer): use keyset pagination for PG-family sources 2026-09-10 17:43:53 +08:00
Loc Nguyen 3e707d7777 fix(mongodb): support zero-arg and numeric shell constructors 2026-09-10 16:18:22 +08:00
二丫讲梵 e0cc787f0a feat(query): auto-keep results by default and fix restore race 2026-09-10 15:57:27 +08:00
Eve 5b5d58cd2a fix(objects): preserve category filter per tab 2026-09-10 15:57:15 +08:00
Eve bd828076ab feat(editor): add explain plan shortcut 2026-09-10 15:57:04 +08:00
Eve 981978dcf2 fix(transfer): skip PostgreSQL extension members 2026-09-10 14:56:13 +08:00
zccat 4e686bd00c fix(opengauss): escape comment quotes in pg_get_tabledef ddl 2026-09-10 14:56:03 +08:00
t8y2 ab04deea11 docs(wry): record macos keydown gating divergence 2026-09-10 14:34:50 +08:00
zipg c3f2bbf650 fix(wry): forward unhandled macos keydown to webview 2026-09-10 14:33:14 +08:00
zipg 88f4999691 style(tabs): simplify classic compact active tab state 2026-09-10 14:30:34 +08:00
Eve a688aadec2 fix(editor): preserve multi-click selection drags 2026-09-10 14:30:23 +08:00
t8y2 b37f63e716 fix(docs): reword draft timeout without screenshots and retry once 2026-09-10 14:08:15 +08:00
Loc Nguyen 6ddf4f5a10 fix(mongodb): support upsert option in legacy agent 2026-09-10 13:21:08 +08:00
Eve e17246c0f6 fix(structure): respect identifier quote preference 2026-09-10 13:14:19 +08:00
CSXFanMeng dd708fc052 feat(backup): show connection groups in backup selector 2026-09-10 12:46:56 +08:00
onenewcode 087351f73f fix(mongodb): keep collection results across tab switches 2026-09-10 12:46:18 +08:00
zipg 1e9a3c0f67 fix(redis): raise filter button selected-state contrast 2026-09-10 12:45:41 +08:00
zipg 86eabb7224 fix(vastbase): reset timeout for each query page 2026-09-10 12:45:02 +08:00
Eve d48167ef66 fix(editor): include SQL Server variable prefix in word selection 2026-09-10 12:44:23 +08:00
二丫讲梵 2cee2da21d feat(consul): improve deregister flow and agent write hints 2026-09-10 12:43:46 +08:00
二丫讲梵 0f07d337c3 feat(mongodb): distinguish missing fields from BSON null in grid 2026-09-10 11:59:28 +08:00
Abeautifulsnow 464f2f33c5 feat(object-browser): add table Add to AI context menu entry 2026-09-10 11:59:20 +08:00
zipg 182b6f3d8d fix(oracle): close cursors between batch queries 2026-09-10 11:56:14 +08:00
二丫讲梵 e96af4aa30 feat(updater): download and prepare desktop updates in background 2026-09-10 11:44:18 +08:00
t8y2 7affad8ae0 chore(core): fix rustfmt drift 2026-09-10 11:29:01 +08:00
zipg 0456af3f2c fix(dameng): fall back to dictionary DDL for some views 2026-09-10 11:25:52 +08:00
zipg 72bfd74e5e fix(tdengine): keep database context when viewing tables 2026-09-10 11:25:13 +08:00
二丫讲梵 436dd7129f feat(toolbar): move sidebar expand button to toolbar 2026-09-10 11:24:35 +08:00
一颗红心 dc1b651949 fix(redis): keep key browser filters across tab switches 2026-09-10 11:23:57 +08:00
二丫讲梵 b2d2507aec feat(process-list): batch cancel MySQL queries 2026-09-10 11:23:18 +08:00
二丫讲梵 1d9cfe45f9 feat(sql): add COMMENT keyword completion 2026-09-10 11:22:39 +08:00
C.J.S 0e32e003f5 fix(editor): keep drag selection responsive 2026-09-10 11:20:56 +08:00
github-actions[bot] dbaa640762 chore(packages): release 0.4.84 [skip node-packages-release] 2026-09-09 17:15:25 +00:00
github-actions[bot] 1324aba3f9 chore: bump module versions [skip ci] 2026-09-09 17:10:03 +00:00
github-actions[bot] 32eeb92039 chore(jdbc): bump plugin version [skip ci] 2026-09-09 16:58:10 +00:00
t8y2 5536ddf16b chore(release): prepare v0.6.9 2026-09-10 00:57:23 +08:00
zipg bc4ed3b89f fix(desktop): restore tab bar after layout changes 2026-09-10 00:42:36 +08:00
二丫讲梵 ce3bd1c8ed feat(backup): support custom backup file name templates and path preview 2026-09-10 00:29:15 +08:00
以谶 38be5ba35c feat(cassandra): add TLS store support 2026-09-10 00:28:58 +08:00
jearhe e2240d4ed1 fix(doris): enable explain plan support 2026-09-10 00:28:40 +08:00
t8y2 1663f3633f fix(oracle): reduce view query page round trips 2026-09-10 00:00:06 +08:00
cowking 7b0ea38bbf feat(settings): highlight settings items on hover 2026-09-09 23:53:16 +08:00
cowking 758ac1b5a2 fix(saved-sql): scope file names to folders 2026-09-09 23:53:09 +08:00
Eve aca3faa762 fix(editor): split Doris range partition statements 2026-09-09 23:53:03 +08:00
Zzz 619cd8f897 fix(redis): honor configured value scan page size 2026-09-09 23:52:55 +08:00
zipg 3caa4f2b89 fix(ui): refine tab bar group layout and focus fallback 2026-09-09 22:44:56 +08:00
Guoyu Su 7ec1d9f6e2 fix(import): honor absolute Excel worksheet row numbers 2026-09-09 22:36:33 +08:00
zipg c32be4185f fix(ai): localize MiniMax default endpoint 2026-09-09 22:36:26 +08:00
Abeautifulsnow 41b5e51dcc feat(grid): preserve data-grid viewport and selection across tab switches 2026-09-09 22:36:20 +08:00
zhangyc 2b26bd6a1a fix(connection): stop background search auto-reconnect 2026-09-09 22:36:13 +08:00
Tara a9ec07e651 fix(etcd): bound member status probe timeout 2026-09-09 22:36:07 +08:00
zipg f3e55a8337 fix(grid): reset filter editor caret offset on expand 2026-09-09 22:35:58 +08:00
zipg b84d162049 fix(oracle): stop ROWID pagination injection on views 2026-09-09 22:35:51 +08:00
t8y2 c8c7a40e46 fix(editor): smooth SQL editor state synchronization 2026-09-09 22:34:52 +08:00
t8y2 7434295b1f chore(format): fix oxfmt drift in data grid export 2026-09-09 16:46:12 +08:00
t8y2 9e18dc2f58 chore(i18n): translate changed host fingerprint prompts for seven locales 2026-09-09 16:32:01 +08:00
64673a c30fc33305 feat(ssh): prompt to reset a changed host fingerprint 2026-09-09 16:29:31 +08:00
zipg fd44331876 fix(editor): verify current-schema tables in semantic diagnosis 2026-09-09 16:05:43 +08:00
Eve 3b46109d36 fix(transfer): preserve exact PostgreSQL comments 2026-09-09 16:05:36 +08:00
Eve 3d34f318e6 fix(completion): show derived table columns 2026-09-09 16:05:28 +08:00
t8y2 78b0f935be fix(postgres): preserve index key ordering on transfer
Closes #8559
2026-09-09 15:27:31 +08:00
t8y2 19b0c49db4 fix(rocketmq): handle query empty responses
Closes #8469
2026-09-09 15:27:31 +08:00
t8y2 918ff69338 fix(export): preserve MongoDB Extended JSON
MongoDB JSON exports now use mongo_copy_documents so nested values and BSON date and object wrappers survive serialization without changing SQL export rows.

Closes #8558
2026-09-09 15:27:31 +08:00
cowking 23ebc67e6c feat(grid): support UUID v4 and v7 value generation 2026-09-09 14:57:03 +08:00
t8y2 a04ba3abd4 chore(i18n): translate redis key grouping strings for six locales 2026-09-09 14:49:02 +08:00
Guoyu Su 1cbb5abf3b feat(redis): add custom key groups 2026-09-09 14:44:45 +08:00
jearhe a535cf8f51 fix(redis): disable SQL formatting for Redis queries 2026-09-09 14:44:40 +08:00
HolyeTyrone 894ecdec42 fix(transfer): count tables in rebuild confirm and localize hints 2026-09-09 14:44:35 +08:00
zyuer 4e8a58a08c fix(mcp): restore MongoDB database discovery 2026-09-09 14:22:05 +08:00
Eve cd43ff07f0 fix(completion): quote SQL Server identifiers 2026-09-09 13:58:15 +08:00
t8y2 b2784def56 chore(format): fix oxfmt drift in data grid export files 2026-09-09 13:55:41 +08:00
t8y2 775b45f235 fix(postgres): preserve transferred sequence definitions
Closes #8556
2026-09-09 13:13:09 +08:00
t8y2 c8ef1f44f3 fix(oracle): copy temporal cells in display format
Closes #8569
2026-09-09 13:13:09 +08:00
t8y2 c63d7927f9 fix(ai): preserve references when copying failed messages
Closes #8552
2026-09-09 13:13:09 +08:00
Jamal Ali 3408ddbb65 feat(i18n): add Azerbaijani locale 2026-09-09 12:30:53 +08:00
zipg ef1d7ae9c4 fix(ddl): preserve safe Oracle and Dameng identifiers 2026-09-09 12:28:47 +08:00
二丫讲梵 99c698f72e refactor(settings): group SQL file size settings by category 2026-09-09 12:26:39 +08:00
t8y2 776d01199c fix(metadata): scope schemas to current catalog
Closes #8461
2026-09-09 12:13:16 +08:00
t8y2 271ed56a00 fix(dameng): preserve VARCHAR2 leading zeroes
Closes #8537
2026-09-09 12:13:16 +08:00
t8y2 886ba2c596 fix(db2): read CLOB query values as text
Closes #8532
2026-09-09 12:13:16 +08:00
二丫讲梵 a0cce8cc8f feat(settings): preview locale and serialize UI scale application 2026-09-09 12:04:54 +08:00
二丫讲梵 004b27a3c2 feat(grid): keep filter editor expanded 2026-09-09 12:04:32 +08:00
onenewcode 08edb60772 fix(mq): hide database browser for message queue connections 2026-09-09 12:04:10 +08:00
Eve 047cf96f8e fix(zookeeper): recursively delete non-empty znodes 2026-09-09 12:02:10 +08:00
onenewcode 451e50b2fb fix(grid): preserve data tab scroll and search across switches 2026-09-09 12:01:49 +08:00
Eve ed29ef9eed fix(hive): keep CTE outside count subquery 2026-09-09 11:58:54 +08:00
t8y2 b80135dbbd fix(editor): reduce SQL editor typing latency 2026-09-09 11:19:48 +08:00
t8y2 6d1eb026cd fix(iris): qualify Cache JDBC table previews
Refs: #8514
2026-09-09 10:35:49 +08:00
t8y2 a4e4fd0d2f fix(mongodb): preserve collection queries and preview selection
Closes #8306
2026-09-09 10:28:03 +08:00
t8y2 7835f4c309 fix(dameng): emit modify syntax for column changes
Closes #8517
2026-09-09 10:28:03 +08:00
t8y2 9690feee4f fix(jdbc): read long varchar values through string accessor
Closes #8514
2026-09-09 10:28:02 +08:00
github-actions[bot] 895661710b chore(packages): release 0.4.83 [skip node-packages-release] 2026-09-08 18:04:54 +00:00
t8y2 3c06e9a432 chore(release): prepare v0.6.8 2026-09-09 01:43:17 +08:00
t8y2 646745a880 fix(grid): align font settings spec with formatter tick 2026-09-09 01:32:38 +08:00
t8y2 d191c93ccc fix(grid): reset column filter offset on open 2026-09-09 01:31:13 +08:00
t8y2 20378d61ad fix(grid): initialize formatters before column sizing 2026-09-09 01:20:33 +08:00
t8y2 f267f4ccfb fix(grid): guard DataGrid startup initialization 2026-09-09 01:07:06 +08:00
t8y2 210512e842 fix(redis): bound collection detail page loading
Closes #8371
2026-09-09 00:38:56 +08:00
t8y2 3d450a42cc fix(sidebar): locate reloaded data tables
Closes #8413
2026-09-09 00:38:56 +08:00
t8y2 03cfeba8f7 refactor(grid): extract large value workflow 2026-09-09 00:31:02 +08:00
二丫讲梵 69ef418d5f fix(backup): enhance backup configuration and history management 2026-09-09 00:15:12 +08:00
t8y2 789740c9be refactor(grid): extract cell detail edit workflow 2026-09-09 00:02:30 +08:00
t8y2 16eb52c81f refactor(grid): extract column filter workflow 2026-09-08 23:46:42 +08:00
t8y2 ecc19fdd30 style(core): format metadata fixes 2026-09-08 23:23:36 +08:00
t8y2 d4f4538843 refactor(grid): split cell detail panels 2026-09-08 23:22:14 +08:00
t8y2 4a3d1b1db3 fix(elasticsearch): paginate beyond the result window
Closes #8426
2026-09-08 23:11:14 +08:00
t8y2 d6f919208f fix(postgres): recover empty partition column metadata
Closes #8486
2026-09-08 23:11:14 +08:00
t8y2 ca44609c56 fix(oracle): resolve synonym columns across schemas
Closes #8464
2026-09-08 23:11:14 +08:00
t8y2 40016d45cc refactor(grid): extract column filter popover 2026-09-08 23:06:00 +08:00
zhangyc e5c6cacec4 fix(grid): align query export icon semantics 2026-09-08 22:57:01 +08:00
t8y2 2e0d003538 refactor(grid): extract table info panels 2026-09-08 22:53:23 +08:00
luo jiyin cd6e0d6e92 perf(sidebar): add large schema benchmark 2026-09-08 22:37:09 +08:00
leekeep b7c9312c9f feat(mysql): add batch auto-increment start setting 2026-09-08 22:35:06 +08:00
Eve dcbe9701ef fix(mcp): expose batch tool permission 2026-09-08 22:34:57 +08:00
Eve f102c5d2d5 fix(mcp): refresh pools after connection changes 2026-09-08 22:34:47 +08:00
Abeautifulsnow 4528a15492 feat(redis): add hash field and value copy targets 2026-09-08 22:34:36 +08:00
二丫讲梵 798733fd29 feat(ai): add panel shortcut and conversation restore 2026-09-08 22:34:26 +08:00
二丫讲梵 b70d4153a4 style(tabbar): improve tooltip wrapping and layout 2026-09-08 22:32:14 +08:00
t8y2 3bb54625f3 refactor(grid): extract table metadata loaders 2026-09-08 22:25:20 +08:00
t8y2 ed20ed275d refactor(grid): extract column formatter workflow 2026-09-08 22:04:49 +08:00
t8y2 acb0070f9e fix(editor): keep wrapped classic tabs at a single-row height 2026-09-08 21:41:49 +08:00
t8y2 49923296d8 refactor(connection): unify tree metadata loading 2026-09-08 21:26:42 +08:00
t8y2 92f052fa6c refactor(grid): extract local filter value logic 2026-09-08 21:12:04 +08:00
t8y2 52f23e3c79 test(settings): align assertions with 60s query timeout 2026-09-08 20:55:21 +08:00
t8y2 1431d4996f fix(settings): centralize draft normalization and use 60s query timeout 2026-09-08 20:39:39 +08:00
t8y2 39e34d44f7 feat(settings): configure SQL file size limits 2026-09-08 19:42:32 +08:00
Eve 9a0e3e744c fix(jdbc): prefer native DDL for MySQL wrappers 2026-09-08 19:32:52 +08:00
zipg 1268dcdf52 fix(sqlserver): avoid legacy schema switch during writes 2026-09-08 19:30:44 +08:00
github-actions[bot] f445d008de chore(packages): release 0.4.82 [skip node-packages-release] 2026-09-08 11:13:37 +00:00
github-actions[bot] 6cefb3d372 chore: bump module versions [skip ci] 2026-09-08 10:53:50 +00:00
t8y2 755f7b31bd perf(sidebar): reduce saved SQL tree and export metadata work 2026-09-08 18:47:13 +08:00
t8y2 cd6ee718b6 fix(editor): optimize tab group rendering 2026-09-08 17:49:32 +08:00
t8y2 09b8439c86 chore(release): prepare v0.6.7 2026-09-08 17:26:48 +08:00
zipg 05b719e7a6 feat(editor): show pinned tabs in separate row with per-row scroll 2026-09-08 17:11:30 +08:00
t8y2 3b9cd3eec4 fix(postgres): decode multi-dimensional arrays as server literals
Closes #8457
2026-09-08 17:01:55 +08:00
Eve e8b8e20eaa fix(editor): isolate new query tab position 2026-09-08 16:48:47 +08:00
Eve 5c400a4498 fix(oracle): avoid blocking query object discovery 2026-09-08 16:48:14 +08:00
二丫讲梵 cbd769d36b feat(nacos): show conflict config diff in copy preview 2026-09-08 16:47:39 +08:00
HolyeTyrone 538aeed99c feat(transfer): add rebuild structure and data mode 2026-09-08 16:46:39 +08:00
Abeautifulsnow 2b2d1f0d95 feat(ai): export conversation as markdown or html 2026-09-08 16:46:22 +08:00
t8y2 d0832f17f4 test(structure): assert pending initial-tab replay in draft restore
Refs: #8419
2026-09-08 16:42:52 +08:00
t8y2 c7dbaf5965 fix(structure): stop replaying consumed initial tab on remount
Closes #8419
2026-09-08 15:10:48 +08:00
t8y2 a081f6a839 feat(sponsors): link hualong ai entries to promo register url 2026-09-08 14:36:49 +08:00
t8y2 da217c2e34 fix(sqlserver): decode legacy CP437/CP850 collations
Closes #8417
2026-09-08 14:20:59 +08:00
Eve 47e41bb48a fix(intersystems): read out-of-range integers via BigDecimal 2026-09-08 12:47:44 +08:00
Eve 4c0ca9293a fix(mqtt): preserve paused message selection 2026-09-08 12:44:33 +08:00
jearhe 301e6e2802 fix(sqlite): interrupt running statement on cancel to unblock query tab 2026-09-08 12:43:36 +08:00
zipg 579317b4bf fix(settings): migrate legacy null copy setting 2026-09-08 12:19:51 +08:00
КГБ Lieu 0bebb90790 fix(import): honor connection query timeout for sql file execution 2026-09-08 12:13:22 +08:00
t8y2 d361ef526e fix(grid): resolve oracle jdbc edits with folded table names
Closes #8368
2026-09-08 12:07:49 +08:00
t8y2 9392a68832 fix(dameng): keep object tab schema across reloads
Closes #8301
2026-09-08 12:07:49 +08:00
t8y2 ab5fa637e0 fix(redshift): drop user placeholder from preserved search path
Closes #8344
2026-09-08 12:07:04 +08:00
zipg 04ab5ec96e fix(settings): preserve legacy browse-objects behavior on activation 2026-09-08 11:56:18 +08:00
Abeautifulsnow 8642e1a599 feat(object-browser): preserve object list on return via stale cache 2026-09-08 11:51:50 +08:00
Eve a6cd501a78 fix(elasticsearch): support fuzzy index completion 2026-09-08 11:51:07 +08:00
Eve 80cd461ea1 fix(grid): keep restored sort out of where clause 2026-09-08 11:50:26 +08:00
onenewcode 4b64656069 fix(mq): hide new-query entry for message queue connections 2026-09-08 11:49:42 +08:00
Eve d0b9e49b7e fix(structure): load table comment on initial DDL tab 2026-09-08 11:48:59 +08:00
miracle 39aa663ebf feat(app): add configurable global background wallpaper 2026-09-08 11:19:24 +08:00
zipgandzipg 5fb667716f feat(editor): 持久化SQL编辑器光标位置
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-09-08 11:01:41 +08:00
zipg 772ce36de8 feat(grid): edit Oracle join query results via ROWID 2026-09-08 10:32:09 +08:00
t8y2 abc32f1c71 fix(cache): avoid quoted identifiers and alias lists for cache sql
Closes #8340
2026-09-08 01:53:50 +08:00
t8y2 4f2c67e34e fix(import): route oracle jdbc imports through insert all
Closes #8309
2026-09-08 01:53:50 +08:00
t8y2 d2de504248 fix(mysql): carry datetime precision into current_timestamp clauses
Closes #8402
2026-09-08 01:53:50 +08:00
二丫讲梵 eb87c9e837 feat(nacos): improve config history and diff viewing 2026-09-08 01:47:36 +08:00
zipg 2e72bee9a1 fix(editor): locate SQL errors after pagination rewriting 2026-09-08 01:43:13 +08:00
二丫讲梵 3ae1548de0 feat(settings): preview theme and UI fonts on hover 2026-09-08 01:21:12 +08:00
hb b4d807b818 fix(db2): read view source from SYSCAT.VIEWS 2026-09-08 01:21:08 +08:00
githubasda 7961e688f2 feat(settings): settings import/export and WebView2 clipboard history 2026-09-08 00:43:45 +08:00
zipg d44798f70f feat(export): add SQL INSERT export crash diagnostics 2026-09-08 00:43:39 +08:00
zipg 9ae2e70030 fix(grid): keep filter editor open after applying filters 2026-09-08 00:15:57 +08:00
C.J.S 0ac2d33188 fix(backup): confirm legacy macOS destinations before manual runs 2026-09-08 00:14:08 +08:00
zipg 889fa4f5d0 fix(editor): unquote DDL view when identifier quoting disabled 2026-09-08 00:14:02 +08:00
Melody Ruch 5814ff0088 fix(sqlite): reject ambiguous keyless updates on duplicate rows 2026-09-07 23:51:37 +08:00
zipg 9e0381f737 fix(grid): expand multiline WHERE conditions after paste 2026-09-07 23:51:32 +08:00
zipg 743583249f fix(grid): add spacing between preview and save buttons 2026-09-07 23:51:28 +08:00
zipg 5efbe360d9 feat(postgres): export all schemas in one SQL file 2026-09-07 23:20:46 +08:00
zipg 0bacb5effd feat(export): apply connection query timeout to database backup 2026-09-07 23:11:01 +08:00
zipg e09437ea8f feat(transfer): apply connection query timeout to transfer statements 2026-09-07 22:55:19 +08:00
auth0 ca73e9f2eb fix(sqlserver): preserve comments during pagination 2026-09-07 22:50:33 +08:00
Elias 3e75be87eb fix(xugu): enable safe row editing without primary keys 2026-09-07 22:44:30 +08:00
Abeautifulsnow 39b2d2a6ac feat(ai): add sandboxed HTML preview for AI rich content 2026-09-07 22:44:23 +08:00
Melody Ruch 11e0db5acb fix(h2): detect remote server version via H2VERSION() 2026-09-07 21:26:30 +08:00
zipg 96a3dc052a fix(ui): responsive table info sidebar and SQL toolbar layout 2026-09-07 21:26:11 +08:00
Melody Ruch e3b9bae67e fix(grid): keep new top row visible after a toolbar refresh 2026-09-07 21:25:51 +08:00
t8y2 fcc7dfb896 fix(editor): restore viewport on uncached tab activations
Closes #8374
2026-09-07 19:21:42 +08:00
t8y2 e3d5d14183 fix(grid): keep manual primary keys in exclude-PK insert copies
Closes #8293
2026-09-07 18:24:37 +08:00
t8y2 f64827a607 style(grid): flatten error card visual nesting 2026-09-07 16:42:08 +08:00
二丫讲梵 0ec7bef098 fix(editor): restore focus after query tab switches 2026-09-07 15:27:02 +08:00
t8y2 5f81ed2e11 feat(tabs): show table comments in tab tooltips
Closes #8342
2026-09-07 14:10:16 +08:00
Elias c0c97ef0e4 feat(xugu): add object dependency inspection 2026-09-07 14:06:55 +08:00
jearhe dc7b905220 feat(compare): keep schema and data compares running in background 2026-09-07 14:04:39 +08:00
Abeautifulsnow 29c6d1fbed feat(ai): render chart-json blocks as interactive charts 2026-09-07 14:03:57 +08:00
zipg 852ad1c808 test(objects): cover search persistence after table close 2026-09-07 13:54:22 +08:00
t8y2 d29befe81c fix(editor): isolate per-tab undo history on tab switches 2026-09-07 13:20:35 +08:00
github-actions[bot] 7bd2c62f65 chore(packages): release 0.4.81 [skip node-packages-release] 2026-09-07 02:54:24 +00:00
github-actions[bot] 89dbbfff3f chore: bump module versions [skip ci] 2026-09-07 02:33:41 +00:00
t8y2 2a3e1c1439 chore(release): prepare v0.6.6 2026-09-07 10:00:53 +08:00
二丫讲梵 691ecd084b feat(mcp): add resource group scopes with permission inheritance 2026-09-07 02:18:28 +08:00
二丫讲梵 2eaf7f9914 feat(editor): add toggle for SQL identifier quoting 2026-09-07 01:11:42 +08:00
zipg 14e1d4f25b fix(query): enable editing multi-table JOIN result sets 2026-09-07 00:54:59 +08:00
Melody Ruch 93a4b5803a fix(postgres): fall back to default-aware signature for proc DDL lookup
Closes #8279
2026-09-06 22:14:30 +08:00
二丫讲梵 148345494e fix(desktop): unify grouped tab bar portal across special pages 2026-09-06 20:39:34 +08:00
jearhe 3123e962c8 feat(schema-diff): support case-insensitive table and column matching 2026-09-06 18:24:44 +08:00
二丫讲梵 0ef8369e2b feat(desktop): add pinned auto-show table info panel 2026-09-06 15:01:27 +08:00
Melody Ruch 17441ad0d2 fix(dameng): detect identity columns via DDL fallback for unprivileged users 2026-09-06 15:00:29 +08:00
dienaso 16491cc3dd fix(editor): keep Enter and Tab with the IME during composition 2026-09-06 15:00:00 +08:00
t8y2 b9ea54350d fix(dameng): list all schemas in object browse for normal users
Closes #7699
2026-09-06 14:37:01 +08:00
t8y2 260c6710d8 fix(kingbase): iterate multi-host cluster endpoints with per-host ports
Closes #7885
2026-09-06 13:01:37 +08:00
t8y2 5acc5f7e82 fix(query): serialize 64-bit integers beyond js safe range as strings
Closes #7832
2026-09-06 13:01:37 +08:00
t8y2 c0b4fa09cc test(query): lock GROUP BY survival in MySQL pagination and count SQL
Refs: #7832
2026-09-06 13:01:37 +08:00
t8y2 450d9fb9ee feat(ai): add HuaLongAI partner provider preset 2026-09-06 12:37:44 +08:00
Elias 3e82d6d650 feat(xugu): add read-only server monitoring dashboard 2026-09-06 10:57:08 +08:00
t8y2 d6c4fa1aff fix(docs): add Caché entry to database support table 2026-09-06 10:57:07 +08:00
t8y2 68b1c4e971 fix(completion): align semantic replacement range with kept prefix
Closes #7757
2026-09-06 10:57:07 +08:00
t8y2 42fb2a48aa fix(admin-sql): truncate starrocks tables instead of where-less delete
Closes #8155
2026-09-06 10:57:07 +08:00
t8y2 c0d0a62fa8 fix(postgres): keep fk compat queries lateral-free for pre-9.3
Closes #8173
2026-09-06 10:57:06 +08:00
jearhe 53be33c86c feat(mqtt): add payload search to MQTT console 2026-09-06 10:57:06 +08:00
t8y2 a1192fc651 docs: add HuaLongAI sponsor 2026-09-06 10:57:05 +08:00
Melody Ruch 0bccda193b fix(ssh): follow ssh config ProxyJump hops in tunnels 2026-09-06 10:35:38 +08:00
dienaso 0938b6bee0 fix(cassandra): report materialized views and skip db-side sort 2026-09-06 10:31:47 +08:00
dienaso eea3faa224 fix(compare): match data compare columns case-insensitively 2026-09-06 10:29:48 +08:00
jearhe ab35a5b1da feat(export): add SQL INSERT batch or single-row mode 2026-09-06 10:27:34 +08:00
Elias 5d65be03a3 fix(xugu): exclude synthetic scopes from schema export 2026-09-06 10:25:16 +08:00
zipg 6886fde709 fix(sql): stop flagging ORDER BY aliases as unknown columns 2026-09-06 10:23:34 +08:00
t8y2 27369a40dd fix(editor): tokenize t-sql temp table names for highlighting
Closes #8267
2026-09-06 10:18:37 +08:00
Melody Ruch c0390bff16 fix(postgres): add legacy sequence catalog fallback for PG 9 2026-09-05 23:26:19 +08:00
Melody Ruch 72ffe7e224 fix(oracle): read SDO_GEOMETRY via native driver as WKT 2026-09-05 22:57:25 +08:00
Melody Ruch 5244e57dca fix(objects): keep browse search filter per tab 2026-09-05 22:28:14 +08:00
Melody Ruch 38df8e3097 fix(sidebar): bound row bleed for horizontal scroll 2026-09-05 22:27:32 +08:00
miracle 7e06992f3b feat(mcp): add Qoder configuration 2026-09-05 22:22:55 +08:00
Melody Ruch 644812fa07 fix(sqlite): show SQLCipher unsupported hint for SSH tunnels 2026-09-05 22:20:08 +08:00
Melody Ruch 3c3ab96421 fix(grid): clear identity columns when pasting new rows 2026-09-05 22:19:05 +08:00
Zzz bc2d32f20f fix(redis): bound implicit key and value scanning 2026-09-05 22:18:04 +08:00
hanxuanyu 4bef571fc4 fix(editor): preserve editor selection during result auto refresh 2026-09-05 22:17:01 +08:00
Melody Ruch eab1f6344b fix(diff): default missing VARCHAR length for MySQL-family targets 2026-09-05 22:15:59 +08:00
Melody Ruch 781bb46d3a fix(transfer): rebuild pool on transient drop in batch transfer 2026-09-05 22:14:58 +08:00
Melody Ruch 559b7febad fix(schema-diff): detect MySQL integer display width changes 2026-09-05 22:13:39 +08:00
Melody Ruch 63363c2834 fix(editor): collapse shared results pane for unexecuted queries 2026-09-05 22:11:59 +08:00
Melody Ruch a1e987ec0f fix(layout): avoid blank column-mode workspace on special tabs 2026-09-05 22:06:21 +08:00
Melody Ruch fc621a3840 fix(grid): include Mongo JSON preview in grid track sizing 2026-09-05 22:06:07 +08:00
Melody Ruch 3907cb1372 fix(settings): stop AI deep link from hijacking later settings opens 2026-09-05 22:04:12 +08:00
t8y2 da03c1f0c4 fix(document): keep collection filter and sort across tab switches
Refs: #8035
2026-09-05 20:00:04 +08:00
t8y2 290576cb4f fix(export): keep numeric scale visible in xlsx cells
Excel renders numeric cells with the General format in the shortest
form, so database values like 0.3500000 exported from decimal columns
displayed as 0.35 and the declared scale looked truncated (#8225).
Pre-register one 0.0...0 number format per fractional-digit count (up
to 15) plus right/left alignment variants after the six fixed styles,
and switch fractional numeric cells to the matching format so Excel
keeps the decimals visible. Exponent-notation values and scales beyond
15 decimals keep the previous General rendering.

Refs: #8225
2026-09-05 19:21:21 +08:00
t8y2 8598416998 fix(sidebar): honor schema filter for single-db picker key
OceanBase Oracle (and other single-database) connections persist their
schema checkbox filter under the empty database key, but the editor
toolbar schema picker addresses the bucket with the "_" single-db
sentinel, so the strict filter never applied and unchecked schemas
still appeared (#8145). Normalize the sentinel before the lookup.

Also keep the login schema visible for user@tenant usernames: the
OceanBase login embeds the tenant in the username, so the bare-name
comparison hid SYS from sys@tenant sessions even though the user's own
schema should always stay selectable.

Closes #8145
2026-09-05 19:21:21 +08:00
t8y2 0b9ddff19c fix(zookeeper): drop unsupported sql query capability
Closes #8215
2026-09-05 16:10:39 +08:00
t8y2 641f82c962 fix(redshift): skip WKB probe slice on multibyte text values
Redshift reads run every simple-query cell through decode_pg_text_wkb
with an unknown column type. The probe sliced hex[..2] before the
is_ascii() guard, so any cell starting with a 3-4 byte UTF-8 character
with an even byte length >= 10 panicked on a non-char boundary and
aborted the process under panic = abort (Windows BEX64 in dbx.exe).
Check is_ascii() first so ordinary multibyte text passes through.

Closes #7958
2026-09-05 16:10:38 +08:00
t8y2 cbaccb3a3a fix(iris): raise jdbc query prefetch for large results
Refs: #7953
2026-09-05 14:38:57 +08:00
t8y2 2f63c34ffa fix(redis): bound subtree fill and resume on demand
Closes #7918
2026-09-05 14:38:57 +08:00
t8y2 51d340eb9b fix(transfer): pin char length unit for oracle-family targets
Closes #8101
2026-09-05 14:38:56 +08:00
Resheba 54d7a2c05d feat(ai): add skip TLS certificate verification for AI providers 2026-09-05 14:03:58 +08:00
t8y2 dca6d0e5f6 fix(oracle): recover row-iteration panics to keep agent alive
Refs: #7934
2026-09-05 13:52:52 +08:00
t8y2 295db2903b fix(oracle): fall back to placeholder when sde extproc fails
Closes #7952
2026-09-05 13:52:52 +08:00
t8y2 7390cf6684 fix(transfer): emit dameng comment ddl in table transfer
Closes #8217
2026-09-05 13:52:52 +08:00
zhangyaxi 0b8c7d700a feat(sidebar): navigate the connection tree with arrow keys 2026-09-05 13:25:03 +08:00
Melody Ruch a196a68d38 fix(redis): sync detail pane size badge on auto refresh 2026-09-05 13:17:56 +08:00
github-actions[bot] 9e9e20a57b chore: bump module versions [skip ci] 2026-09-05 05:03:18 +00:00
Lem26 73c167bed3 fix(editor): let mouse hide/re-show the shared query results pane 2026-09-05 13:02:41 +08:00
github-actions[bot] b8d4388caf chore(jdbc): bump plugin version [skip ci] 2026-09-05 04:59:41 +00:00
t8y2 5a26faae22 chore(release): prepare v0.6.5 2026-09-05 12:58:59 +08:00
t8y2 c66a699aa7 fix(layout): merge special page tabs into the editor tab strip
Settings and driver store no longer sit on a strip of their own: while
open but inactive they append to the focused group's tab strip (v0.6.2
order), and while active the app-level strip re-renders the open tabs
with mode icons and inactive styling as the mouse path back to the
editors. Special tabs follow the classic/separated presentation,
including the classic active underline.
2026-09-05 12:46:32 +08:00
t8y2 789d65d3df fix(layout): keep group tab strips visible on special pages
Opening settings or the driver store hid the whole workspace, so every
editor tab vanished with no mouse path back. The workspace now stays
mounted and hides only the pane content, keeping the strips clickable.
Special-page tabs also follow the classic flat tab presentation instead
of always rendering as separated-layout pills.
2026-09-05 11:56:58 +08:00
t8y2 46afd0dd5e fix(editor): take tab id first in toolbar context switching
Closes #8218
2026-09-05 11:36:31 +08:00
t8y2 cae4c5fde3 fix(grid): take tab id first in grid execute sql action
Closes #8216
2026-09-05 11:25:42 +08:00
Melody Ruch 124819e1fa fix(dameng): use ALTER TABLE constraints for unique indexes 2026-09-05 11:16:43 +08:00
t8y2 a19a77c6df fix(test): silence late cache-delete errors that race vitest teardown 2026-09-05 10:48:24 +08:00
t8y2 f661ff7ff3 fix(export): surface lenient per-object failures in export completion
Closes #8184
2026-09-05 10:04:22 +08:00
二丫讲梵 116aced217 feat(editor): group editor tabs by database 2026-09-05 10:01:54 +08:00
jearhe b5fcb3a832 feat(diff): manual table mapping for schema compare 2026-09-05 09:52:52 +08:00
Melody Ruch d5a511b719 fix(editor): highlight builtin SQL functions in editor themes 2026-09-05 09:22:16 +08:00
Melody Ruch ab6a7645f6 fix(export): preserve datetime precision across CSV and XLSX exports 2026-09-05 09:22:08 +08:00
t8y2 ffe1e58fbb fix(editor): restore keyword highlighting for standard-SQL dialects
Closes #8123
2026-09-05 09:11:22 +08:00
t8y2 2a45374490 fix(agents): allow documented non-listing drivers in coverage matrix 2026-09-05 09:06:07 +08:00
Melody Ruch aeab0f8b03 fix(completion): resolve Oracle current schema for JDBC star expansion 2026-09-05 09:04:57 +08:00
t8y2 4cc6d0959d fix(desktop): open streaming sql executor for oversized sql files
Closes #8100
2026-09-05 08:50:40 +08:00
t8y2 3be523b2f5 fix(jdbc): render generic table DDL for unmatched external JDBC drivers
Closes #8187
2026-09-05 01:23:35 +08:00
二丫讲梵 ce36a7f40d feat(grid): resizable column filter panel 2026-09-05 01:03:38 +08:00
Guoyu Su 61aa00bf8f fix(redis): preserve viewport after fetch all 2026-09-05 01:03:30 +08:00
wnyr 14168cc36a fix(editor): enable backslash escapes for MySQL-family dialects 2026-09-05 00:53:59 +08:00
zipg a9ce18aef1 fix(grid): select cell editor text only once on focus 2026-09-05 00:42:07 +08:00
jearhe c258fe86be fix(docs): clear docs dialog close button overlap 2026-09-05 00:41:59 +08:00
jearhe 23a4ca8903 fix(grid): responsive cell detail layout in narrow panels 2026-09-05 00:41:51 +08:00
Lem26 719aa3e849 fix(tabs): keep overflow popup search from filtering tab strip 2026-09-05 00:41:15 +08:00
Guoyu Su 6ac2b68553 perf(redis): keep fetch-all finalization responsive 2026-09-05 00:40:52 +08:00
t8y2 b8becde86a fix(desktop): replace lookbehind regex that crashed legacy WebKit at startup
Closes #8202
2026-09-05 00:13:55 +08:00
t8y2 8b8c0b8075 feat(cache): add built-in InterSystems Caché agent driver
Closes #7972
2026-09-04 23:40:07 +08:00
t8y2 6bdfd72809 perf(test): raise local vitest workers to 8 with 10s timeout
The 4-worker cap existed only to keep deferred-promise tests inside
Vitest's old 5s default when several workers pay the first dynamic
import of the large store modules at once; a 10s testTimeout absorbs
that without capping throughput. CI keeps 4 workers (4-vCPU runners
already share CPU with vue-tsc/oxlint/oxfmt via pnpm check). Also add
pnpm test:watch for incremental local runs.
2026-09-04 21:29:04 +08:00
github-actions[bot] 51a642d179 chore: bump module versions [skip ci] 2026-09-04 13:09:10 +00:00
1664 changed files with 198647 additions and 24826 deletions
+3
View File
@@ -11,6 +11,9 @@ apps/desktop/tsconfig.json text eol=lf
apps/desktop/*.ts text eol=lf
src-tauri/windows/nsis/**/*.nsi text eol=lf
# Keep plugin dev-host formatting checks consistent on Windows checkouts.
plugins/sdk/dev-host/** text=auto eol=lf
# Keep tests available for review and CI without counting them as shipped code
# in GitHub's repository language breakdown.
packages/app-tests/** linguist-vendored
+29 -36
View File
@@ -7,58 +7,51 @@ body:
attributes:
value: |
感谢反馈。涉及敏感信息请脱敏。请尽量提供数据库类型、版本、复现步骤、截图和错误日志。
Thanks for reporting this. Redact sensitive information and include the database type/version, reproduction steps, screenshots, and logs when possible.
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。
You can copy the DBX version, runtime, operating system, and architecture in the app via “Settings → About → Copy support information”.
- type: input
id: database
attributes:
label: 数据库类型和版本
description: 例如 PostgreSQL 16、MySQL 8.0、Redis 7.2。
placeholder: "例如:PostgreSQL 16.2"
validations:
required: true
label: 数据库类型和版本 / Database type and version
description: 例如 PostgreSQL 16、MySQL 8.0、Redis 7.2。 / For example, PostgreSQL 16, MySQL 8.0, or Redis 7.2.
placeholder: "例如:PostgreSQL 16.2 / Example: PostgreSQL 16.2"
validations: {required: true}
- type: textarea
id: description
attributes:
label: 问题描述
description: 请按下面的清单逐项填写,截图和错误日志尤其重要。
label: 问题描述 / Problem description
description: 请按下面的清单逐项填写。 / Complete each item below.
placeholder: |
1. 复现步骤:新建连接 → 填写信息 → 点击保存
2. 期望结果:能正常展开 schema
3. 实际结果:一直 loading,无错误提示
4. 最好附上截图和错误日志(设置 → 外观 → 启用"启用调试日志" → 复现问题 → 点击"下载日志",将内容粘贴到这里)
validations:
required: true
1. 复现步骤 / Steps: 新建连接 → 填写信息 → 点击保存 / Create connection → enter details → click Save
2. 期望结果 / Expected: 能正常展开 schema / Schema expands normally
3. 实际结果 / Actual: 一直 loading,无错误提示 / Loading never finishes
4. 附上截图和错误日志 / Attach screenshots and logs when possible
validations: {required: true}
- type: textarea
id: support-info
attributes:
label: 支持信息
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
label: 支持信息 / Support information
description: 粘贴“复制支持信息”的结果;如无法打开应用,请说明原因。 / Paste the copied support information; explain if the app cannot open.
placeholder: |
DBX 版本:v0.5.52
运行环境:桌面端
操作系统:macOS 26.4
系统架构:aarch64
validations:
required: true
DBX 版本 / Version: v0.5.52
运行环境 / Runtime: Desktop
操作系统 / OS: macOS 26.4
系统架构 / Architecture: aarch64
validations: {required: true}
- type: dropdown
id: priority
attributes:
label: 优先级(是否紧急)
description: 紧急 = 希望下个版本优先修。维护者可根据实际情况调整。
label: 优先级(是否紧急) / Priority (urgency)
description: 紧急 = 希望下个版本优先修;维护者可调整。 / Urgent means you hope for the next release; maintainers may adjust it.
options:
- "🔴 P0 紧急 · 下个版本必须修"
- "🟠 P1 重要 · 尽快处理"
- "🟡 P2 普通 · 按排期处理"
- "🟢 P3 低优 · 有空再修"
validations:
required: true
- "🔴 P0 紧急 · 下个版本必须修 / Urgent · Must fix next release"
- "🟠 P1 重要 · 尽快处理 / Important · Fix soon"
- "🟡 P2 普通 · 按排期处理 / Normal · Schedule as appropriate"
- "🟢 P3 低优 · 有空再修 / Low · Fix when possible"
validations: {required: true}
- type: textarea
id: extra
attributes:
label: 补充信息
description: 截图、SQL 样例、连接方式、特殊环境等。
label: 补充信息 / Additional information
description: 截图、SQL 样例、连接方式、特殊环境等。 / Screenshots, SQL samples, connection method, or special environments.
@@ -7,13 +7,14 @@ body:
attributes:
value: |
用于收集数据库兼容性细节,例如连接失败、schema 读取不完整、字段类型显示错误等。请脱敏。
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。
This form collects database compatibility details such as connection failures, incomplete schema reads, and incorrect field types. Redact sensitive information.
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息 / Support information”一键复制。
- type: input
id: database
attributes:
label: 数据库类型和版本
description: 也请注明发行版,如 MariaDB、AWS RDS、TiDB 等。
label: 数据库类型和版本 / Database type and version
description: 也请注明发行版,如 MariaDB、AWS RDS、TiDB 等。 / Include the distribution, such as MariaDB, AWS RDS, or TiDB.
placeholder: "例如:MariaDB 10.11"
validations:
required: true
@@ -21,16 +22,17 @@ body:
- type: textarea
id: description
attributes:
label: 问题描述
description: 什么操作,出了什么错,期望怎样?
placeholder: "例如:展开 schema 时报错 'unknown type',期望能正常显示字段。"
label: 问题描述 / Description / Problem description
description: 什么操作,出了什么错,期望怎样? / What did you do, what went wrong, and what did you expect?
# What did you do, what went wrong, and what did you expect?
placeholder: "例如:展开 schema 时报错 'unknown type',期望能正常显示字段。 / Example: expanding a schema reports 'unknown type'; fields should display normally."
validations:
required: true
- type: textarea
id: error
attributes:
label: 错误信息
label: 错误信息 / Error message
render: shell
validations:
required: true
@@ -38,8 +40,8 @@ body:
- type: textarea
id: support-info
attributes:
label: 支持信息
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
label: 支持信息 / Support information
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。 / Open DBX Settings → About, copy Support Information, and paste it here. Explain if the app cannot open.
placeholder: |
DBX 版本:v0.5.52
运行环境:桌面端
@@ -51,5 +53,6 @@ body:
- type: textarea
id: extra
attributes:
label: 补充信息
description: 表结构、SQL 样例、截图、部署方式(Docker/云/自建)等。
label: 补充信息 / Additional information
description: 表结构、SQL 样例、截图、部署方式(Docker/云/自建)等。 / Include schema, SQL samples, screenshots, and deployment details (Docker, cloud, or self-hosted).
# Include schema, SQL samples, screenshots, and deployment details (Docker, cloud, or self-hosted).
+31 -40
View File
@@ -6,64 +6,55 @@ body:
- type: markdown
attributes:
value: |
好的建议最好说明使用场景、数据库类型和期望效果。请尽量避免只写“支持 X”。
好的建议最好说明使用场景、数据库类型和期望效果。请避免只写“支持 X”。
Describe the use case, database type, and desired outcome; avoid requests that only say “support X”.
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。
You can copy the DBX version, runtime, operating system, and architecture in the app via “Settings → About → Copy support information”.
- type: input
id: database
attributes:
label: 数据库类型和版本
description: 如果是通用功能,请填写“通用”。如果只影响特定数据源,请注明数据库类型、版本或发行版。
placeholder: "例如:PostgreSQL 16.2 / MySQL 8.0 / Redis 7.2 / 通用"
validations:
required: true
label: 数据库类型和版本 / Database type and version
description: 通用功能请填写“通用”;特定数据源请注明类型和版本。 / Enter “通用” for general features; otherwise specify the data source and version.
placeholder: "例如:PostgreSQL 16.2 / MySQL 8.0 / Redis 7.2 / 通用 · General"
validations: {required: true}
- type: textarea
id: support-info
attributes:
label: 支持信息
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
label: 支持信息 / Support information
description: 粘贴应用中的支持信息。 / Paste the support information copied from the app.
placeholder: |
DBX 版本:v0.5.52
运行环境:桌面端
操作系统:macOS 26.4
系统架构:aarch64
validations:
required: true
DBX 版本 / Version: v0.5.52
运行环境 / Runtime: Desktop
操作系统 / OS: macOS 26.4
系统架构 / Architecture: aarch64
validations: {required: true}
- type: textarea
id: problem
attributes:
label: 当前痛点
description: 现在做这件事有什么不方便?
placeholder: "例如:每次查询后都要手动复制结果再转成 Markdown。"
validations:
required: true
label: 当前痛点 / Current pain point
description: 现在做这件事有什么不方便? / What is inconvenient about the current workflow?
placeholder: "例如:每次查询后都要手动复制结果再转成 Markdown。 / Example: I must manually copy query results and convert them to Markdown."
validations: {required: true}
- type: textarea
id: proposal
attributes:
label: 期望方案
description: 希望怎么支持?
placeholder: "例如:在查询结果右上角增加导出 Markdown 按钮。"
validations:
required: true
label: 期望方案 / Proposed solution
description: 希望怎么支持? / How would you like this to work?
placeholder: "例如:在查询结果右上角增加导出 Markdown 按钮。 / Example: Add an Export to Markdown button."
validations: {required: true}
- type: dropdown
id: priority
attributes:
label: 优先级(是否紧急)
description: 紧急 = 希望下个版本优先做。维护者可根据实际情况调整。
label: 优先级(是否紧急) / Priority (urgency)
description: 紧急 = 希望下个版本优先做;维护者可调整。 / Urgent means you hope for the next release; maintainers may adjust it.
options:
- "🔴 P0 紧急 · 下个版本必须做"
- "🟠 P1 重要 · 尽快处理"
- "🟡 P2 普通 · 按排期处理"
- "🟢 P3 低优 · 有空再做"
validations:
required: true
- "🔴 P0 紧急 · 下个版本必须做 / Urgent · Must do next release"
- "🟠 P1 重要 · 尽快处理 / Important · Do soon"
- "🟡 P2 普通 · 按排期处理 / Normal · Schedule as appropriate"
- "🟢 P3 低优 · 有空再做 / Low · Do when possible"
validations: {required: true}
- type: textarea
id: extra
attributes:
label: 补充信息
description: 参考截图、替代方案、特殊环境等。
label: 补充信息 / Additional information
description: 参考截图、替代方案、特殊环境等。 / Screenshots, alternatives, or special environments.
+11 -8
View File
@@ -6,8 +6,9 @@ body:
- type: input
id: database
attributes:
label: 数据库类型和版本
description: 如果问题与数据库无关,请填写“无关”。否则请注明数据库类型、版本或发行版。
label: 数据库类型和版本 / Database type and version
description: 如果问题与数据库无关,请填写“无关”。否则请注明数据库类型、版本或发行版。 / Enter “无关” if unrelated to a database; otherwise specify the type, version, or distribution.
# Enter “无关” if unrelated to a database; otherwise specify the type, version, or distribution.
placeholder: "例如:PostgreSQL 16.2 / MySQL 8.0 / Redis 7.2 / 无关"
validations:
required: true
@@ -15,8 +16,8 @@ body:
- type: textarea
id: support-info
attributes:
label: 支持信息
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
label: 支持信息 / Support information
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息 / Support information”,然后粘贴到这里。如无法打开应用,请说明原因。
placeholder: |
DBX 版本:v0.5.52
运行环境:桌面端
@@ -28,8 +29,9 @@ body:
- type: textarea
id: goal
attributes:
label: 描述
description: 你想完成什么?已经尝试了什么?
label: 描述 / Description
description: 你想完成什么?已经尝试了什么? / What are you trying to accomplish, and what have you already tried?
# What are you trying to accomplish, and what have you already tried?
placeholder: "例如:我想通过 SSH 隧道连接内网 PostgreSQL,已确认本机 psql 可以连接,但 DBX 中测试连接提示..."
validations:
required: true
@@ -37,8 +39,9 @@ body:
- type: textarea
id: context
attributes:
label: 环境信息
description: 连接方式、特殊环境、已尝试的排查方式等。
label: 环境信息 / Environment
description: 连接方式、特殊环境、已尝试的排查方式等。 / Connection method, special environment, and troubleshooting already attempted.
# Connection method, special environment, and troubleshooting already attempted.
placeholder: |
连接方式:
补充环境:
+20 -20
View File
@@ -1,32 +1,32 @@
## 变更说明
## 变更说明 / Change Description
<!-- 简要描述这个 PR 做了什么 -->
<!-- 简要描述这个 PR 做了什么 / Briefly describe what this PR changes. -->
## 变更类型
## 变更类型 / Change Type
- [ ] 新功能
- [ ] Bug 修复
- [ ] 性能优化
- [ ] 代码重构
- [ ] 文档更新
- [ ] CI / 构建
- [ ] 新功能 / New feature
- [ ] Bug 修复 / Bug fix
- [ ] 性能优化 / Performance improvement
- [ ] 代码重构 / Code refactoring
- [ ] 文档更新 / Documentation
- [ ] CI / 构建 / CI or build
## 涉及前端
## 涉及前端 / Frontend Changes
<!-- 如果 PR 涉及任何前端改动(UI 组件、样式、交互、文案等),必须附截图或录屏 -->
<!-- 如果 PR 涉及任何前端改动(UI 组件、样式、交互、文案等),必须附截图或录屏 / If this PR changes UI components, styles, interactions, or user-facing text, include screenshots or a recording. -->
- [ ] 本 PR 涉及前端改动,已附截图/录屏(见下方)
- [ ] 本 PR 涉及前端改动,已附截图/录屏(见下方) / This PR includes frontend changes and screenshots or a recording are attached below.
<!-- 截图区域 -->
<!-- 截图区域 / Screenshots or recording -->
## 验证
## 验证 / Validation
<!-- 说明如何验证你的改动,例如:命令、测试用例、手动操作步骤 -->
<!-- 说明如何验证你的改动,例如:命令、测试用例、手动操作步骤 / Explain how you verified the change, including commands, tests, or manual steps. -->
- [ ] `make check` 通过
- [ ] `make cargo-check-fast` 通过
- [ ] 相关测试通过
- [ ] `make check` 通过 / `make check` passes
- [ ] `make cargo-check-fast` 通过 / `make cargo-check-fast` passes
- [ ] 相关测试通过 / Relevant tests pass
## 关联 Issue
## 关联 Issue / Related Issue
<!-- 如有,填写 `Close #xxx` 或 `Related #xxx` -->
<!-- 如有,填写 `Close #xxx` 或 `Related #xxx` / If applicable, use `Close #xxx` or `Related #xxx`. -->
+4 -4
View File
@@ -169,7 +169,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
go-version: "1.20.14"
- name: Test Oracle native agent
working-directory: agents/drivers/oracle-go
run: go test ./...
@@ -222,7 +222,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
go-version: "1.20.14"
- name: Test Xugu native agent
working-directory: agents/drivers/xugu
run: GONOSUMDB=gitee.com/XuguDB/go-xugu-driver go test ./...
@@ -262,7 +262,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
go-version: "1.20.14"
- name: Test RabbitMQ native agent
working-directory: agents/drivers/rabbitmq
run: go test ./...
@@ -595,7 +595,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
go-version: "1.20.14"
- name: Test Kingbase native agent
working-directory: agents/drivers/kingbase-go
run: go test ./...
+5 -89
View File
@@ -21,12 +21,6 @@ jobs:
env:
# The workspace intentionally contains platform-specific CLI/MCP packages for every release target.
NPM_CONFIG_LOGLEVEL: error
# Same two-layer cache as rust-test/rust-fmt-clippy: sccache cannot
# reuse Cargo incremental artifacts, so avoid generating them in CI.
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
steps:
- uses: actions/checkout@v5
@@ -44,92 +38,11 @@ jobs:
- name: Install frontend dependencies
run: pnpm --filter dbx... install --frozen-lockfile
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
with:
version: "v0.10.0"
- name: Configure S3 sccache
if: env.SCCACHE_GHA_ENABLED != 'true'
shell: bash
env:
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
run: |
{
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
echo "SCCACHE_REGION=${CACHE_REGION}"
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
# Keep the server alive through post-compile test/lint phases so
# "Show sccache stats" reflects the real counters.
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. These jobs are
# native Linux builds, so plain CC/CXX select the target compiler.
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
} >> "$GITHUB_ENV"
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./ -> target"
# Own key: this job builds one crate with --no-default-features,
# a different fingerprint than either Rust job's feature set, so
# sharing their key would only ever miss and waste cache space.
shared-key: ci-frontend-docs-export-smoke-x86_64-unknown-linux-gnu
# Preserve completed dependency builds when a later step fails.
cache-on-failure: true
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
save-if: ${{ github.ref == 'refs/heads/main' }}
# exportSmoke.spec.ts shells out to this example to build a real
# to_standalone_html fixture. Built here, before `pnpm check`, so the
# test's own `cargo run` (same flags — see the spec's comment) is a
# cache hit rather than a fresh compile of dbx-core inside a vitest
# hook. `--no-default-features`: the docs module needs none of
# duckdb-sidecar, mq-admin, sqlite-sqlcipher or system-fonts, so this
# avoids requiring any system dev packages this job doesn't already
# install.
- name: Build the docs export smoke fixture example
run: cargo build -p dbx-core --locked --no-default-features --example docs_export_smoke
# Guards that the committed bundle (crates/dbx-core/assets/docs-export.*)
# matches the sources under apps/desktop/ it was built from. This job is
# what runs on a frontend-only change, so the guard has to run here too —
# rust-test is gated on crates/**/src-tauri/**/Cargo.*, none of which a
# frontend-only PR touches. Same `--no-default-features` fingerprint as
# the prebuild step above, so this is a cache hit, not a fresh compile.
- name: Guard the committed docs export bundle
run: cargo test -p dbx-core --locked --no-default-features --lib docs::export
- name: Show sccache stats
if: always()
continue-on-error: true
run: ${SCCACHE_PATH} --show-stats
- name: Frontend check
run: pnpm check
# Advisory, not a guard: the manifest guard above hashes INPUTS, so it
# cannot see a hand-edited artefact (no source hash moves) or a
# toolchain bump (vite, tailwindcss and @vitejs/plugin-vue are
# plugins, not modules — they never enter the graph). Rebuilding and
# diffing checks the relationship between inputs and output instead,
# closing both gaps. Must run last: it overwrites the committed
# bundle, and both the guard above and `pnpm check` above (whose
# exportSmoke.spec.ts executes the *committed* bundle) need it
# untouched to be testing what is actually checked in.
# Rebuild and diff the committed docs-export bundle after the frontend
# checks so source changes cannot silently leave stale generated assets.
- name: Rebuild the docs export bundle
run: pnpm build:docs-export
@@ -187,6 +100,9 @@ jobs:
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Install plugin development host dependencies
run: npm ci --prefix plugins/sdk/dev-host
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
+14
View File
@@ -16,7 +16,21 @@ jobs:
update:
runs-on: ubuntu-latest
steps:
# main 的 ruleset 要求改动走 PR,默认 GITHUB_TOKEN 直推会被 GH013 拒掉;
# 用 owner PAT 后 push 以 admin 身份命中 ruleset 的 bypass(同 mcp-release.yml)。
- uses: actions/checkout@v5
with:
token: ${{ secrets.MCP_RELEASE_TOKEN || secrets.PROJECT_AUTOMATION_TOKEN }}
- name: Verify push token
# 快照抓取要打好几页 GitHub API,先用一步廉价检查把缺 token 的情况挡在前面,
# 否则要等到最后 push 才以 GH013 报错,很难看出是令牌问题。
env:
PUSH_TOKEN: ${{ secrets.MCP_RELEASE_TOKEN || secrets.PROJECT_AUTOMATION_TOKEN }}
run: |
if [ -z "$PUSH_TOKEN" ]; then
echo "::error::No owner PAT configured; the default GITHUB_TOKEN cannot push to the protected main branch."
exit 1
fi
- uses: actions/setup-node@v6
with:
node-version: 22
+79 -29
View File
@@ -57,6 +57,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install dev-host dependencies for plugin-cli packing
run: npm ci --prefix plugins/sdk/dev-host
- name: Set package versions
id: version
env:
@@ -916,39 +919,66 @@ jobs:
publish-homebrew-formula:
name: Publish Homebrew formula
runs-on: ubuntu-latest
needs: [prepare, publish-cli, publish-mcp-server]
needs: [prepare, publish-cli-github-release]
steps:
- name: Download CLI npm tarball and compute SHA256
- name: Download native CLI archives and compute SHA256
id: cli-hash
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.prepare.outputs.version }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
VERSION="${VERSION}"
NPM_TARBALL="cli-${VERSION}.tgz"
NPM_URL="https://registry.npmjs.org/@dbx-app/cli/-/${NPM_TARBALL}"
# Poll npm until the package is available (CDN propagation delay)
for i in $(seq 1 12); do
if curl -fsSLI "${NPM_URL}" >/dev/null 2>&1; then
echo "Package found on attempt ${i}"
break
mkdir -p release-assets
ASSETS=(
"CLI-SHA256SUMS"
"dbx-cli-darwin-arm64.tar.gz"
"dbx-cli-darwin-x64.tar.gz"
"dbx-cli-linux-arm64-gnu.tar.gz"
"dbx-cli-linux-x64-gnu.tar.gz"
)
for ASSET in "${ASSETS[@]}"; do
gh release download "${TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--pattern "${ASSET}" \
--dir release-assets \
--clobber
done
(
cd release-assets
sha256sum --check --ignore-missing CLI-SHA256SUMS
)
hash_for() {
awk -v f="$1" '$2 == f {print $1}' release-assets/CLI-SHA256SUMS
}
SHA_DARWIN_ARM64="$(hash_for dbx-cli-darwin-arm64.tar.gz)"
SHA_DARWIN_X64="$(hash_for dbx-cli-darwin-x64.tar.gz)"
SHA_LINUX_ARM64="$(hash_for dbx-cli-linux-arm64-gnu.tar.gz)"
SHA_LINUX_X64="$(hash_for dbx-cli-linux-x64-gnu.tar.gz)"
for HASH_NAME in SHA_DARWIN_ARM64 SHA_DARWIN_X64 SHA_LINUX_ARM64 SHA_LINUX_X64; do
if [ -z "${!HASH_NAME}" ]; then
echo "::error::Missing SHA256 entry for ${HASH_NAME} in CLI-SHA256SUMS"
exit 1
fi
echo "Waiting for npm CDN (attempt ${i}/12)..."
sleep 10
done
curl -fsSL -o "${NPM_TARBALL}" "${NPM_URL}"
CLI_SHA256=$(sha256sum "${NPM_TARBALL}" | cut -d ' ' -f 1)
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "sha256=${CLI_SHA256}" >> "$GITHUB_OUTPUT"
echo " cli version: ${VERSION}"
echo " sha256: ${CLI_SHA256}"
echo "sha_darwin_arm64=${SHA_DARWIN_ARM64}" >> "$GITHUB_OUTPUT"
echo "sha_darwin_x64=${SHA_DARWIN_X64}" >> "$GITHUB_OUTPUT"
echo "sha_linux_arm64=${SHA_LINUX_ARM64}" >> "$GITHUB_OUTPUT"
echo "sha_linux_x64=${SHA_LINUX_X64}" >> "$GITHUB_OUTPUT"
- name: Push formula to homebrew-tap
env:
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
CLI_VERSION: ${{ steps.cli-hash.outputs.version }}
CLI_SHA256: ${{ steps.cli-hash.outputs.sha256 }}
SHA_DARWIN_ARM64: ${{ steps.cli-hash.outputs.sha_darwin_arm64 }}
SHA_DARWIN_X64: ${{ steps.cli-hash.outputs.sha_darwin_x64 }}
SHA_LINUX_ARM64: ${{ steps.cli-hash.outputs.sha_linux_arm64 }}
SHA_LINUX_X64: ${{ steps.cli-hash.outputs.sha_linux_x64 }}
run: |
git clone --depth 1 \
"https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/t8y2/homebrew-tap.git" \
@@ -956,32 +986,52 @@ jobs:
cd homebrew-tap
mkdir -p Formula
# The formula installs the prebuilt native binary from the packages
# release. Installing the npm tarball instead breaks within the
# Homebrew --min-release-age cooldown: the platform binary is an npm
# optionalDependency, so during the cooldown npm silently skips it
# and brew ships a non-working CLI (t8y2/homebrew-tap#2).
cat > Formula/dbx-cli.rb <<'RUBY_EOF'
class DbxCli < Formula
desc "Command-line interface for DBX database connections, schema, and safe queries"
homepage "https://github.com/t8y2/dbx"
url "https://registry.npmjs.org/@dbx-app/cli/-/cli-__CLI_VERSION__.tgz"
sha256 "__CLI_SHA256__"
version "__CLI_VERSION__"
license "Apache-2.0"
depends_on "node"
on_macos do
if Hardware::CPU.intel?
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-cli-darwin-x64.tar.gz"
sha256 "__SHA_DARWIN_X64__"
else
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-cli-darwin-arm64.tar.gz"
sha256 "__SHA_DARWIN_ARM64__"
end
end
on_linux do
if Hardware::CPU.intel?
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-cli-linux-x64-gnu.tar.gz"
sha256 "__SHA_LINUX_X64__"
else
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-cli-linux-arm64-gnu.tar.gz"
sha256 "__SHA_LINUX_ARM64__"
end
end
def install
system "npm", "install", *std_npm_args
bin.install_symlink libexec.glob("bin/*")
bin.install "dbx"
end
test do
assert_path_exists bin/"dbx"
system bin/"dbx", "doctor"
assert_match version.to_s, shell_output("#{bin}/dbx --version")
end
end
RUBY_EOF
sed -i 's/^ //' Formula/dbx-cli.rb
sed -i "s/__CLI_VERSION__/${CLI_VERSION}/g" Formula/dbx-cli.rb
sed -i "s/__CLI_SHA256__/${CLI_SHA256}/g" Formula/dbx-cli.rb
sed -i "s/__SHA_DARWIN_ARM64__/${SHA_DARWIN_ARM64}/g" Formula/dbx-cli.rb
sed -i "s/__SHA_DARWIN_X64__/${SHA_DARWIN_X64}/g" Formula/dbx-cli.rb
sed -i "s/__SHA_LINUX_ARM64__/${SHA_LINUX_ARM64}/g" Formula/dbx-cli.rb
sed -i "s/__SHA_LINUX_X64__/${SHA_LINUX_X64}/g" Formula/dbx-cli.rb
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
+16 -12
View File
@@ -77,20 +77,22 @@ jobs:
elif event in ("pull_request", "pull_request_target"):
title = os.environ["PR_TITLE"]
url = os.environ["PR_URL"]
if action == "opened":
# if action == "opened":
# user = os.environ["PR_USER"]
# msg = "\n".join([
# "🔀 新 Pull Request",
# f"标题: {title}",
# f"提交者: {user}",
# f"链接: {url}",
# ])
if action == "closed" and os.environ.get("PR_MERGED") == "true":
user = os.environ["PR_USER"]
msg = "\n".join([
"🔀 新 Pull Request",
f"标题: {title}",
f"提交者: {user}",
f"链接: {url}",
])
elif action == "closed" and os.environ.get("PR_MERGED") == "true":
user = os.environ.get("PR_MERGED_BY", "unknown")
merged_by = os.environ.get("PR_MERGED_BY", "unknown")
msg = "\n".join([
"✅ PR 已合并",
f"标题: {title}",
f"合并者: {user}",
f"提交者: {user}",
f"合并者: {merged_by}",
f"链接: {url}",
])
@@ -195,7 +197,9 @@ jobs:
PAYLOAD=$(jq -nc --arg msg "$MSG_CONTENT" \
'{"umo": "default:GroupMessage:1087880322", "message": $msg}')
printf '%s' "$PAYLOAD" | ssh "root@${HOST}" "curl -fsS -X POST http://localhost:6185/api/v1/im/message \
if ! printf '%s' "$PAYLOAD" | ssh "root@${HOST}" "curl -fsS -X POST http://localhost:6185/api/v1/im/message \
-H 'Authorization: Bearer ${ASTRBOT_API_KEY}' \
-H 'Content-Type: application/json' \
--data-binary @-"
--data-binary @-"; then
echo "::warning::QQ group notification failed; the event notification was sent to Feishu."
fi
+450
View File
@@ -0,0 +1,450 @@
name: Plugin CLI Release
on:
push:
tags:
- "plugin-cli-v*"
workflow_dispatch:
inputs:
version:
description: "Plugin CLI version to publish, for example 0.1.6"
required: true
default: "0.1.6"
npm-tag:
description: "npm distribution tag"
required: true
default: "latest"
type: choice
options:
- latest
- next
permissions:
attestations: write
contents: write
id-token: write
concurrency:
group: plugin-cli-release
cancel-in-progress: false
jobs:
prepare:
name: Validate plugin CLI release
runs-on: ubuntu-24.04
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
npm-tag: ${{ steps.release.outputs.npm-tag }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-go@v6
with:
go-version: "1.22.x"
cache: false
- name: Validate release branch and versions
id: release
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_NPM_TAG: ${{ inputs.npm-tag }}
run: |
node <<'NODE'
const fs = require("fs");
let version;
let tag;
let npmTag;
if (process.env.GITHUB_EVENT_NAME === "push") {
tag = process.env.GITHUB_REF_NAME;
const match = /^plugin-cli-v(.+)$/.exec(tag);
if (!match) throw new Error(`Unexpected plugin CLI tag: ${tag}`);
version = match[1];
npmTag = version.includes("-") ? "next" : "latest";
} else {
if (process.env.GITHUB_REF !== "refs/heads/main") {
throw new Error(`Manual plugin CLI releases must run from main, received ${process.env.GITHUB_REF}.`);
}
version = process.env.INPUT_VERSION.trim();
tag = `plugin-cli-v${version}`;
npmTag = process.env.INPUT_NPM_TAG;
}
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Invalid semantic version: ${version}`);
}
if (version.includes("-") && npmTag === "latest") {
throw new Error(`Prerelease ${version} cannot use the npm latest tag.`);
}
const packagePaths = [
"packages/plugin-cli/package.json",
"packages/plugin-cli-darwin-arm64/package.json",
"packages/plugin-cli-darwin-x64/package.json",
"packages/plugin-cli-linux-arm64-gnu/package.json",
"packages/plugin-cli-linux-x64-gnu/package.json",
"packages/plugin-cli-win32-arm64/package.json",
"packages/plugin-cli-win32-x64/package.json",
];
for (const path of packagePaths) {
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
if (pkg.version !== version) {
throw new Error(`${path} has version ${pkg.version}; expected ${version}.`);
}
}
const cliCargo = fs.readFileSync("plugins/sdk/cli/Cargo.toml", "utf8");
const cliVersion = cliCargo.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (cliVersion !== version) {
throw new Error(`plugins/sdk/cli/Cargo.toml has version ${cliVersion}; expected ${version}.`);
}
const launcher = JSON.parse(fs.readFileSync(packagePaths[0], "utf8"));
for (const [dependency, dependencyVersion] of Object.entries(launcher.optionalDependencies ?? {})) {
if (dependencyVersion !== version) {
throw new Error(`${dependency} is pinned to ${dependencyVersion}; expected ${version}.`);
}
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=${tag}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `npm-tag=${npmTag}\n`);
NODE
- name: Check npm token
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "${NODE_AUTH_TOKEN}" ]; then
echo "::error::NPM_TOKEN secret is required to publish @dbx-app/plugin-cli."
exit 1
fi
- name: Test Rust CLI
run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml
- name: Prepare and test plugin development runtime
run: |
npm ci --prefix plugins/sdk/dev-host
npm test --prefix plugins/sdk/dev-host
- name: Test npm launcher
run: npm --prefix packages/plugin-cli test
- name: Verify packed npm installation
env:
DBX_PLUGIN_CLI_VERIFY_NATIVE: "1"
run: node scripts/verify-plugin-cli-package.mjs
- name: Create immutable source tag
if: github.event_name == 'workflow_dispatch'
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
git fetch origin --tags --force
if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then
TAG_SHA="$(git rev-list -n 1 "refs/tags/${TAG}")"
HEAD_SHA="$(git rev-parse HEAD)"
if [ "${TAG_SHA}" != "${HEAD_SHA}" ]; then
echo "::error::${TAG} already points to ${TAG_SHA}, not ${HEAD_SHA}."
exit 1
fi
echo "${TAG} already points to this commit."
else
git tag "${TAG}" HEAD
git push origin "refs/tags/${TAG}:refs/tags/${TAG}"
fi
refresh-lockfile:
name: Refresh pnpm lockfile
needs: prepare
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
with:
ref: main
- name: Sync plugin-cli lockfile specifiers
id: sync
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
node <<'NODE'
const fs = require("fs");
const version = process.env.VERSION;
const manifest = JSON.parse(fs.readFileSync("packages/plugin-cli/package.json", "utf8"));
if (manifest.version !== version) {
console.log(`main pins plugin-cli ${manifest.version}; release ${version} does not need a lockfile sync.`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, "changed=false\n");
process.exit(0);
}
const expected = Object.keys(manifest.optionalDependencies ?? {});
const lockPath = "pnpm-lock.yaml";
const lines = fs.readFileSync(lockPath, "utf8").split("\n");
let inImporter = false;
let specifiers = 0;
let changed = 0;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (/^ {2}\S/.test(line)) {
inImporter = line === " packages/plugin-cli:";
continue;
}
if (!inImporter) continue;
const match = line.match(/^( {8}specifier: )(.+)$/);
if (!match) continue;
specifiers += 1;
if (match[2] !== version) {
lines[i] = match[1] + version;
changed += 1;
}
}
if (specifiers !== expected.length) {
throw new Error(`pnpm-lock.yaml has ${specifiers} plugin-cli specifiers; manifest declares ${expected.length}.`);
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `changed=${changed > 0}\n`);
if (changed > 0) {
fs.writeFileSync(lockPath, lines.join("\n"));
console.log(`Updated ${changed} plugin-cli specifiers to ${version}.`);
} else {
console.log("plugin-cli lockfile specifiers already match; nothing to do.");
}
NODE
- name: Commit refreshed lockfile
if: steps.sync.outputs.changed == 'true'
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add pnpm-lock.yaml
git commit -m "chore(plugin-cli): refresh pnpm lockfile for ${VERSION}"
for attempt in 1 2 3; do
if git push origin HEAD:main; then
echo "::notice::Refreshed pnpm-lock.yaml plugin-cli specifiers to ${VERSION}."
exit 0
fi
echo "Push attempt ${attempt} rejected; rebasing onto origin/main."
git pull --rebase origin main
done
echo "::error::Unable to push the lockfile refresh after 3 attempts."
exit 1
publish-platforms:
name: Publish ${{ matrix.package-name }}
needs: prepare
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
target: aarch64-apple-darwin
package-dir: plugin-cli-darwin-arm64
package-name: "@dbx-app/plugin-cli-darwin-arm64"
binary: dbx-plugin
- runner: macos-15-intel
target: x86_64-apple-darwin
package-dir: plugin-cli-darwin-x64
package-name: "@dbx-app/plugin-cli-darwin-x64"
binary: dbx-plugin
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
package-dir: plugin-cli-linux-arm64-gnu
package-name: "@dbx-app/plugin-cli-linux-arm64-gnu"
binary: dbx-plugin
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
package-dir: plugin-cli-linux-x64-gnu
package-name: "@dbx-app/plugin-cli-linux-x64-gnu"
binary: dbx-plugin
- runner: windows-11-arm
target: aarch64-pc-windows-msvc
package-dir: plugin-cli-win32-arm64
package-name: "@dbx-app/plugin-cli-win32-arm64"
binary: dbx-plugin.exe
- runner: windows-2025
target: x86_64-pc-windows-msvc
package-dir: plugin-cli-win32-x64
package-name: "@dbx-app/plugin-cli-win32-x64"
binary: dbx-plugin.exe
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: actions/setup-python@v6
if: runner.os == 'Linux'
with:
python-version: "3.x"
- name: Install Linux compatibility builder
if: runner.os == 'Linux'
run: pip install ziglang==0.14.0 cargo-zigbuild==0.23.0
- name: Build precompiled CLI
shell: bash
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target/plugin-cli
run: |
if [[ "${{ runner.os }}" == "Linux" ]]; then
cargo zigbuild --locked --release --manifest-path plugins/sdk/cli/Cargo.toml --target "${{ matrix.target }}.2.31"
else
cargo build --locked --release --manifest-path plugins/sdk/cli/Cargo.toml --target "${{ matrix.target }}"
fi
- name: Stage platform package
shell: bash
run: |
mkdir -p "packages/${{ matrix.package-dir }}/bin"
cp "target/plugin-cli/${{ matrix.target }}/release/${{ matrix.binary }}" "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"
if [[ "${{ runner.os }}" != "Windows" ]]; then
chmod +x "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"
fi
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
- name: Publish platform package
shell: bash
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
NPM_TAG: ${{ needs.prepare.outputs.npm-tag }}
run: |
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
echo "${{ matrix.package-name }}@${VERSION} already exists; refreshing ${NPM_TAG}."
npm dist-tag add "${{ matrix.package-name }}@${VERSION}" "${NPM_TAG}"
else
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance --tag "${NPM_TAG}"
fi
publish-launcher:
name: Publish @dbx-app/plugin-cli
needs: [prepare, publish-platforms]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
- name: Prepare development runtime build dependencies
run: npm ci --prefix plugins/sdk/dev-host
- name: Wait for platform packages
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
wait_for_package() {
package="$1"
for attempt in $(seq 1 60); do
if npm view "${package}@${VERSION}" version >/dev/null 2>&1; then
return 0
fi
echo "Waiting for ${package}@${VERSION} (${attempt}/60)."
sleep 10
done
return 1
}
for package in \
@dbx-app/plugin-cli-darwin-arm64 \
@dbx-app/plugin-cli-darwin-x64 \
@dbx-app/plugin-cli-linux-arm64-gnu \
@dbx-app/plugin-cli-linux-x64-gnu \
@dbx-app/plugin-cli-win32-arm64 \
@dbx-app/plugin-cli-win32-x64; do
wait_for_package "${package}" || {
echo "::error::${package}@${VERSION} is unavailable; refusing to publish the launcher."
exit 1
}
done
- name: Publish launcher and bundled SDK
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
NPM_TAG: ${{ needs.prepare.outputs.npm-tag }}
run: |
if npm view "@dbx-app/plugin-cli@${VERSION}" version >/dev/null 2>&1; then
echo "@dbx-app/plugin-cli@${VERSION} already exists; refreshing ${NPM_TAG}."
npm dist-tag add "@dbx-app/plugin-cli@${VERSION}" "${NPM_TAG}"
else
npm publish ./packages/plugin-cli --access public --provenance --tag "${NPM_TAG}"
fi
verify-published-package:
name: Verify clean npm installation
needs: [prepare, publish-launcher]
runs-on: ubuntu-24.04
steps:
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-go@v6
with:
go-version: "1.22.x"
cache: false
- name: Install from npm and package every template
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
work="$(mktemp -d)"
cd "${work}"
npm init -y >/dev/null
for attempt in $(seq 1 60); do
if npm view "@dbx-app/plugin-cli@${VERSION}" version >/dev/null 2>&1; then
break
fi
if [ "${attempt}" -eq 60 ]; then
echo "::error::@dbx-app/plugin-cli@${VERSION} did not become visible on npm."
exit 1
fi
sleep 10
done
npm install --ignore-scripts --no-audit --no-fund "@dbx-app/plugin-cli@${VERSION}"
./node_modules/.bin/dbx-plugin --version
for template in frontend rust go; do
./node_modules/.bin/dbx-plugin create "${template}-plugin" \
--template "${template}" \
--yes \
--id "com.example.npm-${template}" \
--name "npm ${template} smoke" \
--publisher example \
--description "Published npm package smoke"
./node_modules/.bin/dbx-plugin package "${template}-plugin"
test "$(find "${template}-plugin/dist" -maxdepth 1 -name '*.dbxp' | wc -l)" -eq 1
done
+39
View File
@@ -0,0 +1,39 @@
name: Plugin development host
on:
pull_request:
paths:
- '.gitattributes'
- 'plugins/sdk/cli/**'
- 'plugins/sdk/dev-host/**'
- 'packages/plugin-cli/**'
- 'scripts/verify-plugin-cli-package.mjs'
- '.github/workflows/plugin-dev-host.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-15, windows-2022]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: false
- run: npm ci --prefix plugins/sdk/dev-host
- run: npm run lint --prefix plugins/sdk/dev-host
- run: npm run fmt:check --prefix plugins/sdk/dev-host
- run: npm test --prefix plugins/sdk/dev-host
- run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml
- run: npm test --prefix packages/plugin-cli
- run: node scripts/verify-plugin-cli-package.mjs
env:
DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'
@@ -0,0 +1,218 @@
name: Reusable DBX plugin release
on:
workflow_call:
inputs:
release-tag:
description: Existing GitHub Release tag that receives the plugin assets
required: true
type: string
package-command:
description: Command that builds one unsigned candidate .dbxp and one .artifact.json for DBX_PLUGIN_TARGET
required: true
type: string
working-directory:
description: Plugin repository directory containing the package command
default: .
required: false
type: string
package-path:
description: Glob relative to working-directory for built .dbxp files
default: dist/*.dbxp
required: false
type: string
metadata-path:
description: Glob relative to working-directory for built .artifact.json files
default: dist/*.artifact.json
required: false
type: string
node-version:
default: "22"
required: false
type: string
go-version:
default: "1.22.x"
required: false
type: string
rust-toolchain:
default: stable
required: false
type: string
sdk-ref:
description: DBX tag or commit used only when installing the plugin CLI from source
default: plugin-sdk-v1
required: false
type: string
plugin-cli-version:
description: Published @dbx-app/plugin-cli version used to package the plugin
default: "0.1.2"
required: false
type: string
install-plugin-cli:
description: Install the dbx-plugin CLI before packaging
default: true
required: false
type: boolean
install-plugin-cli-from-source:
description: Build the CLI from sdk-ref instead of installing its precompiled npm package
default: false
required: false
type: boolean
build-matrix:
description: JSON matrix with runner and DBX target pairs; use one universal entry for platform-independent plugins
default: >-
{"include":[{"runner":"ubuntu-24.04","target":"linux-x64"},{"runner":"ubuntu-24.04-arm","target":"linux-arm64"},{"runner":"windows-2022","target":"windows-x64"},{"runner":"macos-15-intel","target":"darwin-x64"},{"runner":"macos-15","target":"darwin-arm64"}]}
required: false
type: string
permissions:
contents: read
jobs:
build:
name: Build ${{ matrix.target }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(inputs.build-matrix) }}
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- uses: actions/setup-go@v5
with:
go-version: ${{ inputs.go-version }}
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ inputs.rust-toolchain }}
- name: Checkout DBX plugin SDK
if: inputs.install-plugin-cli && inputs.install-plugin-cli-from-source
uses: actions/checkout@v4
with:
repository: t8y2/dbx
ref: ${{ inputs.sdk-ref }}
path: .dbx-plugin-sdk
- name: Install precompiled DBX plugin CLI
if: inputs.install-plugin-cli && !inputs.install-plugin-cli-from-source
shell: bash
run: npm install --global "@dbx-app/plugin-cli@${{ inputs.plugin-cli-version }}"
- name: Install DBX plugin CLI from source
if: inputs.install-plugin-cli && inputs.install-plugin-cli-from-source
shell: bash
run: cargo install --locked --path .dbx-plugin-sdk/plugins/sdk/cli
- name: Build unsigned plugin candidate
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
DBX_PLUGIN_TARGET: ${{ matrix.target }}
DBX_PLUGIN_SDK_ROOT: ${{ inputs.install-plugin-cli-from-source && format('{0}/.dbx-plugin-sdk', github.workspace) || '' }}
run: ${{ inputs.package-command }}
- name: Upload target artifacts
uses: actions/upload-artifact@v4
with:
name: dbx-plugin-${{ matrix.target }}
if-no-files-found: error
retention-days: 1
path: |
${{ inputs.working-directory }}/${{ inputs.package-path }}
${{ inputs.working-directory }}/${{ inputs.metadata-path }}
publish:
name: Publish plugin release assets
needs: build
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Download target artifacts
uses: actions/download-artifact@v4
with:
pattern: dbx-plugin-*
path: release-assets
merge-multiple: true
- name: Validate and merge candidate metadata
shell: bash
run: |
node <<'NODE'
const childProcess = require("node:child_process");
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
function filesUnder(root) {
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const current = path.join(root, entry.name);
return entry.isDirectory() ? filesUnder(current) : [current];
});
}
const files = filesUnder("release-assets");
const metadataFiles = files.filter((file) => file.endsWith(".artifact.json"));
const packages = new Map();
for (const file of files.filter((file) => file.endsWith(".dbxp"))) {
const name = path.basename(file);
if (packages.has(name)) throw new Error(`Duplicate package filename ${name}`);
packages.set(name, file);
}
if (metadataFiles.length === 0 || packages.size === 0) throw new Error("Release produced no DBX plugin artifacts");
const targets = new Set();
let pluginIdentity;
const artifacts = metadataFiles.map((file) => {
const artifact = JSON.parse(fs.readFileSync(file, "utf8"));
if (!/^[a-z0-9-]{1,64}$/.test(artifact.target || "")) throw new Error(`Invalid artifact target in ${file}`);
if (!/^[a-fA-F0-9]{64}$/.test(artifact.sha256 || "")) throw new Error(`Invalid artifact SHA-256 in ${file}`);
if (artifact.signingKeyId !== undefined) throw new Error(`Candidate metadata ${file} must not declare signingKeyId`);
if (!Number.isSafeInteger(artifact.size) || artifact.size < 0) throw new Error(`Invalid artifact size in ${file}`);
const packageName = path.basename(new URL(artifact.url, "https://plugins.invalid/releases/").pathname);
const packageFile = packages.get(packageName);
if (!packageFile) throw new Error(`Artifact metadata ${file} references missing package ${packageName}`);
const packageBytes = fs.readFileSync(packageFile);
const packageSha256 = crypto.createHash("sha256").update(packageBytes).digest("hex");
if (artifact.size !== packageBytes.length) throw new Error(`Artifact metadata ${file} has the wrong package size`);
if (artifact.sha256.toLowerCase() !== packageSha256) throw new Error(`Artifact metadata ${file} has the wrong package SHA-256`);
const entries = childProcess.execFileSync("unzip", ["-Z1", packageFile], { encoding: "utf8" }).split(/\r?\n/);
if (entries.includes("signature.json")) throw new Error(`Candidate package ${packageName} must be unsigned`);
const manifest = JSON.parse(childProcess.execFileSync("unzip", ["-p", packageFile, "manifest.json"], { encoding: "utf8" }));
const identity = {
id: manifest.id,
name: manifest.name,
description: manifest.description || "",
publisher: manifest.publisher,
version: manifest.version,
permissions: [...(manifest.permissions || [])].sort(),
};
if (!identity.id || !identity.name || !identity.publisher || !identity.version) throw new Error(`Package ${packageName} has incomplete manifest identity`);
if (pluginIdentity && JSON.stringify(pluginIdentity) !== JSON.stringify(identity)) throw new Error(`Package ${packageName} manifest identity differs from other targets`);
pluginIdentity ||= identity;
if (targets.has(artifact.target)) throw new Error(`Duplicate artifact target ${artifact.target}`);
targets.add(artifact.target);
return artifact;
}).sort((left, right) => left.target.localeCompare(right.target));
fs.writeFileSync("release-assets/release-candidates.json", `${JSON.stringify({ plugin: pluginIdentity, artifacts }, null, 2)}\n`);
NODE
- name: Upload assets to GitHub Release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
run: |
mapfile -d '' packages < <(find release-assets -type f -name '*.dbxp' -print0)
if [ "${#packages[@]}" -eq 0 ]; then
echo "No .dbxp packages were downloaded" >&2
exit 1
fi
gh release upload "$RELEASE_TAG" "${packages[@]}" release-assets/release-candidates.json \
--repo "$GITHUB_REPOSITORY" \
--clobber
+21 -2
View File
@@ -110,8 +110,7 @@ jobs:
sha256 arm: "__SHA_ARM64__",
intel: "__SHA_X64__"
url "https://github.com/__OWNER__/__APP__/releases/download/v#{version}/__PRODUCT___#{version}_#{arch}.dmg",
verified: "github.com/__OWNER__/__APP__/"
url "https://github.com/__OWNER__/__APP__/releases/download/v#{version}/__PRODUCT___#{version}_#{arch}.dmg"
name "DBX"
desc "Database management tool"
homepage "https://dbxio.com/"
@@ -239,6 +238,26 @@ jobs:
--clobber
ls -lh assets/
- name: Validate latest.json completeness
run: |
LATEST_JSON="assets/latest.json"
if [ ! -f "$LATEST_JSON" ]; then
echo "::error::latest.json not found in release assets; refusing to sync an incomplete manifest to R2"
exit 1
fi
MISSING=""
for p in darwin-aarch64 darwin-x86_64 windows-x86_64 windows-aarch64 linux-x86_64 linux-aarch64; do
if ! jq -e --arg p "$p" '.platforms[$p] != null' "$LATEST_JSON" >/dev/null; then
MISSING="${MISSING} ${p}"
fi
done
if [ -n "$MISSING" ]; then
echo "::error::latest.json is missing updater platforms:${MISSING}. Release builds may still be uploading assets; dispatch again after all release jobs finish."
exit 1
fi
echo "Platform manifest complete:"
jq -r '.platforms | keys[]' "$LATEST_JSON"
- name: Rewrite latest.json download URLs to R2 CDN
run: |
TAG="${{ steps.version.outputs.tag }}"
+17 -1
View File
@@ -23,21 +23,37 @@ jobs:
ref: ${{ github.event.pull_request.base.sha }}
fetch-depth: 0
persist-credentials: false
# The ref is always the trusted base SHA; when a fork head is synced
# to the same commit as the base, checkout v5 refuses the SHA because
# it matches pull_request.head.sha. That commit is still trusted
# content, so opt out of the guard here.
allow-unsafe-pr-checkout: true
- name: Fetch pull request head without checkout
id: verify_head
env:
EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
run: |
git fetch --no-tags origin "pull/${PULL_REQUEST_NUMBER}/head"
test "$(git rev-parse FETCH_HEAD)" = "${EXPECTED_HEAD_SHA}"
ACTUAL_HEAD_SHA="$(git rev-parse FETCH_HEAD)"
if [ "$ACTUAL_HEAD_SHA" != "${EXPECTED_HEAD_SHA}" ]; then
# The PR was pushed to after this event was queued; that push
# triggers its own labeling run, so skip this stale one.
echo "::notice::Pull request head is now ${ACTUAL_HEAD_SHA}, expected ${EXPECTED_HEAD_SHA}; skipping stale labeling run."
echo "stale=true" >> "$GITHUB_OUTPUT"
else
echo "stale=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Node.js
if: steps.verify_head.outputs.stale != 'true'
uses: actions/setup-node@v6
with:
node-version: 22.13.0
- name: Synchronize pull request labels
if: steps.verify_head.outputs.stale != 'true'
run: node .github/scripts/label-pull-request.mjs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -7,8 +7,12 @@ permissions:
contents: read
jobs:
build-windows-x64:
build-windows-7-x64:
runs-on: windows-2022
timeout-minutes: 120
env:
CARGO_INCREMENTAL: "0"
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
steps:
- uses: actions/checkout@v5
@@ -33,16 +37,25 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Setup Rust for standard Windows
uses: dtolnay/rust-toolchain@1.97.1
with:
targets: x86_64-pc-windows-msvc
- name: Check standard Windows dependency path
run: cargo check --locked --package dbx --no-default-features --target x86_64-pc-windows-msvc
- name: Setup Rust for Windows 7
uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly-2026-07-22
components: rust-src
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./ -> target"
shared-key: sqlserver2000-windows-x64
shared-key: sqlserver2000-windows-7-x64
cache-on-failure: true
- name: Build SQL Server legacy agent
@@ -59,15 +72,63 @@ jobs:
}
Copy-Item $agent.FullName "sqlserver-legacy-agent.jar" -Force
- name: Build Windows x64 installer
run: pnpm tauri build --bundles nsis --target x86_64-pc-windows-msvc --ci --no-sign
- name: Prepare Win7-compatible WebView2 loader
shell: pwsh
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
- name: Prepare WebView2 109 fixed runtime
shell: pwsh
run: ./.github/scripts/prepare-webview2-win7-runtime.ps1
- name: Probe WebView2 109 fixed runtime
shell: pwsh
run: ./.github/scripts/assert-webview2-win7-runtime.ps1
- name: Build frontend
run: pnpm build
- name: Build DBX for Windows 7
shell: pwsh
run: |
$env:TAURI_CONFIG = Get-Content src-tauri/tauri.webview2-win7-fixed.conf.json -Raw
cargo build --locked --package dbx --release --features custom-protocol --target x86_64-win7-windows-msvc -Z build-std=std,panic_abort
- name: Audit Windows 7 PE imports
shell: pwsh
run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
- name: Audit Win7 WebView2 loader markers
shell: pwsh
run: ./.github/scripts/assert-webview2-win7-loader.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
- name: Bundle Windows 7 fixed-runtime installer
shell: pwsh
run: |
$bundleDir = "target/x86_64-win7-windows-msvc/release/bundle/nsis"
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (!$installer) {
Write-Error "Missing Windows 7 fixed-runtime installer in ${bundleDir}"
exit 1
}
Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256
- name: Audit Windows 7 installer contents
shell: pwsh
run: |
$installer = Get-ChildItem "target/x86_64-win7-windows-msvc/release/bundle/nsis" -Filter "*.exe" |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
./.github/scripts/assert-win7-installer-content.ps1 -InstallerPath $installer.FullName
- name: Upload installer
uses: actions/upload-artifact@v7
with:
name: DBX-SQLServer2000-Windows-x64
name: DBX-SQLServer2000-Windows-7-x64
path: |
target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
target/x86_64-win7-windows-msvc/release/bundle/nsis/*.exe
sqlserver-legacy-agent.jar
if-no-files-found: error
retention-days: 7
+9 -3
View File
@@ -97,9 +97,15 @@ jobs:
--overwrite-existing
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
aws s3 cp "s3://${R2_BUCKET_NAME}/releases/latest/latest.json" r2-latest.json --endpoint-url "$ENDPOINT"
jq --arg notes "$NOTES" '.notes = $notes' r2-latest.json > r2-latest.json.tmp
mv r2-latest.json.tmp r2-latest.json
# Derive the R2 manifest from the GitHub release latest.json (notes already
# injected above) instead of re-reading the R2 copy, so every sync also
# heals a stale or partial R2 manifest.
jq --arg tag "$TAG" '
(.platforms[]?.url?) |= (
sub("https://github\\.com/t8y2/dbx/releases/download/" + $tag + "/"; "https://dl.dbxio.com/releases/latest/")
+ "?v=" + $tag
)
' updater-assets/latest.json > r2-latest.json
aws s3 cp r2-latest.json "s3://${R2_BUCKET_NAME}/releases/${TAG}/latest.json" \
--endpoint-url "$ENDPOINT" --content-type application/json --cache-control no-cache
aws s3 cp r2-latest.json "s3://${R2_BUCKET_NAME}/releases/latest/latest.json" \
+12
View File
@@ -1,4 +1,5 @@
# Dependencies
openGauss-server/
node_modules
.claude/
.codex/
@@ -28,7 +29,11 @@ agents/drivers/etcd-go/etcd-go
agents/drivers/etcd2-go/etcd2-go
plugins/jdbc/.gradle/
plugins/jdbc/build/
# Java IDE compilation output (keep the launcher scripts in bin/ tracked).
plugins/jdbc/bin/main/
plugins/jdbc/bin/test/
plugins/jdbc/dist/
packages/plugin-cli/sdk-root/
docs/.next/
docs/out/
docs/.source/
@@ -62,14 +67,19 @@ Thumbs.db
.env
.env.*
!.env.example
.dbx-plugin-signing-key.env
# Temp
tmp/
# Local issue screenshots (never commit)
issue-assets/
# AI background-run dev artifacts (never commit)
.dlp-quarantine/
outputs/
target-ai-write-confirmation/
.serena/
# Code review tool artifacts
review_diff.json
@@ -77,6 +87,7 @@ review_classified.json
# Logs
*.log
/.dbx-dev/
# Generated changelog data
@@ -95,3 +106,4 @@ dbx-devtest-connections.json
# Real database integration credentials (see docs/TEST_ENVIRONMENTS.md)
/docs/TEST_ENVIRONMENTS.md
.wrangler/
+8
View File
@@ -65,6 +65,7 @@ For a real local Java agent test, build the target `shadowJar`, back up and repl
| `crates/dbx-web/` | Docker / Web HTTP backend |
| `packages/cli/` | `@dbx-app/cli` |
| `packages/mcp-server/` | `@dbx-app/mcp-server` |
| `packages/plugin-cli/` | Precompiled `@dbx-app/plugin-cli` launcher and bundled plugin SDKs |
| `packages/mongo-shell/` | Private MongoDB editor parsing helpers |
| `docs/` | Official documentation site |
| `examples/` | Sample configs and automation scripts |
@@ -104,6 +105,13 @@ pnpm test
For frontend or package changes, run the relevant package tests under `packages/` or `packages/app-tests/`.
Test quality matters more than test count:
- Exercise production functions or mounted components and assert observable results, state changes, errors, or emitted events. Mock external boundaries, not the behavior under test.
- Do not copy the implementation into a test or use source-string matching to pin class names, local variable names, template fragments, or helper-call spelling. These checks break on harmless refactors without proving runtime behavior. Check layout in a browser rather than inferring it from CSS strings.
- Extend the existing behavior suite for a regression instead of adding a second source-wiring snapshot. Use table-driven cases when only the inputs and expected outputs differ.
- File-content checks are appropriate for shipped artifacts, permissions, compatibility rules, and cross-runtime contracts. Keep safety guards until equivalent behavior coverage exists; do not delete a test merely because it reads files, uses mocks, or runs slowly.
### Documentation
User-facing docs live in two places:
+7
View File
@@ -104,6 +104,13 @@ pnpm test
如果改的是前端或某个 package,再补跑对应目录下的测试。
测试质量比测试数量更重要:
- 调用生产函数或挂载真实组件,验证可观察的结果、状态变化、错误或事件。Mock 外部边界,不要 Mock 正在验证的行为。
- 不要在测试中复制实现,也不要用源码字符串匹配锁定 class、局部变量名、模板片段或辅助函数调用写法。这类检查会阻碍无害重构,却不能证明运行行为;布局应在浏览器中验证,而不是从 CSS 字符串推断。
- 回归用例优先补进已有行为测试,不再另加一份源码接线快照。只有输入和预期不同的场景优先使用参数化用例。
- 发布产物、权限、兼容性规则和跨运行时契约可以使用文件内容检查。安全门禁在有等价行为覆盖前保留,不要仅因测试读文件、使用 Mock 或运行较慢就删除。
### 文档
用户文档主要分两块:
Generated
+95 -20
View File
@@ -1701,6 +1701,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc16"
version = "0.4.0"
@@ -1916,6 +1931,7 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
@@ -2002,7 +2018,7 @@ dependencies = [
[[package]]
name = "dbx"
version = "0.6.4"
version = "0.6.14"
dependencies = [
"anyhow",
"axum",
@@ -2035,6 +2051,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"sys-locale",
"sysinfo",
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
@@ -2048,6 +2065,7 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tempfile",
"tiberius",
"tokio",
"tokio-postgres",
@@ -2060,7 +2078,7 @@ dependencies = [
[[package]]
name = "dbx-cli"
version = "0.4.80"
version = "0.4.89"
dependencies = [
"async-trait",
"dbx-core",
@@ -2088,15 +2106,17 @@ dependencies = [
"calamine",
"cbc 0.1.2",
"chrono",
"crc",
"csv",
"dbx-sqlite-worker",
"deadpool-postgres",
"ed25519-dalek 2.2.0",
"encoding_rs",
"flate2",
"font-kit",
"fs2",
"futures",
"httpdate",
"iana-time-zone",
"insta",
"json5",
"jsonwebtoken",
@@ -2125,10 +2145,12 @@ dependencies = [
"rust_decimal",
"rustls 0.23.43",
"rustls-pemfile 2.2.0",
"semver",
"serde",
"serde_json",
"serde_yaml",
"serde_yaml_ng",
"sha1 0.10.7",
"sha2 0.10.9",
"sqlparser",
"ssfmt",
@@ -2156,7 +2178,7 @@ dependencies = [
[[package]]
name = "dbx-mcp"
version = "0.4.80"
version = "0.4.89"
dependencies = [
"async-trait",
"axum",
@@ -2191,7 +2213,7 @@ dependencies = [
[[package]]
name = "dbx-web"
version = "0.6.4"
version = "0.6.14"
dependencies = [
"aes-gcm 0.10.3",
"argon2",
@@ -2210,6 +2232,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
"tokio",
"tokio-util",
"tower-http",
@@ -2586,19 +2609,43 @@ dependencies = [
"digest 0.11.3",
"elliptic-curve",
"rfc6979",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"zeroize",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8 0.10.2",
"signature 2.2.0",
]
[[package]]
name = "ed25519"
version = "3.0.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
dependencies = [
"pkcs8",
"signature",
"pkcs8 0.11.0-rc.11",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek 4.1.3",
"ed25519 2.2.3",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]]
@@ -2608,11 +2655,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b"
dependencies = [
"curve25519-dalek 5.0.0-pre.6",
"ed25519",
"ed25519 3.0.0-rc.4",
"rand_core 0.10.1",
"serde",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"subtle",
"zeroize",
]
@@ -2637,7 +2684,7 @@ dependencies = [
"hybrid-array",
"once_cell",
"pem-rfc7468 1.0.0",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"rand_core 0.10.1",
"rustcrypto-ff",
"rustcrypto-group",
@@ -3034,6 +3081,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -4209,7 +4266,7 @@ dependencies = [
"bcrypt-pbkdf",
"crypto-bigint",
"ecdsa",
"ed25519-dalek",
"ed25519-dalek 3.0.0-pre.6",
"hex",
"hmac 0.13.0",
"num-bigint-dig",
@@ -4221,7 +4278,7 @@ dependencies = [
"sec1",
"sha1 0.11.0",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"ssh-cipher",
"ssh-encoding",
"subtle",
@@ -6096,6 +6153,16 @@ dependencies = [
"spki 0.8.0-rc.4",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der 0.7.10",
"spki 0.7.3",
]
[[package]]
name = "pkcs8"
version = "0.11.0-rc.11"
@@ -6850,6 +6917,7 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -7052,10 +7120,10 @@ dependencies = [
"crypto-primes",
"digest 0.11.3",
"pkcs1",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"rand_core 0.10.1",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"zeroize",
]
@@ -7120,7 +7188,7 @@ dependencies = [
"der 0.8.1",
"digest 0.10.7",
"ecdsa",
"ed25519-dalek",
"ed25519-dalek 3.0.0-pre.6",
"elliptic-curve",
"enum_dispatch",
"flate2",
@@ -7149,7 +7217,7 @@ dependencies = [
"pbkdf2 0.13.0",
"pkcs1",
"pkcs5",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"polyval 0.7.3",
"rand 0.10.2",
"rand_core 0.10.1",
@@ -7164,7 +7232,7 @@ dependencies = [
"sha2 0.10.9",
"sha2 0.11.0",
"sha3",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"ssh-encoding",
"subtle",
@@ -8033,6 +8101,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "signature"
version = "3.0.0"
@@ -8846,8 +8923,6 @@ dependencies = [
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
+11 -2
View File
@@ -1,9 +1,11 @@
[workspace]
resolver = "2"
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker"]
exclude = ["vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/wry"]
exclude = ["vendor/tauri-plugin-updater", "vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/wry"]
[patch.crates-io]
# Offline signed update restoration and checked Windows installer launch.
tauri-plugin-updater = { path = "vendor/tauri-plugin-updater" }
# Tauri 2.11 uses ctor 0.8, which excludes Rust's win7 vendor. This vendors
# upstream rust-ctor#443 until tauri-utils moves to ctor 1.x.
ctor = { path = "vendor/ctor" }
@@ -35,9 +37,16 @@ postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git"
# rustls dangerous-mode connections to legacy certificates. Remove after upstream support lands.
mysql_async = { git = "https://github.com/zipg/mysql_async.git", rev = "4a9e60a62f5a48e35ef9ccc9fc8277d5012b19dd" }
# Preserve readable SQL Server result sets when a row contains an unpaired
# UTF-16 surrogate. Remove after the behavior is available in upstream tiberius.
# UTF-16 surrogate, or varchar/text bytes that are invalid in the column's
# collation (replaced with U+FFFD, no BOM sniffing). Remove after the behavior
# is available in upstream tiberius.
tiberius = { path = "vendor/tiberius" }
[profile.dev.package."*"]
# Dependencies are never breakpoint-inspected in dev; line tables keep
# backtraces resolvable while shrinking dev/test build time and artifacts.
debug = "line-tables-only"
[profile.release]
panic = "abort"
strip = true
+21 -5
View File
@@ -1,5 +1,5 @@
<div align="center">
<p style="font-size: 18px; white-space: nowrap;"><strong>90+ databases in 20 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
<p style="font-size: 18px; white-space: nowrap;"><strong>90+ databases in 25 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
<p>
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX screenshot" width="820" />
@@ -8,7 +8,8 @@
<p>
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fshieldcn.dev%2Fgithub%2Fdownloads%2Ft8y2%2Fdbx%2Fshields.json&amp;style=for-the-badge" /></a>
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=&group_code=1087880322"><img src="https://img.shields.io/badge/QQ_群-1087880322-EB1923?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iODYiIHdpZHRoPSI4NiIgdmlld0JveD0iMCAwIDEyMCAxNDUiPjxwYXRoIGZpbGw9IiNmYWFiMDciIGQ9Ik02MC41MDMgMTQyLjIzN2MtMTIuNTMzIDAtMjQuMDM4LTQuMTk1LTMxLjQ0NS0xMC40Ni0zLjc2MiAxLjEyNC04LjU3NCAyLjkzMi0xMS42MSA1LjE3NS0yLjYgMS45MTgtMi4yNzUgMy44NzQtMS44MDcgNC42NjMgMi4wNTYgMy40NyAzNS4yNzMgMi4yMTYgNDQuODYyIDEuMTM2em0wIDBjMTIuNTM1IDAgMjQuMDM5LTQuMTk1IDMxLjQ0Ny0xMC40NiAzLjc2IDEuMTI0IDguNTczIDIuOTMyIDExLjYxIDUuMTc1IDIuNTk4IDEuOTE4IDIuMjc0IDMuODc0IDEuODA1IDQuNjYzLTIuMDU2IDMuNDctMzUuMjcyIDIuMjE2LTQ0Ljg2MiAxLjEzNnptMCAwIi8+PHBhdGggZD0iTTYwLjU3NiA2Ny4xMTljMjAuNjk4LS4xNCAzNy4yODYtNC4xNDcgNDIuOTA3LTUuNjgzIDEuMzQtLjM2NyAyLjA1Ni0xLjAyNCAyLjA1Ni0xLjAyNC4wMDUtLjE4OS4wODUtMy4zNy4wODUtNS4wMUMxMDUuNjI0IDI3Ljc2OCA5Mi41OC4wMDEgNjAuNSAwIDI4LjQyLjAwMSAxNS4zNzUgMjcuNzY5IDE1LjM3NSA1NS40MDFjMCAxLjY0Mi4wOCA0LjgyMi4wODYgNS4wMSAwIDAgLjU4My42MTUgMS42NS45MTMgNS4xOSAxLjQ0NCAyMi4wOSA1LjY1IDQzLjMxMiA1Ljc5NXptNTYuMjQ1IDIzLjAyYy0xLjI4My00LjEyOS0zLjAzNC04Ljk0NC00LjgwOC0xMy41NjggMCAwLTEuMDItLjEyNi0xLjUzNy4wMjMtMTUuOTEzIDQuNjIzLTM1LjIwMiA3LjU3LTQ5LjkgNy4zOTJoLS4xNTNjLTE0LjYxNi4xNzUtMzMuNzc0LTIuNzM3LTQ5LjYzNC03LjMxNS0uNjA2LS4xNzUtMS44MDItLjEtMS44MDItLjEtMS43NzQgNC42MjQtMy41MjUgOS40NC00LjgwOCAxMy41NjgtNi4xMTkgMTkuNjktNC4xMzYgMjcuODM4LTIuNjI3IDI4LjAyIDMuMjM5LjM5MiAxMi42MDYtMTQuODIxIDEyLjYwNi0xNC44MjEgMCAxNS40NTkgMTMuOTU3IDM5LjE5NSA0NS45MTggMzkuNDEzaC44NDhjMzEuOTYtLjIxOCA0NS45MTctMjMuOTU0IDQ1LjkxNy0zOS40MTMgMCAwIDkuMzY4IDE1LjIxMyAxMi42MDcgMTQuODIyIDEuNTA4LS4xODMgMy40OTEtOC4zMzItMi42MjctMjguMDIxIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTQ5LjA4NSA0MC44MjRjLTQuMzUyLjE5Ny04LjA3LTQuNzYtOC4zMDQtMTEuMDYzLS4yMzYtNi4zMDUgMy4wOTgtMTEuNTc2IDcuNDUtMTEuNzczIDQuMzQ3LS4xOTUgOC4wNjQgNC43NiA4LjMgMTEuMDY1LjIzOCA2LjMwNi0zLjA5NyAxMS41NzctNy40NDYgMTEuNzcxbTMxLjEzMy0xMS4wNjNjLS4yMzMgNi4zMDItMy45NTEgMTEuMjYtOC4zMDMgMTEuMDYzLTQuMzUtLjE5NS03LjY4NC01LjQ2NS03LjQ0Ni0xMS43Ny4yMzYtNi4zMDUgMy45NTItMTEuMjYgOC4zLTExLjA2NiA0LjM1Mi4xOTcgNy42ODYgNS40NjggNy40NDkgMTEuNzczIi8+PHBhdGggZmlsbD0iI2ZhYWIwNyIgZD0iTTg3Ljk1MiA0OS43MjVDODYuNzkgNDcuMTUgNzUuMDc3IDQ0LjI4IDYwLjU3OCA0NC4yOGgtLjE1NmMtMTQuNSAwLTI2LjIxMiAyLjg3LTI3LjM3NSA1LjQ0NmEuODYzLjg2MyAwIDAwLS4wODUuMzY3Ljg4Ljg4IDAgMDAuMTYuNDk2Yy45OCAxLjQyNyAxMy45ODUgOC40ODcgMjcuMyA4LjQ4N2guMTU2YzEzLjMxNCAwIDI2LjMxOS03LjA1OCAyNy4yOTktOC40ODdhLjg3My44NzMgMCAwMC4xNi0uNDk4Ljg1Ni44NTYgMCAwMC0uMDg1LS4zNjUiLz48cGF0aCBkPSJNNTQuNDM0IDI5Ljg1NGMuMTk5IDIuNDktMS4xNjcgNC43MDItMy4wNDYgNC45NDMtMS44ODMuMjQyLTMuNTY4LTEuNTgtMy43NjgtNC4wNy0uMTk3LTIuNDkyIDEuMTY3LTQuNzA0IDMuMDQzLTQuOTQ0IDEuODg2LS4yNDQgMy41NzQgMS41OCAzLjc3MSA0LjA3bTExLjk1Ni44MzNjLjM4NS0uNjg5IDMuMDA0LTQuMzEyIDguNDI3LTIuOTkzIDEuNDI1LjM0NyAyLjA4NC44NTcgMi4yMjMgMS4wNTcuMjA1LjI5Ni4yNjIuNzE4LjA1MyAxLjI4Ni0uNDEyIDEuMTI2LTEuMjYzIDEuMDk1LTEuNzM0Ljg3NS0uMzA1LS4xNDItNC4wODItMi42Ni03LjU2MiAxLjA5Ny0uMjQuMjU3LS42NjguMzQ2LTEuMDczLjA0LS40MDctLjMwOC0uNTc0LS45My0uMzM0LTEuMzYyIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYwLjU3NiA4My4wOGgtLjE1M2MtOS45OTYuMTItMjIuMTE2LTEuMjA0LTMzLjg1NC0zLjUxOC0xLjAwNCA1LjgxOC0xLjYxIDEzLjEzMi0xLjA5IDIxLjg1MyAxLjMxNiAyMi4wNDMgMTQuNDA3IDM1LjkgMzQuNjE0IDM2LjFoLjgyYzIwLjIwOC0uMiAzMy4yOTgtMTQuMDU3IDM0LjYxNi0zNi4xLjUyLTguNzIzLS4wODctMTYuMDM1LTEuMDkyLTIxLjg1NC0xMS43MzkgMi4zMTUtMjMuODYyIDMuNjQtMzMuODYgMy41MTgiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMzIuMTAyIDgxLjIzNXYyMS42OTNzOS45MzcgMi4wMDQgMTkuODkzLjYxNlY4My41MzVjLTYuMzA3LS4zNTctMTMuMTA5LTEuMTUyLTE5Ljg5My0yLjMiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMTA1LjUzOSA2MC40MTJzLTE5LjMzIDYuMTAyLTQ0Ljk2MyA2LjI3NWgtLjE1M2MtMjUuNTkxLS4xNzItNDQuODk2LTYuMjU1LTQ0Ljk2Mi02LjI3NUw4Ljk4NyA3Ni41N2MxNi4xOTMgNC44ODIgMzYuMjYxIDguMDI4IDUxLjQzNiA3Ljg0NWguMTUzYzE1LjE3NS4xODMgMzUuMjQyLTIuOTYzIDUxLjQzNy03Ljg0NXptMCAwIi8+PC9zdmc+" alt="Join QQ Group" /></a>
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-Join-07C160?style=for-the-badge&logo=wechat&logoColor=white" alt="Join WeChat Group" /></a>
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-Join-07C160?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI%2BPHBhdGggZmlsbD0iIzA3YzE2MCIgZD0iTTguNjkxIDIuMTg4QzMuODkxIDIuMTg4IDAgNS40NzYgMCA5LjUzYzAgMi4yMTIgMS4xNyA0LjIwMyAzLjAwMiA1LjU1YS41OS41OSAwIDAgMSAuMjEzLjY2NWwtLjM5IDEuNDhjLS4wMTkuMDctLjA0OC4xNDEtLjA0OC4yMTNjMCAuMTYzLjEzLjI5NS4yOS4yOTVhLjMzLjMzIDAgMCAwIC4xNjctLjA1NGwxLjkwMy0xLjExNGEuODYuODYgMCAwIDEgLjcxNy0uMDk4YTEwLjIgMTAuMiAwIDAgMCAyLjgzNy40MDNjLjI3NiAwIC41NDMtLjAyNy44MTEtLjA1Yy0uODU3LTIuNTc4LjE1Ny00Ljk3MiAxLjkzMi02LjQ0NmMxLjcwMy0xLjQxNSAzLjg4Mi0xLjk4IDUuODUzLTEuODM4Yy0uNTc2LTMuNTgzLTQuMTk2LTYuMzQ4LTguNTk2LTYuMzQ4TTUuNzg1IDUuOTkxYy42NDIgMCAxLjE2Mi41MjkgMS4xNjIgMS4xOGExLjE3IDEuMTcgMCAwIDEtMS4xNjIgMS4xNzhBMS4xNyAxLjE3IDAgMCAxIDQuNjIzIDcuMTdjMC0uNjUxLjUyLTEuMTggMS4xNjItMS4xOHptNS44MTMgMGMuNjQyIDAgMS4xNjIuNTI5IDEuMTYyIDEuMThhMS4xNyAxLjE3IDAgMCAxLTEuMTYyIDEuMTc4YTEuMTcgMS4xNyAwIDAgMS0xLjE2Mi0xLjE3OGMwLS42NTEuNTItMS4xOCAxLjE2Mi0xLjE4bTUuMzQgMi44NjdjLTEuNzk3LS4wNTItMy43NDYuNTEyLTUuMjggMS43ODZjLTEuNzIgMS40MjgtMi42ODcgMy43Mi0xLjc4IDYuMjJjLjk0MiAyLjQ1MyAzLjY2NiA0LjIyOSA2Ljg4NCA0LjIyOWMuODI2IDAgMS42MjItLjEyIDIuMzYxLS4zMzZhLjcyLjcyIDAgMCAxIC41OTguMDgybDEuNTg0LjkyNmEuMy4zIDAgMCAwIC4xNC4wNDdjLjEzNCAwIC4yNC0uMTExLjI0LS4yNDdjMC0uMDYtLjAyMy0uMTItLjAzOC0uMTc3bC0uMzI3LTEuMjMzYS42LjYgMCAwIDEtLjAyMy0uMTU2YS40OS40OSAwIDAgMSAuMjAxLS4zOThDMjMuMDI0IDE4LjQ4IDI0IDE2LjgyIDI0IDE0Ljk4YzAtMy4yMS0yLjkzMS01LjgzNy02LjY1Ni02LjA4OFY4Ljg5Yy0uMTM1LS4wMS0uMjctLjAyNy0uNDA3LS4wM3ptLTIuNTMgMy4yNzRjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk3LS45ODJ6bTQuODQ0IDBjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk2OS0uOTgyIi8%2BPC9zdmc%2B" alt="Join WeChat Group" /></a>
<a href="https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=30cvb14f-a9b1-4b12-adb6-2ff6d476a227" target="_blank"><img src="https://img.shields.io/badge/飞书群-Join-3370FF?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjE2NCAyMDQgNzYyIDYxNyI%2BPHBhdGggZmlsbD0iIzAwRDZCOSIgZD0iTTU1OS45MTUgNTMwLjQ1M2MtNDYuNTA3LTExMS43ODYtMTk0LjU2LTI0OC40NjktMjYyLjgwNi0zMDIuODI2aDMzMy43ODJjNDcuMTQ2IDE2LjI5OCA4Ny42MTYgMTM0LjY3NyAxMDEuOTczIDE5MS44MDgtMzUuNDk5IDMxLjIxLTExOS43ODcgOTcuMTA5LTE3Mi45NSAxMTEuMDE4eiIvPjxwYXRoIGZpbGw9IiMxMzNDOUEiIGQ9Ik02MzIuMDIxIDQ1Mi45OTJjLTQ1LjE4NCA2MC40OC0xMzMuNTQ2IDEyMS45NjMtMTcyLjA1MyAxNDUuMTNsLTIuODggMjQuMjc4IDIzNS45NDcgNjMuNjM3YzMyLjIxMy0yNS45NjIgMTAzLjA2MS04Ny4yOTYgMTI4Ljk2LTEyNC45MjggNC4zOTQtNi4zNzggNjguOTkyLTEzNS45MTQgNzkuNDAyLTE1MS41NTItMTguMjQtMTEuMzA2LTQyLjU2LTE4LjI2MS0xMDQuMjc3LTIxLjczOC04Mi41Ni00LjMzMS0xMTYuNDM3IDIwLjg2NC0xNjUuMDk5IDY1LjE3M3oiLz48cGF0aCBmaWxsPSIjMzM3MEZGIiBkPSJNMTg3Ljg4MyA3MTIuOTE3VjM5My41MTVDMzk3LjU2OCA1OTkuODA4IDU1OC4zMTUgNjQyLjY4OCA2NDEuMDQ1IDY1My43NmMxMjQuNDU5IDUuNDE5IDE1NC42NjctNzMuMDQ1IDE4MS4xNDItOTMuMDk5LTk3LjAyNCAxNTMuMTc0LTIyNC42NCAyMzUuNzM0LTM4NC43NDcgMjM1LjczNC0xMjguMTA3IDAtMjE5Ljc1NS01NS42NTktMjQ5LjU1Ny04My40Nzh6Ii8%2BPC9zdmc%2B" alt="Join Feishu Group" /></a>
<a href="https://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="Join Discord" /></a>
</p>
<p>
@@ -111,6 +112,17 @@
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">Visit UCloud</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">
<img src="docs/public/sponsors/hualong-card.png" alt="HuaLongAI" width="175" />
</a>
</td>
<td>
HuaLongAI is a model API relay built for heavy AI developers, offering 100% official-source Codex and Claude models with transparent token-level billing, enterprise contracts, and invoicing.
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">Visit HuaLongAI</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
@@ -167,7 +179,7 @@
<table>
<tr>
<td width="50%">
<h3>🪶 20 MB, zero runtime bloat</h3>
<h3>🪶 25 MB, zero runtime bloat</h3>
<p>No Java JRE. No Python venv. No bundled Chromium. DBX ships as a single small binary — download, install, connect. DBeaver needs Java; TablePlus is Freemium. DBX runs everywhere with nothing extra.</p>
</td>
<td width="50%">
@@ -191,7 +203,7 @@
### 90+ Databases, One Tool
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~25 MB app. No bundled Chromium.
### Query Editor
@@ -447,6 +459,8 @@ make docs
The official DBX documentation site lives in `docs/`. If you want to improve the website content or documentation pages, edit the files under `docs/` and run `make docs` to preview the site locally.
Plugin authors should start with [Develop and Submit DBX Plugins](https://dbxio.com/en/docs/plugin-development). Plugin source normally stays in its own repository; Marketplace listing pull requests go to [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store), while plugin host, SDK, and CLI changes go to this repository.
For clean, reproducible local database instances, use the versioned Docker Compose recipes under [`deploy/database/`](deploy/database/README.md):
```bash
@@ -484,6 +498,7 @@ The installer will be in `src-tauri/target/release/bundle/`.
## Documentation
- [Official docs](https://dbxio.com/en/docs/what-is-dbx) — feature guides and tutorials
- [Plugin development](https://dbxio.com/en/docs/plugin-development) — create plugins and submit Marketplace listings to the correct repository
- [Database Test Lab](https://dbxio.com/en/docs/database-lab) — local database recipes for development and verification
- [Contributing](CONTRIBUTING.md) — how to pick up issues and open PRs
- [Web API reference](docs/content/docs/web-api.mdx) — HTTP API for Docker/Web deployments
@@ -506,7 +521,8 @@ DBX is free and open source, but ongoing maintenance, database compatibility tes
<a href="https://discord.gg/W7NyVDRt6a" target="_blank"><img src="https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://qm.qq.com/q/1087880322" target="_blank"><img src="https://img.shields.io/badge/QQ%20群-1087880322-EB1923?logo=tencentqq&logoColor=white" alt="QQ Group" /></a>
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-Join-07C160?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-Join-07C160?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI%2BPHBhdGggZmlsbD0iIzA3YzE2MCIgZD0iTTguNjkxIDIuMTg4QzMuODkxIDIuMTg4IDAgNS40NzYgMCA5LjUzYzAgMi4yMTIgMS4xNyA0LjIwMyAzLjAwMiA1LjU1YS41OS41OSAwIDAgMSAuMjEzLjY2NWwtLjM5IDEuNDhjLS4wMTkuMDctLjA0OC4xNDEtLjA0OC4yMTNjMCAuMTYzLjEzLjI5NS4yOS4yOTVhLjMzLjMzIDAgMCAwIC4xNjctLjA1NGwxLjkwMy0xLjExNGEuODYuODYgMCAwIDEgLjcxNy0uMDk4YTEwLjIgMTAuMiAwIDAgMCAyLjgzNy40MDNjLjI3NiAwIC41NDMtLjAyNy44MTEtLjA1Yy0uODU3LTIuNTc4LjE1Ny00Ljk3MiAxLjkzMi02LjQ0NmMxLjcwMy0xLjQxNSAzLjg4Mi0xLjk4IDUuODUzLTEuODM4Yy0uNTc2LTMuNTgzLTQuMTk2LTYuMzQ4LTguNTk2LTYuMzQ4TTUuNzg1IDUuOTkxYy42NDIgMCAxLjE2Mi41MjkgMS4xNjIgMS4xOGExLjE3IDEuMTcgMCAwIDEtMS4xNjIgMS4xNzhBMS4xNyAxLjE3IDAgMCAxIDQuNjIzIDcuMTdjMC0uNjUxLjUyLTEuMTggMS4xNjItMS4xOHptNS44MTMgMGMuNjQyIDAgMS4xNjIuNTI5IDEuMTYyIDEuMThhMS4xNyAxLjE3IDAgMCAxLTEuMTYyIDEuMTc4YTEuMTcgMS4xNyAwIDAgMS0xLjE2Mi0xLjE3OGMwLS42NTEuNTItMS4xOCAxLjE2Mi0xLjE4bTUuMzQgMi44NjdjLTEuNzk3LS4wNTItMy43NDYuNTEyLTUuMjggMS43ODZjLTEuNzIgMS40MjgtMi42ODcgMy43Mi0xLjc4IDYuMjJjLjk0MiAyLjQ1MyAzLjY2NiA0LjIyOSA2Ljg4NCA0LjIyOWMuODI2IDAgMS42MjItLjEyIDIuMzYxLS4zMzZhLjcyLjcyIDAgMCAxIC41OTguMDgybDEuNTg0LjkyNmEuMy4zIDAgMCAwIC4xNC4wNDdjLjEzNCAwIC4yNC0uMTExLjI0LS4yNDdjMC0uMDYtLjAyMy0uMTItLjAzOC0uMTc3bC0uMzI3LTEuMjMzYS42LjYgMCAwIDEtLjAyMy0uMTU2YS40OS40OSAwIDAgMSAuMjAxLS4zOThDMjMuMDI0IDE4LjQ4IDI0IDE2LjgyIDI0IDE0Ljk4YzAtMy4yMS0yLjkzMS01LjgzNy02LjY1Ni02LjA4OFY4Ljg5Yy0uMTM1LS4wMS0uMjctLjAyNy0uNDA3LS4wM3ptLTIuNTMgMy4yNzRjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk3LS45ODJ6bTQuODQ0IDBjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk2OS0uOTgyIi8%2BPC9zdmc%2B" alt="WeChat Group" /></a><a href="https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=30cvb14f-a9b1-4b12-adb6-2ff6d476a227" target="_blank"><img src="https://img.shields.io/badge/飞书群-Join-3370FF?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjE2NCAyMDQgNzYyIDYxNyI%2BPHBhdGggZmlsbD0iIzAwRDZCOSIgZD0iTTU1OS45MTUgNTMwLjQ1M2MtNDYuNTA3LTExMS43ODYtMTk0LjU2LTI0OC40NjktMjYyLjgwNi0zMDIuODI2aDMzMy43ODJjNDcuMTQ2IDE2LjI5OCA4Ny42MTYgMTM0LjY3NyAxMDEuOTczIDE5MS44MDgtMzUuNDk5IDMxLjIxLTExOS43ODcgOTcuMTA5LTE3Mi45NSAxMTEuMDE4eiIvPjxwYXRoIGZpbGw9IiMxMzNDOUEiIGQ9Ik02MzIuMDIxIDQ1Mi45OTJjLTQ1LjE4NCA2MC40OC0xMzMuNTQ2IDEyMS45NjMtMTcyLjA1MyAxNDUuMTNsLTIuODggMjQuMjc4IDIzNS45NDcgNjMuNjM3YzMyLjIxMy0yNS45NjIgMTAzLjA2MS04Ny4yOTYgMTI4Ljk2LTEyNC45MjggNC4zOTQtNi4zNzggNjguOTkyLTEzNS45MTQgNzkuNDAyLTE1MS41NTItMTguMjQtMTEuMzA2LTQyLjU2LTE4LjI2MS0xMDQuMjc3LTIxLjczOC04Mi41Ni00LjMzMS0xMTYuNDM3IDIwLjg2NC0xNjUuMDk5IDY1LjE3M3oiLz48cGF0aCBmaWxsPSIjMzM3MEZGIiBkPSJNMTg3Ljg4MyA3MTIuOTE3VjM5My41MTVDMzk3LjU2OCA1OTkuODA4IDU1OC4zMTUgNjQyLjY4OCA2NDEuMDQ1IDY1My43NmMxMjQuNDU5IDUuNDE5IDE1NC42NjctNzMuMDQ1IDE4MS4xNDItOTMuMDk5LTk3LjAyNCAxNTMuMTc0LTIyNC42NCAyMzUuNzM0LTM4NC43NDcgMjM1LjczNC0xMjguMTA3IDAtMjE5Ljc1NS01NS42NTktMjQ5LjU1Ny04My40Nzh6Ii8%2BPC9zdmc%2B" alt="Feishu Group" /></a>
[![LINUX DO](https://img.shields.io/badge/LINUX%20DO-Community-blue)](https://linux.do)
[![1Panel](https://img.shields.io/badge/1Panel-Partner-005EEB?logo=1panel&logoColor=white)](https://1panel.cn)
+20 -7
View File
@@ -1,5 +1,5 @@
<div align="center">
<p style="font-size: 18px; white-space: nowrap;"><strong>20 MB 驾驭 90+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
<p style="font-size: 18px; white-space: nowrap;"><strong>25 MB 驾驭 90+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
<p>
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX 截图" width="820" />
@@ -8,7 +8,8 @@
<p>
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fshieldcn.dev%2Fgithub%2Fdownloads%2Ft8y2%2Fdbx%2Fshields.json&amp;style=for-the-badge" /></a>
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=&group_code=1087880322"><img src="https://img.shields.io/badge/QQ_群-1087880322-EB1923?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iODYiIHdpZHRoPSI4NiIgdmlld0JveD0iMCAwIDEyMCAxNDUiPjxwYXRoIGZpbGw9IiNmYWFiMDciIGQ9Ik02MC41MDMgMTQyLjIzN2MtMTIuNTMzIDAtMjQuMDM4LTQuMTk1LTMxLjQ0NS0xMC40Ni0zLjc2MiAxLjEyNC04LjU3NCAyLjkzMi0xMS42MSA1LjE3NS0yLjYgMS45MTgtMi4yNzUgMy44NzQtMS44MDcgNC42NjMgMi4wNTYgMy40NyAzNS4yNzMgMi4yMTYgNDQuODYyIDEuMTM2em0wIDBjMTIuNTM1IDAgMjQuMDM5LTQuMTk1IDMxLjQ0Ny0xMC40NiAzLjc2IDEuMTI0IDguNTczIDIuOTMyIDExLjYxIDUuMTc1IDIuNTk4IDEuOTE4IDIuMjc0IDMuODc0IDEuODA1IDQuNjYzLTIuMDU2IDMuNDctMzUuMjcyIDIuMjE2LTQ0Ljg2MiAxLjEzNnptMCAwIi8+PHBhdGggZD0iTTYwLjU3NiA2Ny4xMTljMjAuNjk4LS4xNCAzNy4yODYtNC4xNDcgNDIuOTA3LTUuNjgzIDEuMzQtLjM2NyAyLjA1Ni0xLjAyNCAyLjA1Ni0xLjAyNC4wMDUtLjE4OS4wODUtMy4zNy4wODUtNS4wMUMxMDUuNjI0IDI3Ljc2OCA5Mi41OC4wMDEgNjAuNSAwIDI4LjQyLjAwMSAxNS4zNzUgMjcuNzY5IDE1LjM3NSA1NS40MDFjMCAxLjY0Mi4wOCA0LjgyMi4wODYgNS4wMSAwIDAgLjU4My42MTUgMS42NS45MTMgNS4xOSAxLjQ0NCAyMi4wOSA1LjY1IDQzLjMxMiA1Ljc5NXptNTYuMjQ1IDIzLjAyYy0xLjI4My00LjEyOS0zLjAzNC04Ljk0NC00LjgwOC0xMy41NjggMCAwLTEuMDItLjEyNi0xLjUzNy4wMjMtMTUuOTEzIDQuNjIzLTM1LjIwMiA3LjU3LTQ5LjkgNy4zOTJoLS4xNTNjLTE0LjYxNi4xNzUtMzMuNzc0LTIuNzM3LTQ5LjYzNC03LjMxNS0uNjA2LS4xNzUtMS44MDItLjEtMS44MDItLjEtMS43NzQgNC42MjQtMy41MjUgOS40NC00LjgwOCAxMy41NjgtNi4xMTkgMTkuNjktNC4xMzYgMjcuODM4LTIuNjI3IDI4LjAyIDMuMjM5LjM5MiAxMi42MDYtMTQuODIxIDEyLjYwNi0xNC44MjEgMCAxNS40NTkgMTMuOTU3IDM5LjE5NSA0NS45MTggMzkuNDEzaC44NDhjMzEuOTYtLjIxOCA0NS45MTctMjMuOTU0IDQ0LjkxNy0zOS40MTMgMCAwIDkuMzY4IDE1LjIxMyAxMi42MDcgMTQuODIyIDEuNTA4LS4xODMgMy40OTEtOC4zMzItMi42MjctMjguMDIxIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTQ5LjA4NSA0MC44MjRjLTQuMzUyLjE5Ny04LjA3LTQuNzYtOC4zMDQtMTEuMDYzLS4yMzYtNi4zMDUgMy4wOTgtMTEuNTc2IDcuNDUtMTEuNzczIDQuMzQ3LS4xOTUgOC4wNjQgNC43NiA4LjMgMTEuMDY1LjIzOCA2LjMwNi0zLjA5NyAxMS41NzctNy40NDYgMTEuNzcxbTMxLjEzMy0xMS4wNjNjLS4yMzMgNi4zMDItMy45NTEgMTEuMjYtOC4zMDMgMTEuMDYzLTQuMzUtLjE5NS03LjY4NC01LjQ2NS03LjQ0Ni0xMS43Ny4yMzYtNi4zMDUgMy45NTItMTEuMjYgOC4zLTExLjA2NiA0LjM1Mi4xOTcgNy42ODYgNS40NjggNy40NDkgMTEuNzczIi8+PHBhdGggZmlsbD0iI2ZhYWIwNyIgZD0iTTg3Ljk1MiA0OS43MjVDODYuNzkgNDcuMTUgNzUuMDc3IDQ0LjI4IDYwLjU3OCA0NC4yOGgtLjE1NmMtMTQuNSAwLTI2LjIxMiAyLjg3LTI3LjM3NSA1LjQ0NmEuODYzLjg2MyAwIDAwLS4wODUuMzY3Ljg4Ljg4IDAgMDAuMTYuNDk2Yy45OCAxLjQyNyAxMy45ODUgOC40ODcgMjcuMyA4LjQ4N2guMTU2YzEzLjMxNCAwIDI2LjMxOS03LjA1OCAyNy4yOTktOC40ODdhLjg3My44NzMgMCAwMC4xNi0uNDk4Ljg1Ni44NTYgMCAwMC0uMDg1LS4zNjUiLz48cGF0aCBkPSJNNTQuNDM0IDI5Ljg1NGMuMTk5IDIuNDktMS4xNjcgNC43MDItMy4wNDYgNC45NDMtMS44ODMuMjQyLTMuNTY4LTEuNTgtMy43NjgtNC4wNy0uMTk3LTIuNDkyIDEuMTY3LTQuNzA0IDMuMDQzLTQuOTQ0IDEuODg2LS4yNDQgMy41NzQgMS41OCAzLjc3MSA0LjA3bTExLjk1Ni44MzNjLjM4NS0uNjg5IDMuMDA0LTQuMzEyIDguNDI3LTIuOTkzIDEuNDI1LjM0NyAyLjA4NC44NTcgMi4yMjMgMS4wNTcuMjA1LjI5Ni4yNjIuNzE4LjA1MyAxLjI4Ni0uNDEyIDEuMTI2LTEuMjYzIDEuMDk1LTEuNzM0Ljg3NS0uMzA1LS4xNDItNC4wODItMi42Ni03LjU2MiAxLjA5Ny0uMjQuMjU3LS42NjguMzQ2LTEuMDczLjA0LS40MDctLjMwOC0uNTc0LS45My0uMzM0LTEuMzYyIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYwLjU3NiA4My4wOGgtLjE1M2MtOS45OTYuMTItMjIuMTE2LTEuMjA0LTMzLjg1NC0zLjUxOC0xLjAwNCA1LjgxOC0xLjYxIDEzLjEzMi0xLjA5IDIxLjg1MyAxLjMxNiAyMi4wNDMgMTQuNDA3IDM1LjkgMzQuNjE0IDM2LjFoLjgyYzIwLjIwOC0uMiAzMy4yOTgtMTQuMDU3IDM0LjYxNi0zNi4xLjUyLTguNzIzLS4wODctMTYuMDM1LTEuMDkyLTIxLjg1NC0xMS43MzkgMi4zMTUtMjMuODYyIDMuNjQtMzMuODYgMy41MTgiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMzIuMTAyIDgxLjIzNXYyMS42OTNzOS45MzcgMi4wMDQgMTkuODkzLjYxNlY4My41MzVjLTYuMzA3LS4zNTctMTMuMTA5LTEuMTUyLTE5Ljg5My0yLjMiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMTA1LjUzOSA2MC40MTJzLTE5LjMzIDYuMTAyLTQ0Ljk2MyA2LjI3NWgtLjE1M2MtMjUuNTkxLS4xNzItNDQuODk2LTYuMjU1LTQ0Ljk2Mi02LjI3NUw4Ljk4NyA3Ni41N2MxNi4xOTMgNC44ODIgMzYuMjYxIDguMDI4IDUxLjQzNiA3Ljg0NWguMTUzYzE1LjE3NS4xODMgMzUuMjQyLTIuOTYzIDUxLjQzNy03Ljg0NXptMCAwIi8+PC9zdmc+" alt="加入 QQ 群" /></a>
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信交流群-点击加入-07C160?style=for-the-badge&logo=wechat&logoColor=white" alt="加入微信交流群" /></a>
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-点击加入-07C160?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI%2BPHBhdGggZmlsbD0iIzA3YzE2MCIgZD0iTTguNjkxIDIuMTg4QzMuODkxIDIuMTg4IDAgNS40NzYgMCA5LjUzYzAgMi4yMTIgMS4xNyA0LjIwMyAzLjAwMiA1LjU1YS41OS41OSAwIDAgMSAuMjEzLjY2NWwtLjM5IDEuNDhjLS4wMTkuMDctLjA0OC4xNDEtLjA0OC4yMTNjMCAuMTYzLjEzLjI5NS4yOS4yOTVhLjMzLjMzIDAgMCAwIC4xNjctLjA1NGwxLjkwMy0xLjExNGEuODYuODYgMCAwIDEgLjcxNy0uMDk4YTEwLjIgMTAuMiAwIDAgMCAyLjgzNy40MDNjLjI3NiAwIC41NDMtLjAyNy44MTEtLjA1Yy0uODU3LTIuNTc4LjE1Ny00Ljk3MiAxLjkzMi02LjQ0NmMxLjcwMy0xLjQxNSAzLjg4Mi0xLjk4IDUuODUzLTEuODM4Yy0uNTc2LTMuNTgzLTQuMTk2LTYuMzQ4LTguNTk2LTYuMzQ4TTUuNzg1IDUuOTkxYy42NDIgMCAxLjE2Mi41MjkgMS4xNjIgMS4xOGExLjE3IDEuMTcgMCAwIDEtMS4xNjIgMS4xNzhBMS4xNyAxLjE3IDAgMCAxIDQuNjIzIDcuMTdjMC0uNjUxLjUyLTEuMTggMS4xNjItMS4xOHptNS44MTMgMGMuNjQyIDAgMS4xNjIuNTI5IDEuMTYyIDEuMThhMS4xNyAxLjE3IDAgMCAxLTEuMTYyIDEuMTc4YTEuMTcgMS4xNyAwIDAgMS0xLjE2Mi0xLjE3OGMwLS42NTEuNTItMS4xOCAxLjE2Mi0xLjE4bTUuMzQgMi44NjdjLTEuNzk3LS4wNTItMy43NDYuNTEyLTUuMjggMS43ODZjLTEuNzIgMS40MjgtMi42ODcgMy43Mi0xLjc4IDYuMjJjLjk0MiAyLjQ1MyAzLjY2NiA0LjIyOSA2Ljg4NCA0LjIyOWMuODI2IDAgMS42MjItLjEyIDIuMzYxLS4zMzZhLjcyLjcyIDAgMCAxIC41OTguMDgybDEuNTg0LjkyNmEuMy4zIDAgMCAwIC4xNC4wNDdjLjEzNCAwIC4yNC0uMTExLjI0LS4yNDdjMC0uMDYtLjAyMy0uMTItLjAzOC0uMTc3bC0uMzI3LTEuMjMzYS42LjYgMCAwIDEtLjAyMy0uMTU2YS40OS40OSAwIDAgMSAuMjAxLS4zOThDMjMuMDI0IDE4LjQ4IDI0IDE2LjgyIDI0IDE0Ljk4YzAtMy4yMS0yLjkzMS01LjgzNy02LjY1Ni02LjA4OFY4Ljg5Yy0uMTM1LS4wMS0uMjctLjAyNy0uNDA3LS4wM3ptLTIuNTMgMy4yNzRjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk3LS45ODJ6bTQuODQ0IDBjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk2OS0uOTgyIi8%2BPC9zdmc%2B" alt="加入微信群" /></a>
<a href="https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=30cvb14f-a9b1-4b12-adb6-2ff6d476a227" target="_blank"><img src="https://img.shields.io/badge/飞书群-点击加入-3370FF?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjE2NCAyMDQgNzYyIDYxNyI%2BPHBhdGggZmlsbD0iIzAwRDZCOSIgZD0iTTU1OS45MTUgNTMwLjQ1M2MtNDYuNTA3LTExMS43ODYtMTk0LjU2LTI0OC40NjktMjYyLjgwNi0zMDIuODI2aDMzMy43ODJjNDcuMTQ2IDE2LjI5OCA4Ny42MTYgMTM0LjY3NyAxMDEuOTczIDE5MS44MDgtMzUuNDk5IDMxLjIxLTExOS43ODcgOTcuMTA5LTE3Mi45NSAxMTEuMDE4eiIvPjxwYXRoIGZpbGw9IiMxMzNDOUEiIGQ9Ik02MzIuMDIxIDQ1Mi45OTJjLTQ1LjE4NCA2MC40OC0xMzMuNTQ2IDEyMS45NjMtMTcyLjA1MyAxNDUuMTNsLTIuODggMjQuMjc4IDIzNS45NDcgNjMuNjM3YzMyLjIxMy0yNS45NjIgMTAzLjA2MS04Ny4yOTYgMTI4Ljk2LTEyNC45MjggNC4zOTQtNi4zNzggNjguOTkyLTEzNS45MTQgNzkuNDAyLTE1MS41NTItMTguMjQtMTEuMzA2LTQyLjU2LTE4LjI2MS0xMDQuMjc3LTIxLjczOC04Mi41Ni00LjMzMS0xMTYuNDM3IDIwLjg2NC0xNjUuMDk5IDY1LjE3M3oiLz48cGF0aCBmaWxsPSIjMzM3MEZGIiBkPSJNMTg3Ljg4MyA3MTIuOTE3VjM5My41MTVDMzk3LjU2OCA1OTkuODA4IDU1OC4zMTUgNjQyLjY4OCA2NDEuMDQ1IDY1My43NmMxMjQuNDU5IDUuNDE5IDE1NC42NjctNzMuMDQ1IDE4MS4xNDItOTMuMDk5LTk3LjAyNCAxNTMuMTc0LTIyNC42NCAyMzUuNzM0LTM4NC43NDcgMjM1LjczNC0xMjguMTA3IDAtMjE5Ljc1NS01NS42NTktMjQ5LjU1Ny04My40Nzh6Ii8%2BPC9zdmc%2B" alt="加入飞书群" /></a>
<a href="https://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="加入 Discord" /></a>
</p>
<p>
@@ -111,6 +112,17 @@
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">访问 UCloud 优刻得</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">
<img src="docs/public/sponsors/hualong-card.png" alt="HuaLongAI" width="175" />
</a>
</td>
<td>
HuaLongAI(华龙算力)是面向重度 AI 开发者的模型 API 中转服务商,主营 Codex 与 Claude 系列模型,100% 官方源直供、不掺假;计费透明,Token 级账单可逐笔核验,支持企业合同与发票。
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">访问 HuaLongAI</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
@@ -167,7 +179,7 @@
<table>
<tr>
<td width="50%">
<h3>🪶 20 MB,极致轻量</h3>
<h3>🪶 25 MB,极致轻量</h3>
<p>无需 Java 运行环境,无需 Python 虚拟环境,不内嵌 Chromium。DBX 是单个小巧的二进制文件——下载、安装、连接。DBeaver 依赖 Java;TablePlus 是 Freemium。DBX 全平台可用,无需额外运行时。</p>
</td>
<td width="50%">
@@ -191,7 +203,7 @@
### 90+ 种数据库,一个工具搞定
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 25 MB 的应用里,不内嵌 Chromium。
### 查询编辑器
@@ -495,7 +507,8 @@ make package
<a href="https://discord.gg/W7NyVDRt6a" target="_blank"><img src="https://img.shields.io/badge/Discord-加入-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://qm.qq.com/q/1087880322" target="_blank"><img src="https://img.shields.io/badge/QQ%20群-1087880322-EB1923?logo=tencentqq&logoColor=white" alt="QQ 群" /></a>
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信交流群-点击加入-07C160?logo=wechat&logoColor=white" alt="微信交流群" /></a>
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-点击加入-07C160?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI%2BPHBhdGggZmlsbD0iIzA3YzE2MCIgZD0iTTguNjkxIDIuMTg4QzMuODkxIDIuMTg4IDAgNS40NzYgMCA5LjUzYzAgMi4yMTIgMS4xNyA0LjIwMyAzLjAwMiA1LjU1YS41OS41OSAwIDAgMSAuMjEzLjY2NWwtLjM5IDEuNDhjLS4wMTkuMDctLjA0OC4xNDEtLjA0OC4yMTNjMCAuMTYzLjEzLjI5NS4yOS4yOTVhLjMzLjMzIDAgMCAwIC4xNjctLjA1NGwxLjkwMy0xLjExNGEuODYuODYgMCAwIDEgLjcxNy0uMDk4YTEwLjIgMTAuMiAwIDAgMCAyLjgzNy40MDNjLjI3NiAwIC41NDMtLjAyNy44MTEtLjA1Yy0uODU3LTIuNTc4LjE1Ny00Ljk3MiAxLjkzMi02LjQ0NmMxLjcwMy0xLjQxNSAzLjg4Mi0xLjk4IDUuODUzLTEuODM4Yy0uNTc2LTMuNTgzLTQuMTk2LTYuMzQ4LTguNTk2LTYuMzQ4TTUuNzg1IDUuOTkxYy42NDIgMCAxLjE2Mi41MjkgMS4xNjIgMS4xOGExLjE3IDEuMTcgMCAwIDEtMS4xNjIgMS4xNzhBMS4xNyAxLjE3IDAgMCAxIDQuNjIzIDcuMTdjMC0uNjUxLjUyLTEuMTggMS4xNjItMS4xOHptNS44MTMgMGMuNjQyIDAgMS4xNjIuNTI5IDEuMTYyIDEuMThhMS4xNyAxLjE3IDAgMCAxLTEuMTYyIDEuMTc4YTEuMTcgMS4xNyAwIDAgMS0xLjE2Mi0xLjE3OGMwLS42NTEuNTItMS4xOCAxLjE2Mi0xLjE4bTUuMzQgMi44NjdjLTEuNzk3LS4wNTItMy43NDYuNTEyLTUuMjggMS43ODZjLTEuNzIgMS40MjgtMi42ODcgMy43Mi0xLjc4IDYuMjJjLjk0MiAyLjQ1MyAzLjY2NiA0LjIyOSA2Ljg4NCA0LjIyOWMuODI2IDAgMS42MjItLjEyIDIuMzYxLS4zMzZhLjcyLjcyIDAgMCAxIC41OTguMDgybDEuNTg0LjkyNmEuMy4zIDAgMCAwIC4xNC4wNDdjLjEzNCAwIC4yNC0uMTExLjI0LS4yNDdjMC0uMDYtLjAyMy0uMTItLjAzOC0uMTc3bC0uMzI3LTEuMjMzYS42LjYgMCAwIDEtLjAyMy0uMTU2YS40OS40OSAwIDAgMSAuMjAxLS4zOThDMjMuMDI0IDE4LjQ4IDI0IDE2LjgyIDI0IDE0Ljk4YzAtMy4yMS0yLjkzMS01LjgzNy02LjY1Ni02LjA4OFY4Ljg5Yy0uMTM1LS4wMS0uMjctLjAyNy0uNDA3LS4wM3ptLTIuNTMgMy4yNzRjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk3LS45ODJ6bTQuODQ0IDBjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk2OS0uOTgyIi8%2BPC9zdmc%2B" alt="微信群" /></a><a href="https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=30cvb14f-a9b1-4b12-adb6-2ff6d476a227" target="_blank"><img src="https://img.shields.io/badge/飞书群-点击加入-3370FF?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjE2NCAyMDQgNzYyIDYxNyI%2BPHBhdGggZmlsbD0iIzAwRDZCOSIgZD0iTTU1OS45MTUgNTMwLjQ1M2MtNDYuNTA3LTExMS43ODYtMTk0LjU2LTI0OC40NjktMjYyLjgwNi0zMDIuODI2aDMzMy43ODJjNDcuMTQ2IDE2LjI5OCA4Ny42MTYgMTM0LjY3NyAxMDEuOTczIDE5MS44MDgtMzUuNDk5IDMxLjIxLTExOS43ODcgOTcuMTA5LTE3Mi45NSAxMTEuMDE4eiIvPjxwYXRoIGZpbGw9IiMxMzNDOUEiIGQ9Ik02MzIuMDIxIDQ1Mi45OTJjLTQ1LjE4NCA2MC40OC0xMzMuNTQ2IDEyMS45NjMtMTcyLjA1MyAxNDUuMTNsLTIuODggMjQuMjc4IDIzNS45NDcgNjMuNjM3YzMyLjIxMy0yNS45NjIgMTAzLjA2MS04Ny4yOTYgMTI4Ljk2LTEyNC45MjggNC4zOTQtNi4zNzggNjguOTkyLTEzNS45MTQgNzkuNDAyLTE1MS41NTItMTguMjQtMTEuMzA2LTQyLjU2LTE4LjI2MS0xMDQuMjc3LTIxLjczOC04Mi41Ni00LjMzMS0xMTYuNDM3IDIwLjg2NC0xNjUuMDk5IDY1LjE3M3oiLz48cGF0aCBmaWxsPSIjMzM3MEZGIiBkPSJNMTg3Ljg4MyA3MTIuOTE3VjM5My41MTVDMzk3LjU2OCA1OTkuODA4IDU1OC4zMTUgNjQyLjY4OCA2NDEuMDQ1IDY1My43NmMxMjQuNDU5IDUuNDE5IDE1NC42NjctNzMuMDQ1IDE4MS4xNDItOTMuMDk5LTk3LjAyNCAxNTMuMTc0LTIyNC42NCAyMzUuNzM0LTM4NC43NDcgMjM1LjczNC0xMjguMTA3IDAtMjE5Ljc1NS01NS42NTktMjQ5LjU1Ny04My40Nzh6Ii8%2BPC9zdmc%2B" alt="飞书群" /></a>
[![LINUX DO](https://img.shields.io/badge/LINUX%20DO-社区-blue)](https://linux.do)
[![1Panel](https://img.shields.io/badge/1Panel-合作伙伴-005EEB?logo=1panel&logoColor=white)](https://1panel.cn)
@@ -515,7 +528,7 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
<details>
<summary><strong>DBX 会收集用户数据吗?</strong></summary>
不会。DBX 不收集任何遥测数据。自动更新功能仅检查 GitHub Releases 获取新版本——你可以在设置中禁用它。
不会。DBX 不收集任何遥测数据。开启更新通知时,桌面端会通过所选更新源检查新版本并静默下载安装包;下载并校验完成后,更新入口显示提示,只有点击“重启并更新”才会安装。已下载的安装包会保留到下次启动,也可忽略该版本。你可以在设置中关闭自动检查和下载。
</details>
<details>
@@ -526,7 +539,7 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
<details>
<summary><strong>DBX 和 DBeaver / TablePlus / Beekeeper Studio 有什么区别?</strong></summary>
DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 90+ 数据库、桌面端、Docker 和 Web。
DBX 仅 25 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 90+ 数据库、桌面端、Docker 和 Web。
</details>
<details>
+3
View File
@@ -26,5 +26,8 @@ Security-sensitive areas include:
- AI provider keys and OpenAI-compatible endpoint configuration.
- MCP and CLI access to local DBX connections.
- Docker web service authentication and data directory handling.
- Plugin package verification, native sidecar execution, sandboxed plugin UI, host bridge permissions, and plugin connection secrets.
Plugin connection secrets are stored outside ordinary connection JSON. Unencrypted sync snapshots are always redacted. If encrypted secret sync is enabled with a user-provided passphrase, plugin secrets are included only in the encrypted payload and are restored through the normal secret-store path.
Please avoid testing against systems you do not own or have explicit permission to assess.
+1
View File
@@ -1,4 +1,5 @@
**/libs/*.jar
!drivers/cache/libs/CacheDB.jar
!drivers/oscar/libs/oscarJDBC8.jar
!drivers/uxdb/libs/uxdbjdbc-2.1.2.3p.jre8.jar
gradle.properties
+2
View File
@@ -50,6 +50,8 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
| zookeeper | Apache ZooKeeper | go-zookeeper native agent |
| rabbitmq | RabbitMQ | amqp091-go native agent |
| rocketmq | Apache RocketMQ | rocketmq-admin-go native agent |
| iris | InterSystems IRIS | InterSystems IRIS JDBC |
| cache | InterSystems Caché | InterSystems Caché JDBC (CacheDB) |
## Multi-JRE Support
+2
View File
@@ -49,6 +49,8 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
| zookeeper | Apache ZooKeeper | go-zookeeper 原生 Agent |
| rabbitmq | RabbitMQ | amqp091-go 原生 agent |
| rocketmq | Apache RocketMQ | rocketmq-admin-go 原生 agent |
| iris | InterSystems IRIS | InterSystems IRIS JDBC |
| cache | InterSystems Caché | InterSystems Caché JDBC (CacheDB) |
## 多 JRE 支持
+1 -1
View File
@@ -7,7 +7,7 @@ def legacyStandaloneProjects = ['mongodb', 'kafka'] as Set
def pooledJdbcProjects = [
'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo',
'ignite', 'ignite3', 'informix', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
'ignite', 'ignite3', 'informix', 'iris', 'cache', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
'snowflake', 'spanner', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
'vertica', 'yashandb'
] as Set
@@ -255,13 +255,19 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
@Override
public QueryResult executeTransaction(List<String> statements, String schema) {
return TransactionExecutor.executeUpdateStatements(
requireConnected(),
statements,
schema,
this::setSchemaSQL,
this::resetSchemaSQL
);
Connection conn = requireConnected();
return unchecked(() -> {
if (!conn.getAutoCommit()) {
throw new IllegalStateException("Cannot start a one-shot transaction while a manual transaction is open");
}
return TransactionExecutor.executeUpdateStatements(
conn,
statements,
schema,
this::setSchemaSQL,
this::resetSchemaSQL
);
});
}
@Override
@@ -377,7 +383,9 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
JdbcExecutor executor,
boolean succeeded,
boolean requiresSessionAffinity,
boolean evictAfterRequest
boolean evictAfterRequest,
boolean endsSessionAffinity,
boolean preservesSchemaContext
) {
if (poolRegistry == null) {
return;
@@ -389,7 +397,12 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
}
if (succeeded && requiresSessionAffinity) {
sessionAffinity = true;
JdbcSchemaSwitcher.forget(connection);
if (!preservesSchemaContext) {
JdbcSchemaSwitcher.forget(connection);
}
}
if (succeeded && endsSessionAffinity) {
sessionAffinity = false;
}
if (pooledLease == null) {
connection = null;
@@ -441,7 +454,57 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
}
try {
String quote = connection.getMetaData().getIdentifierQuoteString();
return quote == null || quote.trim().isEmpty() ? "" : quote.trim();
if (quote == null || quote.trim().isEmpty()) {
return "";
}
return sanitizeInformixFamilyBacktickQuote(params, connection, quote.trim());
} catch (Exception ignored) {
return "";
}
}
// GBase 8s and other Informix-family JDBC drivers report "`" from
// getIdentifierQuoteString() even though their servers reject backtick-quoted
// identifiers with "illegal character in statement" outside MySQL compatibility
// mode (SQLMODE=mysql). An empty quote makes clients emit unquoted identifiers,
// which both SQL modes accept.
private static String sanitizeInformixFamilyBacktickQuote(ConnectParams params, Connection connection, String quote) {
if (!"`".equals(quote) || !isInformixFamilyJdbc(params, connection) || isInformixFamilyMysqlCompat(params, connection)) {
return quote;
}
return "";
}
private static boolean isInformixFamilyJdbc(ConnectParams params, Connection connection) {
StringBuilder identity = new StringBuilder();
appendJdbcIdentity(identity, params.getConnection_string());
appendJdbcIdentity(identity, params.getJdbc_driver_class());
List<String> driverPaths = params.getJdbc_driver_paths();
if (driverPaths != null) {
for (String path : driverPaths) {
appendJdbcIdentity(identity, path);
}
}
appendJdbcIdentity(identity, connectionUrl(connection));
String normalized = identity.toString().toLowerCase(Locale.ROOT);
return normalized.contains("jdbc:gbasedbt")
|| normalized.contains("jdbc:informix")
|| normalized.contains("com.gbasedbt")
|| normalized.contains("com.informix");
}
private static boolean isInformixFamilyMysqlCompat(ConnectParams params, Connection connection) {
if (params.isMysql_compat_mode()) {
return true;
}
String identity = (params.getConnection_string() + "\n" + connectionUrl(connection)).toLowerCase(Locale.ROOT);
return identity.contains("sqlmode=mysql");
}
private static String connectionUrl(Connection connection) {
try {
String url = connection.getMetaData().getURL();
return url == null ? "" : url;
} catch (Exception ignored) {
return "";
}
@@ -68,8 +68,12 @@ public final class AgentProtocol {
public static final String MONGO_METHOD_DROP_DATABASE = "drop_database";
public static final String MONGO_METHOD_INSERT_DOCUMENT = "insert_document";
public static final String MONGO_METHOD_INSERT_DOCUMENTS = "insert_documents";
public static final String MONGO_METHOD_START_FIND_CURSOR = "start_find_cursor";
public static final String MONGO_METHOD_FETCH_FIND_CURSOR = "fetch_find_cursor";
public static final String MONGO_METHOD_CLOSE_FIND_CURSOR = "close_find_cursor";
public static final String MONGO_METHOD_UPDATE_DOCUMENT = "update_document";
public static final String MONGO_METHOD_UPDATE_DOCUMENTS = "update_documents";
public static final String MONGO_METHOD_REPLACE_DOCUMENT = "replace_document";
public static final String MONGO_METHOD_DELETE_DOCUMENT = "delete_document";
public static final String MONGO_METHOD_DELETE_DOCUMENTS = "delete_documents";
public static final String MONGO_METHOD_RUN_COMMAND = "run_command";
@@ -127,6 +131,8 @@ public final class AgentProtocol {
public static final String CAPABILITY_MONGO_CLONE_COLLECTION = "mongo_clone_collection";
public static final String CAPABILITY_MONGO_RUN_COMMAND = "mongo_run_command";
public static final String CAPABILITY_MONGO_INSERT_DOCUMENTS = "mongo_insert_documents";
public static final String CAPABILITY_MONGO_REPLACE_DOCUMENT = "mongo_replace_document";
public static final String CAPABILITY_MONGO_FIND_CURSOR = "mongo_find_cursor";
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
@@ -162,7 +168,9 @@ public final class AgentProtocol {
CAPABILITY_MONGO_DROP_DATABASE,
CAPABILITY_MONGO_CLONE_COLLECTION,
CAPABILITY_MONGO_RUN_COMMAND,
CAPABILITY_MONGO_INSERT_DOCUMENTS
CAPABILITY_MONGO_INSERT_DOCUMENTS,
CAPABILITY_MONGO_REPLACE_DOCUMENT,
CAPABILITY_MONGO_FIND_CURSOR
));
public static final List<String> MULTI_SESSION_CAPABILITIES;
@@ -226,6 +234,8 @@ public final class AgentProtocol {
mongoCapabilities.add(CAPABILITY_MONGO_CLONE_COLLECTION);
mongoCapabilities.add(CAPABILITY_MONGO_RUN_COMMAND);
mongoCapabilities.add(CAPABILITY_MONGO_INSERT_DOCUMENTS);
mongoCapabilities.add(CAPABILITY_MONGO_REPLACE_DOCUMENT);
mongoCapabilities.add(CAPABILITY_MONGO_FIND_CURSOR);
MONGO_LEGACY_CAPABILITIES = Collections.unmodifiableList(mongoCapabilities);
List<String> mongoMultiSessionCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
@@ -233,6 +243,8 @@ public final class AgentProtocol {
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_CLONE_COLLECTION);
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_RUN_COMMAND);
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_INSERT_DOCUMENTS);
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_REPLACE_DOCUMENT);
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_FIND_CURSOR);
MONGO_LEGACY_MULTI_SESSION_CAPABILITIES = Collections.unmodifiableList(mongoMultiSessionCapabilities);
List<String> jdbcCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
@@ -274,9 +286,13 @@ public final class AgentProtocol {
MONGO_METHOD_INSERT_DOCUMENTS,
MONGO_METHOD_UPDATE_DOCUMENT,
MONGO_METHOD_UPDATE_DOCUMENTS,
MONGO_METHOD_REPLACE_DOCUMENT,
MONGO_METHOD_DELETE_DOCUMENT,
MONGO_METHOD_DELETE_DOCUMENTS,
MONGO_METHOD_RUN_COMMAND
MONGO_METHOD_RUN_COMMAND,
MONGO_METHOD_START_FIND_CURSOR,
MONGO_METHOD_FETCH_FIND_CURSOR,
MONGO_METHOD_CLOSE_FIND_CURSOR
));
public static final List<String> KV_METHODS = Collections.unmodifiableList(Arrays.asList(
@@ -47,7 +47,7 @@ public abstract class ConfiguredJdbcAgent extends AbstractJdbcAgent {
@Override
public List<String> listSchemas() {
return StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), profile);
return StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), profile, configuredDatabase);
}
@Override
@@ -224,13 +224,71 @@ public interface DatabaseAgent {
if (conn == null) {
throw new IllegalStateException("Not connected");
}
return TransactionExecutor.executeUpdateStatements(
conn,
statements,
schema,
this::setSchemaSQL,
this::resetSchemaSQL
);
return unchecked(() -> {
if (!conn.getAutoCommit()) {
throw new IllegalStateException("Cannot start a one-shot transaction while a manual transaction is open");
}
return TransactionExecutor.executeUpdateStatements(
conn,
statements,
schema,
this::setSchemaSQL,
this::resetSchemaSQL
);
});
}
/** Starts an interactive transaction on the current JDBC connection. */
default Map<String, Object> beginManualTransaction(String schema) {
Connection conn = getConnection();
if (conn == null) {
throw new IllegalStateException("Not connected");
}
return unchecked(() -> {
if (!conn.getAutoCommit()) {
throw new IllegalStateException("Manual transaction already open");
}
if (schema != null && !schema.trim().isEmpty()) {
JdbcSchemaSwitcher.apply(conn, schema, this::setSchemaSQL, this::resetSchemaSQL);
}
conn.setAutoCommit(false);
JdbcSchemaSwitcher.preserve(conn);
return Collections.singletonMap("ok", (Object) true);
});
}
/** Commits an interactive transaction on the current JDBC connection. */
default Map<String, Object> commitManualTransaction() {
Connection conn = getConnection();
if (conn == null) {
throw new IllegalStateException("Not connected");
}
return unchecked(() -> {
if (conn.getAutoCommit()) {
throw new IllegalStateException("No manual transaction open");
}
conn.commit();
conn.setAutoCommit(true);
JdbcSchemaSwitcher.releasePreserved(conn);
return Collections.singletonMap("ok", (Object) true);
});
}
/** Rolls back an interactive transaction on the current JDBC connection. */
default Map<String, Object> rollbackManualTransaction() {
Connection conn = getConnection();
if (conn == null) {
throw new IllegalStateException("Not connected");
}
return unchecked(() -> {
if (conn.getAutoCommit()) {
throw new IllegalStateException("No manual transaction open");
}
conn.rollback();
conn.setAutoCommit(true);
JdbcSchemaSwitcher.releasePreserved(conn);
return Collections.singletonMap("ok", (Object) true);
});
}
default QueryResult executeBatch(List<String> statements, String schema) {
@@ -219,6 +219,118 @@ public final class DdlBuilder {
return ddl.toString();
}
/**
* Appends trailing SQL (e.g. GRANT statements) after a CREATE TABLE DDL script.
* Returns the original DDL unchanged when {@code trailingSql} is blank.
*/
public static String appendTrailingSql(String ddl, String trailingSql) {
if (!notBlank(trailingSql)) {
return ddl == null ? "" : ddl;
}
String base = ddl == null ? "" : ddl.trim();
if (base.isEmpty()) {
return trailingSql.trim();
}
if (!base.endsWith(";")) {
base = base + ";";
}
String grants = trailingSql.trim();
return base + "\n\n" + grants + (grants.endsWith(";") ? "" : ";");
}
/**
* Builds Oracle-style object GRANT statements for table and column privileges.
* Privileges are grouped by grantee / grantable / privilege(+columns).
*/
public static String buildOracleObjectGrantSql(
String schema,
String table,
List<OracleObjectPrivilege> privileges
) {
if (privileges == null || privileges.isEmpty() || !notBlank(table)) {
return "";
}
String tableRef = qualifiedName(schema, table, false);
Map<String, List<String>> tablePrivileges = new LinkedHashMap<>();
Map<String, List<String>> columnPrivileges = new LinkedHashMap<>();
for (OracleObjectPrivilege privilege : privileges) {
if (privilege == null || !notBlank(privilege.grantee()) || !notBlank(privilege.privilege())) {
continue;
}
String privilegeName = privilege.privilege().trim().toUpperCase(Locale.ROOT);
String grantableKey = privilege.grantable() ? "1" : "0";
if (notBlank(privilege.columnName())) {
String key = privilege.grantee() + "\u0000" + privilegeName + "\u0000" + grantableKey;
columnPrivileges
.computeIfAbsent(key, ignored -> new ArrayList<>())
.add(privilege.columnName().trim());
} else {
String key = privilege.grantee() + "\u0000" + grantableKey;
List<String> names = tablePrivileges.computeIfAbsent(key, ignored -> new ArrayList<>());
if (!names.contains(privilegeName)) {
names.add(privilegeName);
}
}
}
List<String> statements = new ArrayList<>();
for (Map.Entry<String, List<String>> entry : tablePrivileges.entrySet()) {
String[] parts = entry.getKey().split("\u0000", -1);
String grantee = parts[0];
boolean grantable = "1".equals(parts[1]);
statements.add(formatOracleGrant(
join(entry.getValue(), ", "),
null,
tableRef,
grantee,
grantable
));
}
for (Map.Entry<String, List<String>> entry : columnPrivileges.entrySet()) {
String[] parts = entry.getKey().split("\u0000", -1);
String grantee = parts[0];
String privilegeName = parts[1];
boolean grantable = "1".equals(parts[2]);
List<String> columns = new ArrayList<>();
for (String column : entry.getValue()) {
String quoted = quoteIdent(column, false);
if (!columns.contains(quoted)) {
columns.add(quoted);
}
}
statements.add(formatOracleGrant(
privilegeName,
join(columns, ", "),
tableRef,
grantee,
grantable
));
}
return join(statements, "\n");
}
private static String formatOracleGrant(
String privilegeList,
String columnList,
String tableRef,
String grantee,
boolean grantable
) {
StringBuilder statement = new StringBuilder("GRANT ");
statement.append(privilegeList);
if (notBlank(columnList)) {
statement.append(" (").append(columnList).append(")");
}
statement.append(" ON ").append(tableRef);
statement.append(" TO ").append(quoteIdent(grantee, false));
if (grantable) {
statement.append(" WITH GRANT OPTION");
}
statement.append(";");
return statement.toString();
}
private static List<ForeignKeyGroup> groupForeignKeys(List<ForeignKeyInfo> foreignKeys) {
List<ForeignKeyGroup> result = new ArrayList<>();
Map<String, ForeignKeyGroup> namedGroups = new LinkedHashMap<>();
@@ -18,6 +18,13 @@ public final class IndexInfo {
// doesn't track this (provenance unknown for that dialect/path). Mirrors
// crate::types::IndexInfo::key_is_expression on the Rust side (#6312 review).
private List<Boolean> key_is_expression;
private List<Integer> key_options = Collections.emptyList();
// True when this index is the object behind a table constraint (PRIMARY KEY / UNIQUE)
// instead of a standalone index. Dameng lists both kinds in ALL_INDEXES but only accepts
// index-level DDL for standalone ones, so the SQL builder needs to tell them apart
// (#7959). Mirrors crate::types::IndexInfo::constraint_backed on the Rust side; false
// when the introspection source doesn't report it.
private boolean constraint_backed;
public IndexInfo() {
this("", Collections.emptyList(), false, false);
@@ -98,6 +105,14 @@ public final class IndexInfo {
return key_is_expression;
}
public List<Integer> getKey_options() {
return key_options;
}
public boolean getConstraint_backed() {
return constraint_backed;
}
public void setName(String name) {
this.name = name;
}
@@ -134,6 +149,14 @@ public final class IndexInfo {
this.key_is_expression = key_is_expression;
}
public void setKey_options(List<Integer> key_options) {
this.key_options = key_options == null ? Collections.emptyList() : key_options;
}
public void setConstraint_backed(boolean constraint_backed) {
this.constraint_backed = constraint_backed;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
@@ -141,19 +164,31 @@ public final class IndexInfo {
IndexInfo that = (IndexInfo) other;
return is_unique == that.is_unique
&& is_primary == that.is_primary
&& constraint_backed == that.constraint_backed
&& Objects.equals(name, that.name)
&& Objects.equals(columns, that.columns)
&& Objects.equals(filter, that.filter)
&& Objects.equals(index_type, that.index_type)
&& Objects.equals(included_columns, that.included_columns)
&& Objects.equals(comment, that.comment)
&& Objects.equals(key_is_expression, that.key_is_expression);
&& Objects.equals(key_is_expression, that.key_is_expression)
&& Objects.equals(key_options, that.key_options);
}
@Override
public int hashCode() {
return Objects.hash(
name, columns, is_unique, is_primary, filter, index_type, included_columns, comment, key_is_expression
name,
columns,
is_unique,
is_primary,
filter,
index_type,
included_columns,
comment,
key_is_expression,
key_options,
constraint_backed
);
}
@@ -168,6 +203,8 @@ public final class IndexInfo {
+ ", included_columns=" + included_columns
+ ", comment=" + comment
+ ", key_is_expression=" + key_is_expression
+ ", key_options=" + key_options
+ ", constraint_backed=" + constraint_backed
+ ")";
}
}
@@ -12,6 +12,8 @@ import java.util.function.Supplier;
final class JdbcSchemaSwitcher {
private static final Map<Connection, ResetState> RESET_STATE_BY_CONNECTION =
Collections.synchronizedMap(new WeakHashMap<>());
private static final Map<Connection, Boolean> PRESERVED_BY_CONNECTION =
Collections.synchronizedMap(new WeakHashMap<>());
private JdbcSchemaSwitcher() {
}
@@ -27,6 +29,9 @@ final class JdbcSchemaSwitcher {
Supplier<String> resetSchemaSql
) throws Exception {
if (schema == null || schema.trim().isEmpty()) {
if (isPreserved(conn)) {
return;
}
if (!restore(conn, true)) {
throw new SQLException("Failed to restore the original JDBC schema context");
}
@@ -70,7 +75,20 @@ final class JdbcSchemaSwitcher {
RESET_STATE_BY_CONNECTION.remove(conn);
}
static void preserve(Connection conn) {
PRESERVED_BY_CONNECTION.put(conn, Boolean.TRUE);
}
static void releasePreserved(Connection conn) {
PRESERVED_BY_CONNECTION.remove(conn);
}
private static boolean isPreserved(Connection conn) {
return PRESERVED_BY_CONNECTION.containsKey(conn);
}
private static boolean restore(Connection conn, boolean preserveOnFailure) {
PRESERVED_BY_CONNECTION.remove(conn);
ResetState resetState = RESET_STATE_BY_CONNECTION.remove(conn);
if (resetState == null) {
return true;
@@ -111,7 +111,9 @@ public final class JsonRpcServer {
jdbcExecutor,
succeeded,
requiresSessionAffinity(method, params),
evictAfterRequest(method)
evictAfterRequest(method),
endsSessionAffinity(method),
preservesSchemaContext()
);
}
}
@@ -315,6 +317,15 @@ public final class JsonRpcServer {
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
return agent.executeTransaction(statements, stringOrNull(params, "schema"));
}
if (AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION.equals(method)) {
return agent.beginManualTransaction(stringOrNull(params, "schema"));
}
if (AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION.equals(method)) {
return agent.commitManualTransaction();
}
if (AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION.equals(method)) {
return agent.rollbackManualTransaction();
}
if (AgentProtocol.METHOD_EXECUTE_BATCH.equals(method)) {
Type statementsType = new TypeToken<List<String>>() {}.getType();
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
@@ -422,6 +433,9 @@ public final class JsonRpcServer {
}
private boolean requiresSessionAffinity(String method, JsonObject params) {
if (AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION.equals(method)) {
return true;
}
try {
if (AgentProtocol.METHOD_EXECUTE_QUERY.equals(method)
|| AgentProtocol.METHOD_EXECUTE_QUERY_PAGE.equals(method)
@@ -447,6 +461,23 @@ public final class JsonRpcServer {
return false;
}
private static boolean endsSessionAffinity(String method) {
return AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION.equals(method)
|| AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION.equals(method);
}
private boolean preservesSchemaContext() {
Connection connection = agent.getConnection();
if (connection == null) {
return false;
}
try {
return !connection.getAutoCommit();
} catch (Exception ignored) {
return false;
}
}
private static boolean evictAfterRequest(String method) {
return AgentProtocol.METHOD_GET_EXPLAIN_INFO.equals(method);
}
@@ -0,0 +1,38 @@
package com.dbx.agent;
/**
* Oracle-compatible object privilege row used when reconstructing GRANT DDL.
*/
public final class OracleObjectPrivilege {
private final String grantee;
private final String privilege;
private final boolean grantable;
private final String columnName;
public OracleObjectPrivilege(String grantee, String privilege, boolean grantable) {
this(grantee, privilege, grantable, null);
}
public OracleObjectPrivilege(String grantee, String privilege, boolean grantable, String columnName) {
this.grantee = grantee;
this.privilege = privilege;
this.grantable = grantable;
this.columnName = columnName;
}
public String grantee() {
return grantee;
}
public String privilege() {
return privilege;
}
public boolean grantable() {
return grantable;
}
public String columnName() {
return columnName;
}
}
@@ -546,7 +546,9 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " +
"(ix.indisunique AND ix.indisvalid) AS is_unique, ix.indisprimary AS is_primary, " +
"COALESCE(a.attname, " + profile.catalogPrefixedFunction("get_indexdef") + "(ix.indexrelid, k.n, true)) AS column_text, " +
"(a.attname IS NULL) AS is_expression " +
"(a.attname IS NULL) AS is_expression, " +
"array_length(ix.indoption, 1) AS nkeyatts, k.n AS key_position, " +
"ix.indoption[(k.n - 1)::int] AS key_option " +
"FROM " + profile.catalogRelation("index") + " ix " +
"JOIN " + profile.catalogRelation("class") + " t ON t.oid = ix.indrelid " +
"JOIN " + profile.catalogRelation("class") + " i ON i.oid = ix.indexrelid " +
@@ -569,24 +571,36 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
indexName,
name -> new IndexBuilder(name, indexType, isUnique, isPrimary)
);
builder.columns.add(rs.getString("column_text"));
String columnText = rs.getString("column_text");
if (rs.getObject("nkeyatts") != null && rs.getObject("key_position") != null
&& rs.getInt("key_position") > rs.getInt("nkeyatts")) {
builder.includedColumns.add(columnText);
continue;
}
builder.columns.add(columnText);
builder.keyIsExpression.add(rs.getBoolean("is_expression"));
Object keyOption = rs.getObject("key_option");
if (keyOption != null) {
builder.keyOptions.add(rs.getInt("key_option"));
}
}
}
}
List<IndexInfo> result = new ArrayList<>();
for (IndexBuilder builder : byName.values()) {
result.add(new IndexInfo(
IndexInfo index = new IndexInfo(
builder.name,
builder.columns,
builder.isUnique,
builder.isPrimary,
null,
builder.indexType,
null,
builder.includedColumns.isEmpty() ? null : builder.includedColumns,
null,
builder.keyIsExpression
));
);
index.setKey_options(builder.keyOptions);
result.add(index);
}
return result;
});
@@ -598,8 +612,9 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
private final boolean isUnique;
private final boolean isPrimary;
private final List<String> columns = new ArrayList<>();
private final List<String> includedColumns = new ArrayList<>();
private final List<Boolean> keyIsExpression = new ArrayList<>();
private final List<Integer> keyOptions = new ArrayList<>();
private IndexBuilder(String name, String indexType, boolean isUnique, boolean isPrimary) {
this.name = name;
this.indexType = indexType;
@@ -42,11 +42,23 @@ public final class StandardJdbcMetadata {
}
public List<String> listSchemas(Connection conn, JdbcAgentProfile profile) {
return listSchemas(conn, profile, null);
}
public List<String> listSchemas(Connection conn, JdbcAgentProfile profile, String configuredCatalog) {
return unchecked(() -> {
Set<String> names = new LinkedHashSet<>();
DatabaseMetaData meta = conn.getMetaData();
String catalog = blankToNull(configuredCatalog);
try {
appendSchemas(names, meta.getSchemas(null, null));
String currentCatalog = conn.getCatalog();
if (currentCatalog != null && !currentCatalog.trim().isEmpty()) {
catalog = currentCatalog.trim();
}
} catch (Exception | AbstractMethodError ignored) {
}
try {
appendSchemas(names, meta.getSchemas(catalog, null));
} catch (Exception | AbstractMethodError first) {
try {
appendSchemas(names, meta.getSchemas());
@@ -2,7 +2,7 @@
"protocolVersion": 1,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_find_cursor"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"commonMethods": [
@@ -42,6 +42,6 @@
"disconnect",
"shutdown"
],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "delete_document", "delete_documents", "run_command"],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"],
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"]
}
@@ -2,7 +2,7 @@
"protocolVersion": 2,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "multi_session", "structured_error_v1"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_find_cursor", "multi_session", "structured_error_v1"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session", "structured_error_v1"],
"commonMethods": [
@@ -46,7 +46,7 @@
"disconnect",
"shutdown"
],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "delete_document", "delete_documents", "run_command"],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"],
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"],
"sessionField": "agentSessionId",
"cursorSessionField": "sessionId"
@@ -115,6 +115,55 @@ class AbstractJdbcAgentTest {
assertEquals(0, tracking.compatibilityQueryCount);
}
@Test
void ignoresGbase8sDriverBacktickIdentifierQuote() {
TrackingConnection tracking = new TrackingConnection();
tracking.identifierQuote = "`";
tracking.jdbcUrl = "jdbc:gbasedbt-sqli://localhost:9088/appdb:GBASEDBTSERVER=gbase8s";
TestAgent agent = new TestAgent(tracking);
agent.connect(new ConnectParams());
assertEquals("", agent.getIdentifierQuote());
}
@Test
void ignoresBacktickQuoteForInformixFamilyConnectionString() {
TrackingConnection tracking = new TrackingConnection();
tracking.identifierQuote = "`";
TestAgent agent = new TestAgent(tracking);
ConnectParams params = new ConnectParams();
params.setConnection_string("jdbc:informix-sqli://localhost:9088/appdb:INFORMIXSERVER=ol_informix");
agent.connect(params);
assertEquals("", agent.getIdentifierQuote());
}
@Test
void keepsInformixFamilyBacktickQuoteInMysqlCompatMode() {
TrackingConnection tracking = new TrackingConnection();
tracking.identifierQuote = "`";
tracking.jdbcUrl = "jdbc:gbasedbt-sqli://localhost:9088/appdb:GBASEDBTSERVER=gbase8s;SQLMODE=mysql";
TestAgent agent = new TestAgent(tracking);
agent.connect(new ConnectParams());
assertEquals("`", agent.getIdentifierQuote());
}
@Test
void keepsNonBacktickQuotesForInformixFamilyDrivers() {
TrackingConnection tracking = new TrackingConnection();
tracking.identifierQuote = "\"";
tracking.jdbcUrl = "jdbc:gbasedbt-sqli://localhost:9088/appdb:GBASEDBTSERVER=gbase8s";
TestAgent agent = new TestAgent(tracking);
agent.connect(new ConnectParams());
assertEquals("\"", agent.getIdentifierQuote());
}
@Test
void testsConnectionsThroughSharedLifecycle() {
TrackingConnection tracking = new TrackingConnection();
@@ -300,6 +349,22 @@ class AbstractJdbcAgentTest {
);
}
@Test
void rejectsOneShotTransactionWhenManualTransactionIsOpen() {
TrackingConnection tracking = new TrackingConnection();
TestAgent agent = new TestAgent(tracking);
agent.connect(new ConnectParams());
agent.beginManualTransaction(null);
IllegalStateException error = assertThrows(
IllegalStateException.class,
() -> agent.executeTransaction(Collections.singletonList("UPDATE A SET ID = 1"), null)
);
assertEquals("Cannot start a one-shot transaction while a manual transaction is open", error.getMessage());
assertEquals(Collections.singletonList("setAutoCommit:false"), tracking.calls);
}
@Test
void preservesPlSqlBlockTerminatorDuringTransactionExecution() {
TrackingConnection tracking = new TrackingConnection();
@@ -453,6 +518,7 @@ class AbstractJdbcAgentTest {
private boolean autoCommit = true;
private String compatibilityMode;
private String identifierQuote = "\"";
private String jdbcUrl;
private boolean compatibilityQueryFails;
private int compatibilityQueryCount;
private boolean isValidUnsupported;
@@ -518,6 +584,9 @@ class AbstractJdbcAgentTest {
if ("getIdentifierQuoteString".equals(method.getName())) {
return identifierQuote;
}
if ("getURL".equals(method.getName())) {
return jdbcUrl;
}
return defaultValue(method.getReturnType());
}
});
@@ -150,6 +150,42 @@ class CommonJavaCompatibilityTest {
assertEquals("Example JDBC", result.getAsJsonObject("databaseInfo").get("driverName").getAsString());
}
@Test
void jsonRpcServerDispatchesInteractiveTransactionMethods() {
List<String> calls = new ArrayList<>();
MinimalAgent agent = new MinimalAgent() {
@Override
public Map<String, Object> beginManualTransaction(String schema) {
calls.add("begin:" + schema);
return Collections.singletonMap("ok", (Object) true);
}
@Override
public Map<String, Object> commitManualTransaction() {
calls.add("commit");
return Collections.singletonMap("ok", (Object) true);
}
@Override
public Map<String, Object> rollbackManualTransaction() {
calls.add("rollback");
return Collections.singletonMap("ok", (Object) true);
}
};
JsonRpcServer server = new JsonRpcServer(agent);
assertTrue(JsonParser.parseString(server.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"begin_manual_transaction\",\"params\":{\"schema\":\"APP\"}}"
)).getAsJsonObject().has("result"));
assertTrue(JsonParser.parseString(server.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"commit_manual_transaction\",\"params\":{}}"
)).getAsJsonObject().has("result"));
assertTrue(JsonParser.parseString(server.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"rollback_manual_transaction\",\"params\":{}}"
)).getAsJsonObject().has("result"));
assertEquals(List.of("begin:APP", "commit", "rollback"), calls);
}
@Test
void multiSessionServerCreatesAndClosesIndependentAgents() throws Exception {
java.util.List<TrackingAgent> created = new java.util.ArrayList<>();
@@ -750,6 +786,36 @@ class CommonJavaCompatibilityTest {
assertTrue(ddl.contains("\"NAME\" NVARCHAR(100) NOT NULL"));
}
@Test
void appendsOracleObjectGrantSqlAfterTableDdl() {
String ddl = DdlBuilder.buildTableDdl(
"APP",
"USERS",
Collections.singletonList(new ColumnInfo("ID", "NUMBER", false, null, true)),
Collections.emptyList(),
Collections.emptyList()
);
String grants = DdlBuilder.buildOracleObjectGrantSql(
"APP",
"USERS",
Arrays.asList(
new OracleObjectPrivilege("READER", "SELECT", false),
new OracleObjectPrivilege("READER", "INSERT", false),
new OracleObjectPrivilege("ADMIN", "SELECT", true),
new OracleObjectPrivilege("ANALYST", "UPDATE", false, "NAME")
)
);
String combined = DdlBuilder.appendTrailingSql(ddl, grants);
assertTrue(combined.contains("CREATE TABLE \"APP\".\"USERS\""));
assertTrue(combined.contains("GRANT SELECT, INSERT ON \"APP\".\"USERS\" TO \"READER\";"));
assertTrue(combined.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"ADMIN\" WITH GRANT OPTION;"));
assertTrue(combined.contains("GRANT UPDATE (\"NAME\") ON \"APP\".\"USERS\" TO \"ANALYST\";"));
assertEquals("", DdlBuilder.buildOracleObjectGrantSql("APP", "USERS", Collections.emptyList()));
assertEquals(ddl, DdlBuilder.appendTrailingSql(ddl, " "));
}
private static class MinimalAgent implements DatabaseAgent {
@Override
public void connect(ConnectParams params) {
@@ -1869,6 +1869,65 @@ class JdbcConnectionPoolingTest {
}
}
@Test
void interactiveTransactionPinsConnectionUntilCommit() {
AtomicInteger physicalOpens = new AtomicInteger();
AtomicInteger requestIds = new AtomicInteger();
String url = h2Url("manual_transaction");
try (MultiSessionJsonRpcServer server = server(url, physicalOpens, 2)) {
openSession(server, requestIds, "transaction");
query(server, requestIds, "transaction", "CREATE TABLE ITEMS (ID INT)", null);
result(request(
server,
requestIds,
AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION,
sessionParams("transaction")
));
query(server, requestIds, "transaction", "INSERT INTO ITEMS VALUES (1)", null);
openSession(server, requestIds, "observer");
JsonObject beforeCommit = query(server, requestIds, "observer", "SELECT COUNT(*) FROM ITEMS", null);
assertEquals(0, beforeCommit.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsInt());
result(request(
server,
requestIds,
AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION,
sessionParams("transaction")
));
JsonObject afterCommit = query(server, requestIds, "observer", "SELECT COUNT(*) FROM ITEMS", null);
assertEquals(1, afterCommit.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsInt());
}
}
@Test
void interactiveTransactionRestoresSchemaBeforeReturningConnection() {
AtomicInteger physicalOpens = new AtomicInteger();
AtomicInteger requestIds = new AtomicInteger();
String url = h2Url("manual_transaction_schema");
try (MultiSessionJsonRpcServer server = server(url, physicalOpens, 2)) {
openSession(server, requestIds, "transaction-schema");
query(server, requestIds, "transaction-schema", "CREATE SCHEMA IF NOT EXISTS APP", null);
JsonObject beginParams = sessionParams("transaction-schema");
beginParams.addProperty("schema", "APP");
result(request(server, requestIds, AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION, beginParams));
JsonObject inTransaction = query(server, requestIds, "transaction-schema", "SELECT CURRENT_SCHEMA", null);
assertEquals("APP", inTransaction.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsString());
result(request(
server,
requestIds,
AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION,
sessionParams("transaction-schema")
));
openSession(server, requestIds, "schema-observer");
JsonObject afterCommit = query(server, requestIds, "schema-observer", "SELECT CURRENT_SCHEMA", null);
assertEquals("PUBLIC", afterCommit.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsString());
}
}
@Test
void affinityDetectionCoversCommonSessionStateStatements() {
assertTrue(JdbcConnectionAffinity.requiresSessionAffinity("SET search_path TO app"));
@@ -35,7 +35,13 @@ class MetadataConstraintCoverageTest {
Map<String, String> coverage = readCoverageMatrix(agentsRoot.resolve("metadata-constraint-coverage.tsv"));
Set<String> discovered = discoverCustomMetadataDrivers(agentsRoot.resolve("drivers"));
assertEquals(discovered, coverage.keySet());
// One-directional on purpose: every driver with a custom metadata override must be
// documented, but the matrix may also document drivers that only customize other
// metadata surfaces (e.g. column/primary-key queries via INFORMATION_SCHEMA) without
// overriding the listing methods this test discovers.
Set<String> undocumented = new HashSet<>(discovered);
undocumented.removeAll(coverage.keySet());
assertTrue(undocumented.isEmpty(), "custom metadata drivers missing from coverage matrix: " + undocumented);
}
@Test
@@ -157,6 +157,34 @@ class PostgresLikeAgentTest {
assertTrue(index.getIs_unique());
assertFalse(index.getIs_primary());
assertEquals("btree", index.getIndex_type());
assertTrue(index.getKey_options().isEmpty());
}
@Test
void listIndexesPreservesPerKeyOrderingAndSeparatesIncludedColumns() {
TestPostgresLikeAgent agent = new TestPostgresLikeAgent(preparedConnection(resultSet(
new String[]{
"index_name", "index_type", "is_unique", "is_primary", "column_text",
"is_expression", "nkeyatts", "key_position", "key_option"
},
new Object[][]{
{"event_order_idx", "btree", false, false, "created_at", false, 4, 1, 1},
{"event_order_idx", "btree", false, false, "tenant_id", false, 4, 2, 0},
{"event_order_idx", "btree", false, false, "score", false, 4, 3, 2},
{"event_order_idx", "btree", false, false, "lower(payload)", true, 4, 4, 3},
{"event_order_idx", "btree", false, false, "payload", false, 4, 5, null}
}
)));
agent.connect(new ConnectParams());
IndexInfo index = agent.listIndexes("public", "event_log").get(0);
assertEquals(java.util.Arrays.asList("created_at", "tenant_id", "score", "lower(payload)"), index.getColumns());
assertEquals(java.util.Arrays.asList(false, false, false, true), index.getKey_is_expression());
assertEquals(java.util.Arrays.asList(1, 0, 2, 3), index.getKey_options());
assertEquals(java.util.Collections.singletonList("payload"), index.getIncluded_columns());
com.google.gson.Gson gson = new com.google.gson.Gson();
assertEquals(index, gson.fromJson(gson.toJson(index), IndexInfo.class));
}
@Test
@@ -174,6 +202,8 @@ class PostgresLikeAgentTest {
assertTrue(sql.contains("LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0"), sql);
assertTrue(sql.contains("pg_catalog.pg_get_indexdef(ix.indexrelid, k.n, true)"), sql);
assertTrue(sql.contains("ix.indisunique AND ix.indisvalid"), sql);
assertTrue(sql.contains("ix.indoption[(k.n - 1)::int]"), sql);
assertTrue(sql.contains("array_length(ix.indoption, 1) AS nkeyatts"), sql);
}
@Test
@@ -43,6 +43,37 @@ class StandardJdbcMetadataTest {
assertEquals(Arrays.asList("APP", "PUBLIC"), StandardJdbcMetadata.INSTANCE.listSchemas(conn, profile));
}
@Test
void scopesSchemasToTheConnectionCatalog() {
AtomicReference<Object[]> capturedArgs = new AtomicReference<>();
DatabaseMetaData meta = proxy(DatabaseMetaData.class, new MethodHandler() {
@Override
public Object handle(Method method, Object[] args) {
if ("getSchemas".equals(method.getName())) {
capturedArgs.set(args);
return rows(row("TABLE_SCHEM", "APP"));
}
return defaultValue(method.getReturnType());
}
});
Connection conn = proxy(Connection.class, new MethodHandler() {
@Override
public Object handle(Method method, Object[] args) {
if ("getMetaData".equals(method.getName())) {
return meta;
}
if ("getCatalog".equals(method.getName())) {
return "regular_catalog";
}
return defaultValue(method.getReturnType());
}
});
assertEquals(List.of("APP"), StandardJdbcMetadata.INSTANCE.listSchemas(conn, profile, "initial_catalog"));
assertEquals("regular_catalog", capturedArgs.get()[0]);
assertEquals(null, capturedArgs.get()[1]);
}
@Test
void listsSchemasWhenConnectionGetSchemaIsUnsupported() {
Connection conn = connection(
+9
View File
@@ -0,0 +1,9 @@
dependencies {
implementation files('libs/CacheDB.jar')
}
tasks.named('shadowJar') {
manifest {
attributes('Agent-Label': 'InterSystems Cach\u00e9', 'Main-Class': 'com.dbx.agent.cache.CacheAgent')
}
}
Binary file not shown.
@@ -0,0 +1,242 @@
package com.dbx.agent.cache;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.StandardJdbcMetadata;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Types;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public final class CacheAgent extends ConfiguredJdbcAgent {
public static final JdbcAgentProfile CACHE_PROFILE = new JdbcAgentProfile(
"com.intersys.jdbc.CacheDriver",
"jdbc:Cache://{host}:{port}/{database}",
1972,
true
);
private String configuredDatabase = "";
public CacheAgent() {
super(CACHE_PROFILE);
}
@Override
protected void afterConnect(ConnectParams params, Connection connection) {
configuredDatabase = params.getDatabase();
}
@Override
protected Object resultValue(ResultSet rs, int index, int sqlType) {
switch (sqlType) {
case Types.BIGINT:
case Types.INTEGER:
case Types.SMALLINT:
case Types.TINYINT:
// Caché can expose values outside the Java primitive range
// implied by its JDBC metadata. Preserve them without narrowing.
return unchecked(() -> {
Object value = rs.getBigDecimal(index);
return rs.wasNull() ? null : value;
});
case Types.OTHER:
return unchecked(() -> JdbcExecutor.normalizeResultValue(rs.getObject(index)));
default:
return super.resultValue(rs, index, sqlType);
}
}
@Override
public List<String> listSchemas() {
return dedupeCaseInsensitiveSchemas(StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), CACHE_PROFILE));
}
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
return cacheColumns(requireConnection(), getProfile(), configuredDatabase, schema, table);
}
static List<ColumnInfo> cacheColumns(Connection conn, JdbcAgentProfile profile, String configuredDatabase, String schema, String table) {
List<ColumnInfo> columns;
try {
columns = informationSchemaColumns(conn, schema, table);
} catch (Exception informationSchemaUnavailable) {
// Caché releases before 2012.1 do not expose INFORMATION_SCHEMA;
// plain JDBC metadata keeps those servers browsable.
return StandardJdbcMetadata.INSTANCE.getColumns(conn, profile, configuredDatabase, schema, table);
}
if (!hasPrimaryKey(columns)) {
markPrimaryKeys(columns, cachePrimaryKeys(conn, schema, table));
}
return columns;
}
static List<ColumnInfo> informationSchemaColumns(Connection conn, String schema, String table) {
return unchecked(() -> {
List<ColumnInfo> columns = new ArrayList<>();
try (PreparedStatement stmt = conn.prepareStatement(columnSql(schema))) {
int index = 1;
if (!isBlank(schema)) {
stmt.setString(index++, schema.trim());
}
stmt.setString(index, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String name = rs.getString("COLUMN_NAME");
if (isBlank(name)) {
continue;
}
columns.add(new ColumnInfo(
name,
stringOrDefault(rs, "DATA_TYPE", "Unknown"),
!"NO".equalsIgnoreCase(stringOrNull(rs, "IS_NULLABLE")),
stringOrNull(rs, "COLUMN_DEFAULT"),
"YES".equalsIgnoreCase(stringOrNull(rs, "PRIMARY_KEY")),
null,
stringOrNull(rs, "DESCRIPTION"),
intOrNull(rs, "NUMERIC_PRECISION"),
intOrNull(rs, "NUMERIC_SCALE"),
intOrNull(rs, "CHARACTER_MAXIMUM_LENGTH")
));
}
}
}
return columns;
});
}
static List<String> dedupeCaseInsensitiveSchemas(List<String> schemas) {
Map<String, String> byNormalizedName = new LinkedHashMap<>();
for (String schema : schemas) {
if (schema == null || schema.trim().isEmpty()) {
continue;
}
String name = schema.trim();
byNormalizedName.putIfAbsent(name.toUpperCase(Locale.ROOT), name);
}
return new ArrayList<>(byNormalizedName.values());
}
private static Set<String> cachePrimaryKeys(Connection conn, String schema, String table) {
Set<String> keys = jdbcPrimaryKeys(conn, schema, table);
return keys.isEmpty() ? nativePrimaryKeys(conn, schema, table) : keys;
}
private static Set<String> jdbcPrimaryKeys(Connection conn, String schema, String table) {
Set<String> keys = new LinkedHashSet<>();
try {
DatabaseMetaData meta = conn.getMetaData();
try (ResultSet rs = meta.getPrimaryKeys(null, isBlank(schema) ? null : schema.trim(), table)) {
addPrimaryKeys(keys, rs);
}
} catch (Exception ignored) {
}
return keys;
}
private static Set<String> nativePrimaryKeys(Connection conn, String schema, String table) {
Set<String> keys = new LinkedHashSet<>();
try (PreparedStatement stmt = conn.prepareStatement(primaryKeySql(schema))) {
int index = 1;
if (!isBlank(schema)) {
stmt.setString(index++, schema.trim());
}
stmt.setString(index, table);
try (ResultSet rs = stmt.executeQuery()) {
addPrimaryKeys(keys, rs);
}
} catch (Exception ignored) {
// Primary key metadata is optional for display; column listing must keep working.
}
return keys;
}
private static String columnSql(String schema) {
StringBuilder sql = new StringBuilder(
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, "
+ "PRIMARY_KEY, DESCRIPTION, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH "
+ "FROM INFORMATION_SCHEMA.COLUMNS WHERE "
);
if (!isBlank(schema)) {
sql.append("UPPER(TABLE_SCHEMA) = UPPER(?) AND ");
}
sql.append("UPPER(TABLE_NAME) = UPPER(?) ORDER BY ORDINAL_POSITION");
return sql.toString();
}
private static void addPrimaryKeys(Set<String> keys, ResultSet rs) throws Exception {
while (rs.next()) {
String column = rs.getString("COLUMN_NAME");
if (!isBlank(column)) {
keys.add(column.toUpperCase(Locale.ROOT));
}
}
}
private static boolean hasPrimaryKey(List<ColumnInfo> columns) {
for (ColumnInfo column : columns) {
if (column.getIs_primary_key()) {
return true;
}
}
return false;
}
private static void markPrimaryKeys(List<ColumnInfo> columns, Set<String> primaryKeys) {
for (ColumnInfo column : columns) {
if (primaryKeys.contains(column.getName().toUpperCase(Locale.ROOT))) {
column.setIs_primary_key(true);
}
}
}
private static String primaryKeySql(String schema) {
StringBuilder sql = new StringBuilder(
"SELECT k.COLUMN_NAME "
+ "FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE k "
+ "WHERE k.CONSTRAINT_TYPE = 'PRIMARY KEY' AND "
);
if (!isBlank(schema)) {
sql.append("UPPER(k.TABLE_SCHEMA) = UPPER(?) AND ");
}
sql.append("UPPER(k.TABLE_NAME) = UPPER(?) ORDER BY k.ORDINAL_POSITION");
return sql.toString();
}
private static String stringOrDefault(ResultSet rs, String column, String fallback) throws Exception {
String value = stringOrNull(rs, column);
return isBlank(value) ? fallback : value;
}
private static String stringOrNull(ResultSet rs, String column) throws Exception {
String value = rs.getString(column);
return rs.wasNull() ? null : value;
}
private static Integer intOrNull(ResultSet rs, String column) throws Exception {
Object value = rs.getObject(column);
return value instanceof Number ? ((Number) value).intValue() : null;
}
private static boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
public static void main(String[] args) {
new MultiSessionJsonRpcServer(CacheAgent::new).run();
}
}
@@ -0,0 +1,458 @@
package com.dbx.agent.cache;
import com.dbx.agent.ColumnInfo;
import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CacheAgentTest {
@Test
void skipsSchemaSwitchingBecauseCacheRejectsSetSchemaContext() {
CacheAgent agent = new CacheAgent();
assertEquals("", agent.setSchemaSQL("SQLUser"));
}
@Test
void buildsCacheJdbcUrlWithDefaultPort() {
assertEquals(
"jdbc:Cache://db.local:1972/USER",
CacheAgent.CACHE_PROFILE.getUrlTemplate()
.replace("{host}", "db.local")
.replace("{port}", "1972")
.replace("{database}", "USER")
);
assertEquals("com.intersys.jdbc.CacheDriver", CacheAgent.CACHE_PROFILE.getDriverClass());
}
@Test
void dedupesSchemasCaseInsensitively() {
assertEquals(
Arrays.asList("APP", "SQLUSER", "z_user"),
CacheAgent.dedupeCaseInsensitiveSchemas(Arrays.asList("APP", "SQLUSER", "SQLUser", "app", "z_user"))
);
}
@Test
void ignoresBlankSchemasWhenDeduping() {
assertEquals(
Collections.singletonList("SQLUSER"),
CacheAgent.dedupeCaseInsensitiveSchemas(Arrays.asList("", " ", null, "SQLUSER"))
);
}
@Test
void readsVendorOtherValuesThroughGetObject() {
List<String> calls = new ArrayList<>();
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if ("getObject".equals(method.getName())) {
calls.add("getObject");
return new Object() {
@Override
public String toString() {
return "%List(1,2)";
}
};
}
if ("getString".equals(method.getName())) {
calls.add("getString");
throw new AssertionError("Caché %LIST must not use getString");
}
if ("wasNull".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
Object value = new CacheAgent().resultValue(resultSet, 1, Types.OTHER);
assertEquals("%List(1,2)", value);
assertEquals(Collections.singletonList("getObject"), calls);
}
@Test
void readsOutOfRangeIntegerThroughBigDecimal() {
BigDecimal valueOutsideIntegerRange = new BigDecimal("2147483648");
List<String> calls = new ArrayList<>();
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if ("getBigDecimal".equals(method.getName())) {
calls.add("getBigDecimal");
return valueOutsideIntegerRange;
}
if (method.getName().startsWith("get")) {
calls.add(method.getName());
throw new SQLException("Numeric value out of range");
}
if ("wasNull".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
Object value = new CacheAgent().resultValue(resultSet, 1, Types.INTEGER);
assertEquals(valueOutsideIntegerRange, value);
assertEquals(Collections.singletonList("getBigDecimal"), calls);
}
@Test
void preservesStringPathForStandardValues() {
assertStringPath(Types.VARCHAR);
assertStringPath(Types.LONGVARCHAR);
}
private static void assertStringPath(int sqlType) {
List<String> calls = new ArrayList<>();
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if ("getString".equals(method.getName())) {
calls.add("getString");
return "ordinary";
}
if ("wasNull".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
Object value = new CacheAgent().resultValue(resultSet, 1, sqlType);
assertEquals("ordinary", value);
assertEquals(Collections.singletonList("getString"), calls);
}
@Test
void preservesNullForVendorOtherValues() {
List<String> calls = new ArrayList<>();
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if ("getObject".equals(method.getName())) {
calls.add("getObject");
return null;
}
if ("wasNull".equals(method.getName())) {
return true;
}
return defaultValue(method.getReturnType());
});
Object value = new CacheAgent().resultValue(resultSet, 1, Types.OTHER);
assertNull(value);
assertEquals(Collections.singletonList("getObject"), calls);
}
@Test
void readsColumnsAndUsesInlinePrimaryKey() {
List<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
true,
Collections.emptyList(),
null,
Collections.emptyList()
);
List<ColumnInfo> columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People");
assertEquals(2, columns.size());
assertEquals("ID", columns.get(0).getName());
assertEquals("INTEGER", columns.get(0).getData_type());
assertFalse(columns.get(0).getIs_nullable());
assertTrue(columns.get(0).getIs_primary_key());
assertEquals("42", columns.get(0).getColumn_default());
assertEquals("identifier", columns.get(0).getComment());
assertEquals(Integer.valueOf(10), columns.get(0).getNumeric_precision());
assertEquals(Integer.valueOf(0), columns.get(0).getNumeric_scale());
assertNull(columns.get(0).getCharacter_maximum_length());
assertEquals("NAME", columns.get(1).getName());
assertEquals("VARCHAR", columns.get(1).getData_type());
assertTrue(columns.get(1).getIs_nullable());
assertNull(columns.get(1).getColumn_default());
assertNull(columns.get(1).getComment());
assertNull(columns.get(1).getNumeric_precision());
assertNull(columns.get(1).getNumeric_scale());
assertEquals(Integer.valueOf(64), columns.get(1).getCharacter_maximum_length());
assertEquals(1, calls.size());
assertFalse(calls.get(0).contains("LIMIT"));
assertTrue(calls.get(0).contains("PRIMARY_KEY"));
assertTrue(calls.get(0).contains("DESCRIPTION"));
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
assertTrue(calls.get(0).contains("UPPER(TABLE_SCHEMA) = UPPER(?)"));
}
@Test
void readsDescriptionsWithoutSchemaFilter() {
List<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
true,
Collections.emptyList(),
null,
Collections.emptyList()
);
List<ColumnInfo> columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "", null, "People");
assertEquals("identifier", columns.get(0).getComment());
assertNull(columns.get(1).getComment());
assertEquals(1, calls.size());
assertTrue(calls.get(0).contains("DESCRIPTION"));
assertFalse(calls.get(0).contains("TABLE_SCHEMA"));
}
@Test
void usesJdbcPrimaryKeysWhenColumnRowsHaveNoPrimaryKey() {
List<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
false,
List.of(Map.of("COLUMN_NAME", "ID")),
null,
Collections.emptyList()
);
List<ColumnInfo> columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People");
assertTrue(columns.get(0).getIs_primary_key());
assertEquals(2, calls.size());
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
assertEquals("JDBC_PRIMARY_KEYS", calls.get(1));
}
@Test
void fallsBackToNativePrimaryKeysWhenJdbcMetadataFails() {
List<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
false,
Collections.emptyList(),
new SQLException("Caché metadata unavailable"),
List.of(Map.of("COLUMN_NAME", "ID"))
);
List<ColumnInfo> columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People");
assertTrue(columns.get(0).getIs_primary_key());
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
assertEquals("JDBC_PRIMARY_KEYS", calls.get(1));
assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE"));
}
@Test
void leavesColumnsWithoutPrimaryKeysWhenBothSourcesAreEmpty() {
List<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
false,
Collections.emptyList(),
null,
Collections.emptyList()
);
List<ColumnInfo> columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People");
assertFalse(columns.get(0).getIs_primary_key());
assertFalse(columns.get(1).getIs_primary_key());
assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE"));
}
@Test
void fallsBackToJdbcMetadataWhenInformationSchemaIsUnavailable() {
List<String> calls = new ArrayList<>();
Connection conn = proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
calls.add((String) args[0]);
throw new SQLException("SQLCODE -361: unknown table INFORMATION_SCHEMA.COLUMNS");
}
if ("getMetaData".equals(method.getName())) {
return jdbcFallbackMetadata(calls);
}
return defaultValue(method.getReturnType());
});
List<ColumnInfo> columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People");
assertEquals(1, columns.size());
assertEquals("ID", columns.get(0).getName());
assertEquals("INTEGER", columns.get(0).getData_type());
assertTrue(columns.get(0).getIs_primary_key());
assertEquals(3, calls.size());
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
assertEquals("JDBC_PRIMARY_KEYS", calls.get(1));
assertEquals("JDBC_COLUMNS", calls.get(2));
}
private static DatabaseMetaData jdbcFallbackMetadata(List<String> calls) {
return proxy(DatabaseMetaData.class, (method, args) -> {
String name = method.getName();
if ("getPrimaryKeys".equals(name)) {
calls.add("JDBC_PRIMARY_KEYS");
return fakeResultSet(List.of(Map.of("COLUMN_NAME", "ID")));
}
if ("getColumns".equals(name)) {
calls.add("JDBC_COLUMNS");
return fakeResultSet(List.of(Map.of(
"COLUMN_NAME", "ID",
"TYPE_NAME", "INTEGER",
"NULLABLE", 0,
"COLUMN_DEF", "0",
"REMARKS", "identifier",
"COLUMN_SIZE", 10,
"DECIMAL_DIGITS", 0
)));
}
return defaultValue(method.getReturnType());
});
}
private static Connection fakeConnection(
List<String> callLog,
boolean inlinePrimaryKey,
List<Map<String, Object>> jdbcPrimaryKeys,
SQLException jdbcPrimaryKeyFailure,
List<Map<String, Object>> nativePrimaryKeys
) {
return proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
String sql = (String) args[0];
callLog.add(sql);
return fakeStatement(sql, inlinePrimaryKey, nativePrimaryKeys);
}
if ("getMetaData".equals(method.getName())) {
return fakeMetadata(callLog, jdbcPrimaryKeys, jdbcPrimaryKeyFailure);
}
return defaultValue(method.getReturnType());
});
}
private static DatabaseMetaData fakeMetadata(
List<String> callLog,
List<Map<String, Object>> jdbcPrimaryKeys,
SQLException jdbcPrimaryKeyFailure
) {
return proxy(DatabaseMetaData.class, (method, args) -> {
if ("getPrimaryKeys".equals(method.getName())) {
callLog.add("JDBC_PRIMARY_KEYS");
assertEquals(null, args[0]);
assertEquals("SQLUser", args[1]);
assertEquals("People", args[2]);
if (jdbcPrimaryKeyFailure != null) {
throw jdbcPrimaryKeyFailure;
}
return fakeResultSet(jdbcPrimaryKeys);
}
if ("getColumns".equals(method.getName())) {
throw new AssertionError("Caché 2012+ columns must not use JDBC DatabaseMetaData.getColumns()");
}
return defaultValue(method.getReturnType());
});
}
private static PreparedStatement fakeStatement(
String sql,
boolean inlinePrimaryKey,
List<Map<String, Object>> nativePrimaryKeys
) {
return proxy(PreparedStatement.class, (method, args) -> {
if ("executeQuery".equals(method.getName())) {
if (sql.contains("KEY_COLUMN_USAGE")) {
return fakeResultSet(nativePrimaryKeys);
}
return fakeResultSet(List.of(
Map.of(
"COLUMN_NAME", "ID",
"DATA_TYPE", "INTEGER",
"IS_NULLABLE", "NO",
"COLUMN_DEFAULT", "42",
"PRIMARY_KEY", inlinePrimaryKey ? "YES" : "NO",
"DESCRIPTION", "identifier",
"NUMERIC_PRECISION", 10,
"NUMERIC_SCALE", 0
),
Map.of(
"COLUMN_NAME", "NAME",
"DATA_TYPE", "VARCHAR",
"IS_NULLABLE", "YES",
"PRIMARY_KEY", "NO",
"CHARACTER_MAXIMUM_LENGTH", 64
)
));
}
return defaultValue(method.getReturnType());
});
}
private static ResultSet fakeResultSet(List<Map<String, Object>> rows) {
final int[] index = {-1};
final boolean[] wasNull = {false};
return proxy(ResultSet.class, (method, args) -> {
String name = method.getName();
if ("next".equals(name)) {
index[0] += 1;
return index[0] < rows.size();
}
if ("getString".equals(name)) {
Object value = rows.get(index[0]).get((String) args[0]);
wasNull[0] = value == null;
return value == null ? null : String.valueOf(value);
}
if ("getInt".equals(name)) {
Object value = rows.get(index[0]).get((String) args[0]);
wasNull[0] = value == null;
return value instanceof Number ? ((Number) value).intValue() : 0;
}
if ("getObject".equals(name)) {
Object value = rows.get(index[0]).get((String) args[0]);
wasNull[0] = value == null;
return value;
}
if ("wasNull".equals(name)) {
return wasNull[0];
}
return defaultValue(method.getReturnType());
});
}
@SuppressWarnings("unchecked")
private static <T> T proxy(Class<T> type, MethodHandler handler) {
InvocationHandler invocationHandler = new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
return handler.handle(method, args == null ? new Object[0] : args);
}
};
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, invocationHandler);
}
private static Object defaultValue(Class<?> type) {
if (Boolean.TYPE.equals(type)) {
return false;
}
if (Integer.TYPE.equals(type)) {
return 0;
}
return null;
}
private interface MethodHandler {
Object handle(Method method, Object[] args) throws Throwable;
}
}
+10 -5
View File
@@ -13,7 +13,8 @@ multi-session JSON-RPC protocol without a JVM.
- Astra validation: secure-connect bundle parsing and transport configuration;
live Astra credentials were not available
- Authentication: username/password and Kerberos/GSSAPI
- TLS: CA verification, optional client certificate/key, hostname verification
- TLS: CA verification, optional client certificate/key, JKS/PKCS#12 stores,
hostname verification
- Cloud: DataStax Astra secure connect bundles
- Configuration: Java Driver 4 HOCON `configfile` mapping plus native extensions
- Metadata: keyspaces, tables, columns, indexes, CQL table DDL, completion search
@@ -38,6 +39,8 @@ syntax.
| `enablessl` | TLS enablement |
| `sslenginefactory` | the standard `DefaultSslEngineFactory` maps to native TLS |
| `hostnameverification` | TLS hostname verification; enabled by default |
| `truststorepath`, `truststorepassword` | JKS/PKCS#12 server trust configuration |
| `keystorepath`, `keystorepassword` | JKS/PKCS#12 client certificate configuration |
| `user`, `password` | password authentication |
| `configfile` | Java Driver 4 HOCON configuration; overrides URL options except contact points and keyspace |
| `usekrb5` | Kerberos/GSSAPI authentication using password, keytab, or FILE credential cache |
@@ -48,8 +51,8 @@ syntax.
The Agent rejects custom Java implementation classes because they cannot be
loaded by a native binary. This includes custom authentication, SSL, retry,
reconnection, and load-balancing classes. Java JKS/PKCS12 truststores and
keystores are not read directly; use the native PEM paths described below.
reconnection, and load-balancing classes. The default SSL engine's JKS and
PKCS#12 truststores and keystores are supported directly.
## Java Driver HOCON configuration
@@ -66,7 +69,8 @@ Mapped Java Driver paths include:
- `advanced.socket.tcp-no-delay` and `keep-alive`
- `advanced.protocol.version`, retry policy, and reconnection policy
- `advanced.auth-provider` plaintext and Instaclustr Kerberos options
- `advanced.ssl-engine-factory` default TLS and hostname validation
- `advanced.ssl-engine-factory` default TLS, hostname validation, truststore,
and keystore settings
Native-only settings can be placed under `dbx.cassandra`:
@@ -133,7 +137,8 @@ go test -run TestCassandraIntegration -v
Optional variables include `CASSANDRA_TEST_URL_PARAMS`, `CASSANDRA_TEST_SSL`,
`CASSANDRA_TEST_CA_CERT_PATH`, `CASSANDRA_TEST_CLIENT_CERT_PATH`, and
`CASSANDRA_TEST_CLIENT_KEY_PATH`.
`CASSANDRA_TEST_CLIENT_KEY_PATH`. Truststore and keystore settings can also be
provided through `CASSANDRA_TEST_URL_PARAMS`.
See `bench/README.md` for the archived JDBC comparison workflow and measured
Cassandra 4.1.10 results.
+13 -3
View File
@@ -20,11 +20,21 @@ import (
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
// testPlaceholderPassword returns a stand-in for password fields in tests;
// Astra treats the password as an opaque application token, so any non-empty
// value exercises the same code path. Set DBX_TEST_PASSWORD to override.
func testPlaceholderPassword() string {
if value := os.Getenv("DBX_TEST_PASSWORD"); value != "" {
return value
}
return "placeholder-auth-value"
}
func TestSecureConnectBundleBuildsAstraClusterWithoutHost(t *testing.T) {
bundlePath := writeTestSecureConnectBundle(t)
config, err := parseCassandraConfig(connectParams{
Username: "token",
Password: "astra-token",
Password: testPlaceholderPassword(),
URLParams: url.Values{
"secureconnectbundle": []string{bundlePath},
"requesttimeout": []string{"9s"},
@@ -49,7 +59,7 @@ func TestSecureConnectBundleBuildsAstraClusterWithoutHost(t *testing.T) {
t.Fatalf("Astra cluster must use dialer placeholder hosts: %#v", cluster.Hosts)
}
credentials, ok := cluster.Authenticator.(*gocql.PasswordAuthenticator)
if !ok || credentials.Username != "token" || credentials.Password != "astra-token" {
if !ok || credentials.Username != "token" || credentials.Password != testPlaceholderPassword() {
t.Fatalf("unexpected Astra authenticator: %#v", cluster.Authenticator)
}
if cluster.Keyspace != "app" || cluster.Timeout != 9*time.Second || cluster.ConnectTimeout != 7*time.Second {
@@ -95,7 +105,7 @@ func TestSecureConnectBundleValidatesCredentialsAndAuthMode(t *testing.T) {
if _, err := parseCassandraConfig(connectParams{
Username: "token",
Password: "astra-token",
Password: testPlaceholderPassword(),
URLParams: url.Values{
"secureconnectbundle": []string{bundlePath},
"usekrb5": []string{"true"},
+4 -3
View File
@@ -38,11 +38,12 @@ python3 drivers/cassandra-go/bench/agent_compare.py \
> /tmp/dbx-cassandra-bench/result.json
```
If Java is only available in a container, provide the full interactive command:
If Java is only available in a container, provide the full interactive command
as a JSON argv array so no shell is implied (pass `["sh", "-c", "..."]`
explicitly when you need shell features):
```bash
JDBC_AGENT_COMMAND='docker run --rm -i --name dbx-cassandra-jdbc-bench -v /tmp/dbx-cassandra-bench:/bench:ro eclipse-temurin:21-jre java -jar /bench/dbx-agent-cassandra.jar'
JDBC_RSS_COMMAND="docker inspect --format '{{.State.Pid}}' dbx-cassandra-jdbc-bench | xargs -I{} awk '/VmRSS/ {print \$2}' /proc/{}/status"
JDBC_AGENT_COMMAND='["docker","run","--rm","-i","--name","dbx-cassandra-jdbc-bench","-v","/tmp/dbx-cassandra-bench:/bench:ro","eclipse-temurin:21-jre","java","-jar","/bench/dbx-agent-cassandra.jar"]'
```
## Configuration
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
import json
import os
import shlex
import statistics
import subprocess
import sys
@@ -16,7 +15,21 @@ class Candidate:
name: str
command: list[str]
artifact: Path
rss_command: str = ""
def command_env_list(name: str) -> list[str]:
"""Reads a command from an env var holding a JSON argv array.
Command env vars take JSON arrays so no shell is implied; operators who
need shell features pass ["sh", "-c", "..."] explicitly.
"""
raw = os.getenv(name, "")
if not raw:
return []
value = json.loads(raw)
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
raise ValueError(f"{name} must be a JSON array of strings")
return [item for item in value]
class AgentProcess:
@@ -81,9 +94,6 @@ class AgentProcess:
return response.get("result")
def rss_kib(self) -> int:
if self.candidate.rss_command:
output = subprocess.check_output(self.candidate.rss_command, shell=True, text=True).strip()
return int(output)
output = subprocess.check_output(
["ps", "-o", "rss=", "-p", str(self.process.pid)],
text=True,
@@ -168,12 +178,11 @@ def configured_candidates() -> list[Candidate]:
candidates = []
if "go" in selected:
artifact = required_path("GO_AGENT")
candidates.append(Candidate("go-native", [str(artifact)], artifact, os.getenv("GO_RSS_COMMAND", "")))
candidates.append(Candidate("go-native", [str(artifact)], artifact))
if "jdbc" in selected:
artifact = required_path("JDBC_AGENT_JAR")
raw_command = os.getenv("JDBC_AGENT_COMMAND", "")
command = shlex.split(raw_command) if raw_command else [env_default("JAVA_BIN", "java"), "-jar", str(artifact)]
candidates.append(Candidate("jdbc-java", command, artifact, os.getenv("JDBC_RSS_COMMAND", "")))
command = command_env_list("JDBC_AGENT_COMMAND") or [env_default("JAVA_BIN", "java"), "-jar", str(artifact)]
candidates.append(Candidate("jdbc-java", command, artifact))
if not candidates:
raise ValueError("BENCH_CANDIDATES selected no candidates")
return candidates
+50 -1
View File
@@ -31,6 +31,10 @@ type cassandraConfig struct {
caCertPath string
clientCertPath string
clientKeyPath string
truststorePath string
truststorePassword string
keystorePath string
keystorePassword string
hostVerification bool
tcpNoDelay bool
keepAlive bool
@@ -61,6 +65,10 @@ func parseCassandraConfig(cp connectParams) (cassandraConfig, error) {
caCertPath: cp.CACertPath,
clientCertPath: cp.ClientCertPath,
clientKeyPath: cp.ClientKeyPath,
truststorePath: cp.TruststorePath,
truststorePassword: cp.TruststorePassword,
keystorePath: cp.KeystorePath,
keystorePassword: cp.KeystorePassword,
hostVerification: true,
tcpNoDelay: true,
retryCount: 3,
@@ -224,6 +232,14 @@ func applyCassandraURLParams(config *cassandraConfig, params url.Values) error {
return fmt.Errorf("invalid host verification option: %w", err)
}
config.hostVerification = enabled
case "truststorepath":
config.truststorePath = value
case "truststorepassword":
config.truststorePassword = value
case "keystorepath":
config.keystorePath = value
case "keystorepassword":
config.keystorePassword = value
case "tcpnodelay":
enabled, err := strconv.ParseBool(value)
if err != nil {
@@ -406,12 +422,20 @@ func (config cassandraConfig) clusterConfig(keyspace string) (*gocql.ClusterConf
cluster.Authenticator = gocql.PasswordAuthenticator{Username: config.username, Password: config.password}
}
if config.secureConnectBundle == "" && config.ssl {
cluster.SslOpts = &gocql.SslOptions{
sslOptions := &gocql.SslOptions{
CaPath: config.caCertPath,
CertPath: config.clientCertPath,
KeyPath: config.clientKeyPath,
EnableHostVerification: config.hostVerification,
}
if config.truststorePath != "" || config.keystorePath != "" {
tlsConfig, err := buildCassandraTLSConfig(config)
if err != nil {
return nil, err
}
sslOptions.Config = tlsConfig
}
cluster.SslOpts = sslOptions
}
if config.debug {
cluster.Logger = gocql.NewLogger(gocql.LogLevelDebug)
@@ -435,6 +459,31 @@ func (config *cassandraConfig) finalize() error {
if err != nil {
return fmt.Errorf("invalid Cassandra secureconnectbundle: %w", err)
}
if config.secureConnectBundle == "" {
config.truststorePath, err = normalizeLocalFilePath(config.truststorePath)
if err != nil {
return fmt.Errorf("invalid Cassandra truststore path: %w", err)
}
config.keystorePath, err = normalizeLocalFilePath(config.keystorePath)
if err != nil {
return fmt.Errorf("invalid Cassandra keystore path: %w", err)
}
if config.truststorePassword != "" && config.truststorePath == "" {
return fmt.Errorf("Cassandra truststore password requires a truststore path")
}
if config.keystorePassword != "" && config.keystorePath == "" {
return fmt.Errorf("Cassandra keystore password requires a keystore path")
}
if config.truststorePath != "" && strings.TrimSpace(config.caCertPath) != "" {
return fmt.Errorf("Cassandra truststore cannot be combined with a PEM CA certificate")
}
if config.keystorePath != "" && (strings.TrimSpace(config.clientCertPath) != "" || strings.TrimSpace(config.clientKeyPath) != "") {
return fmt.Errorf("Cassandra keystore cannot be combined with PEM client certificate settings")
}
if config.truststorePath != "" || config.keystorePath != "" {
config.ssl = true
}
}
if config.secureConnectBundle != "" && config.kerberos.enabled {
return fmt.Errorf("Cassandra secure connect bundles cannot be combined with Kerberos authentication")
}
+16 -4
View File
@@ -263,13 +263,25 @@ func applyHOCONSSL(config *cassandraConfig, parsed *hocon.Config) error {
config.hostVerification = value
config.ssl = true
}
for _, path := range []string{prefix + "truststore-path", prefix + "keystore-path"} {
if value, ok, err := hoconString(parsed, path); err != nil {
stringMappings := []struct {
path string
target *string
}{
{prefix + "truststore-path", &config.truststorePath},
{prefix + "truststore-password", &config.truststorePassword},
{prefix + "keystore-path", &config.keystorePath},
{prefix + "keystore-password", &config.keystorePassword},
}
for _, mapping := range stringMappings {
if value, ok, err := hoconString(parsed, mapping.path); err != nil {
return err
} else if ok && value != "" {
return fmt.Errorf("Java truststore and keystore files are not supported; use dbx.cassandra.tls PEM paths")
} else if ok {
*mapping.target = value
}
}
if config.truststorePath != "" || config.keystorePath != "" {
config.ssl = true
}
return nil
}
@@ -52,6 +52,10 @@ datastax-java-driver {
ssl-engine-factory {
class = DefaultSslEngineFactory
hostname-validation = false
truststore-path = /certs/client.truststore
truststore-password = trust-secret
keystore-path = /certs/client.keystore
keystore-password = key-secret
}
}
}
@@ -61,7 +65,7 @@ datastax-java-driver {
Host: "url.example.com",
Database: "url_keyspace",
Username: "url-user",
Password: "url-password",
Password: testPlaceholderPassword(),
URLParams: url.Values{
"configfile": []string{configPath},
"requesttimeout": []string{"30s"},
@@ -110,6 +114,12 @@ datastax-java-driver {
if !config.ssl || config.hostVerification {
t.Fatalf("unexpected TLS config: %#v", config)
}
if config.truststorePath != filepath.Clean("/certs/client.truststore") || config.truststorePassword != "trust-secret" {
t.Fatalf("unexpected truststore config: %#v", config)
}
if config.keystorePath != filepath.Clean("/certs/client.keystore") || config.keystorePassword != "key-secret" {
t.Fatalf("unexpected keystore config: %#v", config)
}
}
func TestCassandraConfigFileSupportsKerberosLoginOptionCasing(t *testing.T) {
+4 -2
View File
@@ -8,6 +8,8 @@ require (
github.com/gurkankaymak/hocon v1.3.0
github.com/jcmturner/gofork v1.7.6
github.com/jcmturner/gokrb5/v8 v8.4.4
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0
software.sslmate.com/src/go-pkcs12 v0.7.3
)
require (
@@ -23,7 +25,7 @@ require (
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.6.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/net v0.10.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
)
+8 -2
View File
@@ -45,6 +45,8 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 h1:2nosf3P75OZv2/ZO/9Px5ZgZ5gbKrzA3joN1QMfOGMQ=
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0/go.mod h1:lAVhWwbNaveeJmxrxuSTxMgKpF6DjnuVpn6T8WiBwYQ=
github.com/pierrec/lz4/v4 v4.1.8 h1:ieHkV+i2BRzngO4Wd/3HGowuZStgq6QkPsD1eolNAO4=
github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -74,16 +76,18 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -112,3 +116,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+16 -12
View File
@@ -35,18 +35,22 @@ type response struct {
}
type connectParams struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
URLParams string `json:"url_params"`
ConnectionString string `json:"connection_string"`
SSL bool `json:"ssl"`
CACertPath string `json:"ca_cert_path"`
ClientCertPath string `json:"client_cert_path"`
ClientKeyPath string `json:"client_key_path"`
SessionRole string `json:"sessionRole"`
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
URLParams string `json:"url_params"`
ConnectionString string `json:"connection_string"`
SSL bool `json:"ssl"`
CACertPath string `json:"ca_cert_path"`
ClientCertPath string `json:"client_cert_path"`
ClientKeyPath string `json:"client_key_path"`
TruststorePath string `json:"truststore_path"`
TruststorePassword string `json:"truststore_password"`
KeystorePath string `json:"keystore_path"`
KeystorePassword string `json:"keystore_password"`
SessionRole string `json:"sessionRole"`
}
type queryOptions struct {
+54 -12
View File
@@ -231,38 +231,80 @@ func (s *server) listTables(schema string, constraints metadataListConstraints)
if err != nil {
return nil, err
}
names := sortedMapKeys(metadata.Tables)
result := make([]tableInfo, 0, len(names))
for _, name := range names {
return tableInfosFromKeyspaceMetadata(metadata, constraints), nil
}
// Materialized views live in their own section of the keyspace metadata, so
// listing tables only would hide them from the browser. Report them with their
// own table type so tables and views stay distinguishable.
func tableInfosFromKeyspaceMetadata(metadata *gocql.KeyspaceMetadata, constraints metadataListConstraints) []tableInfo {
result := make([]tableInfo, 0, len(metadata.Tables)+len(metadata.MaterializedViews))
for _, name := range sortedMapKeys(metadata.Tables) {
if !metadataNameMatches(name, constraints.Filter) {
continue
}
result = append(result, tableInfo{Name: name, TableType: "TABLE"})
}
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
for _, name := range sortedMapKeys(metadata.MaterializedViews) {
if !metadataNameMatches(name, constraints.Filter) {
continue
}
result = append(result, tableInfo{Name: name, TableType: "MATERIALIZED_VIEW"})
}
return applyMetadataWindow(result, constraints.Offset, constraints.Limit)
}
func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) {
tables, err := s.listTables(schema, metadataListConstraints{Filter: constraints.Filter})
metadata, err := s.keyspaceMetadata(schema)
if err != nil {
return nil, err
}
return objectInfosFromKeyspaceMetadata(metadata, schema, constraints), nil
}
func objectInfosFromKeyspaceMetadata(metadata *gocql.KeyspaceMetadata, schema string, constraints metadataListConstraints) []objectInfo {
allowed := stringSet(constraints.ObjectTypes)
result := make([]objectInfo, 0, len(tables))
for _, table := range tables {
if len(allowed) > 0 && !allowed["table"] && !allowed["base_table"] {
continue
tablesAllowed := len(allowed) == 0 || allowed["table"] || allowed["base_table"]
viewsAllowed := len(allowed) == 0 || allowed["view"] || allowed["materialized_view"]
result := make([]objectInfo, 0, len(metadata.Tables)+len(metadata.MaterializedViews))
if tablesAllowed {
for _, name := range sortedMapKeys(metadata.Tables) {
if !metadataNameMatches(name, constraints.Filter) {
continue
}
result = append(result, objectInfo{Name: name, ObjectType: "TABLE", Schema: schema})
}
result = append(result, objectInfo{Name: table.Name, ObjectType: "TABLE", Schema: schema})
}
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
if viewsAllowed {
for _, name := range sortedMapKeys(metadata.MaterializedViews) {
if !metadataNameMatches(name, constraints.Filter) {
continue
}
result = append(result, objectInfo{Name: name, ObjectType: "MATERIALIZED_VIEW", Schema: schema})
}
}
return applyMetadataWindow(result, constraints.Offset, constraints.Limit)
}
func (s *server) getColumns(schema, table string) ([]columnInfo, error) {
metadata, err := s.tableMetadata(schema, table)
keyspace, err := s.keyspaceMetadata(schema)
if err != nil {
return nil, err
}
return columnsForSchemaObject(keyspace, schema, table)
}
func columnsForSchemaObject(keyspace *gocql.KeyspaceMetadata, schema, table string) ([]columnInfo, error) {
if view := keyspace.MaterializedViews[table]; view != nil {
if view.BaseTable == nil {
return nil, fmt.Errorf("Cassandra materialized view base table metadata unavailable: %s.%s", schema, table)
}
return columnsFromMetadata(view.BaseTable), nil
}
metadata := keyspace.Tables[table]
if metadata == nil {
return nil, fmt.Errorf("Cassandra table not found: %s.%s", schema, table)
}
return columnsFromMetadata(metadata), nil
}
@@ -159,3 +159,125 @@ func TestListTriggersReturnsQueryError(t *testing.T) {
t.Fatalf("triggers = %#v, err = %v", triggers, err)
}
}
func keyspaceWithTablesAndViews() *gocql.KeyspaceMetadata {
return &gocql.KeyspaceMetadata{
Tables: map[string]*gocql.TableMetadata{
"users": {},
"events": {},
},
MaterializedViews: map[string]*gocql.MaterializedViewMetadata{
"users_by_email": {Name: "users_by_email"},
},
}
}
func TestTableInfosReportMaterializedViewsAsTheirOwnType(t *testing.T) {
tables := tableInfosFromKeyspaceMetadata(keyspaceWithTablesAndViews(), metadataListConstraints{})
want := []tableInfo{
{Name: "events", TableType: "TABLE"},
{Name: "users", TableType: "TABLE"},
{Name: "users_by_email", TableType: "MATERIALIZED_VIEW"},
}
if !reflect.DeepEqual(tables, want) {
t.Fatalf("tables = %#v, want %#v", tables, want)
}
}
func TestTableInfosFilterAppliesToTablesAndViews(t *testing.T) {
tables := tableInfosFromKeyspaceMetadata(keyspaceWithTablesAndViews(), metadataListConstraints{Filter: "users"})
want := []tableInfo{
{Name: "users", TableType: "TABLE"},
{Name: "users_by_email", TableType: "MATERIALIZED_VIEW"},
}
if !reflect.DeepEqual(tables, want) {
t.Fatalf("tables = %#v, want %#v", tables, want)
}
}
func TestObjectInfosIncludeMaterializedViews(t *testing.T) {
objects := objectInfosFromKeyspaceMetadata(keyspaceWithTablesAndViews(), "dev", metadataListConstraints{})
want := []objectInfo{
{Name: "events", ObjectType: "TABLE", Schema: "dev"},
{Name: "users", ObjectType: "TABLE", Schema: "dev"},
{Name: "users_by_email", ObjectType: "MATERIALIZED_VIEW", Schema: "dev"},
}
if !reflect.DeepEqual(objects, want) {
t.Fatalf("objects = %#v, want %#v", objects, want)
}
}
func TestObjectInfosRespectObjectTypeFilters(t *testing.T) {
keyspace := keyspaceWithTablesAndViews()
tablesOnly := objectInfosFromKeyspaceMetadata(keyspace, "dev", metadataListConstraints{ObjectTypes: []string{"table"}})
if len(tablesOnly) != 2 || tablesOnly[0].Name != "events" || tablesOnly[1].Name != "users" {
t.Fatalf("tablesOnly = %#v", tablesOnly)
}
viewsOnly := objectInfosFromKeyspaceMetadata(keyspace, "dev", metadataListConstraints{ObjectTypes: []string{"view"}})
want := []objectInfo{{Name: "users_by_email", ObjectType: "MATERIALIZED_VIEW", Schema: "dev"}}
if !reflect.DeepEqual(viewsOnly, want) {
t.Fatalf("viewsOnly = %#v, want %#v", viewsOnly, want)
}
materializedOnly := objectInfosFromKeyspaceMetadata(keyspace, "dev", metadataListConstraints{ObjectTypes: []string{"materialized_view"}})
if !reflect.DeepEqual(materializedOnly, want) {
t.Fatalf("materializedOnly = %#v, want %#v", materializedOnly, want)
}
}
func TestColumnsForSchemaObjectResolvesViewColumnsFromBaseTable(t *testing.T) {
textType := gocql.NewNativeType(4, gocql.TypeVarchar, "")
id := &gocql.ColumnMetadata{Name: "email", Kind: gocql.ColumnPartitionKey, Type: textType}
name := &gocql.ColumnMetadata{Name: "username", Kind: gocql.ColumnRegular, Type: textType}
baseTable := &gocql.TableMetadata{
OrderedColumns: []string{"email", "username"},
PartitionKey: []*gocql.ColumnMetadata{id},
Columns: map[string]*gocql.ColumnMetadata{"email": id, "username": name},
}
keyspace := &gocql.KeyspaceMetadata{
Tables: map[string]*gocql.TableMetadata{"users": baseTable},
MaterializedViews: map[string]*gocql.MaterializedViewMetadata{
"users_by_email": {Name: "users_by_email", BaseTable: baseTable},
},
}
columns, err := columnsForSchemaObject(keyspace, "dev", "users_by_email")
if err != nil {
t.Fatal(err)
}
want := []string{"email", "username"}
if len(columns) != len(want) {
t.Fatalf("columns = %#v", columns)
}
for index, column := range columns {
if column.Name != want[index] {
t.Fatalf("columns[%d] = %s, want %s", index, column.Name, want[index])
}
if column.IsPrimaryKey != (index == 0) {
t.Fatalf("columns[%d].IsPrimaryKey = %v", index, column.IsPrimaryKey)
}
}
tableColumns, err := columnsForSchemaObject(keyspace, "dev", "users")
if err != nil {
t.Fatal(err)
}
if len(tableColumns) != 2 || tableColumns[0].Name != "email" {
t.Fatalf("tableColumns = %#v", tableColumns)
}
if _, err := columnsForSchemaObject(keyspace, "dev", "missing"); err == nil {
t.Fatal("expected an error for a missing table")
}
orphanKeyspace := &gocql.KeyspaceMetadata{
MaterializedViews: map[string]*gocql.MaterializedViewMetadata{
"users_by_email": {Name: "users_by_email"},
},
}
if _, err := columnsForSchemaObject(orphanKeyspace, "dev", "users_by_email"); err == nil {
t.Fatal("expected an error when view base table metadata is unavailable")
}
}
+181
View File
@@ -0,0 +1,181 @@
package main
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/binary"
"errors"
"fmt"
"os"
keystore "github.com/pavlo-v-chernykh/keystore-go/v4"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
const jksMagic = 0xfeedfeed
func buildCassandraTLSConfig(config cassandraConfig) (*tls.Config, error) {
tlsConfig := &tls.Config{InsecureSkipVerify: !config.hostVerification}
if config.truststorePath != "" {
certificates, err := loadTruststore(config.truststorePath, config.truststorePassword)
if err != nil {
return nil, fmt.Errorf("load Cassandra truststore %q: %w", config.truststorePath, err)
}
roots := x509.NewCertPool()
for _, certificate := range certificates {
roots.AddCert(certificate)
}
tlsConfig.RootCAs = roots
}
if config.keystorePath != "" {
certificate, err := loadKeystore(config.keystorePath, config.keystorePassword)
if err != nil {
return nil, fmt.Errorf("load Cassandra keystore %q: %w", config.keystorePath, err)
}
tlsConfig.Certificates = []tls.Certificate{certificate}
}
return tlsConfig, nil
}
func loadTruststore(path string, password string) ([]*x509.Certificate, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if isJKS(data) {
return loadJKSTruststore(data, password)
}
certificates, err := pkcs12.DecodeTrustStore(data, password)
if err == nil && len(certificates) > 0 {
return certificates, nil
}
_, certificate, chain, chainErr := pkcs12.DecodeChain(data, password)
if chainErr != nil {
if err != nil {
return nil, fmt.Errorf("decode PKCS#12 truststore: %w", err)
}
return nil, fmt.Errorf("PKCS#12 truststore contains no trusted certificates")
}
return append([]*x509.Certificate{certificate}, chain...), nil
}
func loadKeystore(path string, password string) (tls.Certificate, error) {
data, err := os.ReadFile(path)
if err != nil {
return tls.Certificate{}, err
}
if isJKS(data) {
return loadJKSKeystore(data, password)
}
privateKey, certificate, caCertificates, err := pkcs12.DecodeChain(data, password)
if err != nil {
return tls.Certificate{}, fmt.Errorf("decode PKCS#12 keystore: %w", err)
}
chain := make([][]byte, 0, 1+len(caCertificates))
chain = append(chain, certificate.Raw)
for _, caCertificate := range caCertificates {
chain = append(chain, caCertificate.Raw)
}
return tls.Certificate{Certificate: chain, PrivateKey: privateKey, Leaf: certificate}, nil
}
func isJKS(data []byte) bool {
return len(data) >= 4 && binary.BigEndian.Uint32(data[:4]) == jksMagic
}
func loadJKS(data []byte, password string) (keystore.KeyStore, error) {
store := keystore.New()
passwordBytes := []byte(password)
defer clear(passwordBytes)
if err := store.Load(bytes.NewReader(data), passwordBytes); err != nil {
return keystore.KeyStore{}, fmt.Errorf("decode JKS: %w", err)
}
return store, nil
}
func loadJKSTruststore(data []byte, password string) ([]*x509.Certificate, error) {
store, err := loadJKS(data, password)
if err != nil {
return nil, err
}
certificates := make([]*x509.Certificate, 0)
for _, alias := range store.Aliases() {
switch {
case store.IsTrustedCertificateEntry(alias):
entry, err := store.GetTrustedCertificateEntry(alias)
if err != nil {
return nil, fmt.Errorf("read trusted certificate %q: %w", alias, err)
}
certificate, err := x509.ParseCertificate(entry.Certificate.Content)
if err != nil {
return nil, fmt.Errorf("parse trusted certificate %q: %w", alias, err)
}
certificates = append(certificates, certificate)
case store.IsPrivateKeyEntry(alias):
chain, err := store.GetPrivateKeyEntryCertificateChain(alias)
if err != nil {
return nil, fmt.Errorf("read certificate chain %q: %w", alias, err)
}
for _, entry := range chain {
certificate, err := x509.ParseCertificate(entry.Content)
if err != nil {
return nil, fmt.Errorf("parse certificate chain %q: %w", alias, err)
}
certificates = append(certificates, certificate)
}
}
}
if len(certificates) == 0 {
return nil, fmt.Errorf("JKS truststore contains no trusted certificates")
}
return certificates, nil
}
func loadJKSKeystore(data []byte, password string) (tls.Certificate, error) {
store, err := loadJKS(data, password)
if err != nil {
return tls.Certificate{}, err
}
passwordBytes := []byte(password)
defer clear(passwordBytes)
for _, alias := range store.Aliases() {
if !store.IsPrivateKeyEntry(alias) {
continue
}
entry, err := store.GetPrivateKeyEntry(alias, passwordBytes)
if err != nil {
return tls.Certificate{}, fmt.Errorf("read private key %q: %w", alias, err)
}
privateKey, err := parsePrivateKey(entry.PrivateKey)
if err != nil {
return tls.Certificate{}, fmt.Errorf("parse private key %q: %w", alias, err)
}
chain := make([][]byte, 0, len(entry.CertificateChain))
for _, certificate := range entry.CertificateChain {
chain = append(chain, certificate.Content)
}
if len(chain) == 0 {
return tls.Certificate{}, fmt.Errorf("private key %q has no certificate chain", alias)
}
leaf, err := x509.ParseCertificate(chain[0])
if err != nil {
return tls.Certificate{}, fmt.Errorf("parse client certificate %q: %w", alias, err)
}
return tls.Certificate{Certificate: chain, PrivateKey: privateKey, Leaf: leaf}, nil
}
return tls.Certificate{}, fmt.Errorf("JKS keystore contains no private key entries")
}
func parsePrivateKey(data []byte) (any, error) {
if value, err := x509.ParsePKCS8PrivateKey(data); err == nil {
return value, nil
}
if value, err := x509.ParsePKCS1PrivateKey(data); err == nil {
return value, nil
}
if value, err := x509.ParseECPrivateKey(data); err == nil {
return value, nil
}
return nil, errors.New("unsupported private key encoding")
}
@@ -0,0 +1,152 @@
package main
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net/url"
"os"
"path/filepath"
"testing"
"time"
keystore "github.com/pavlo-v-chernykh/keystore-go/v4"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
const tlsStoreTestPassword = "changeit"
func TestLoadJKSStores(t *testing.T) {
_, certificate, privateKeyDER := testTLSIdentity(t)
store := keystore.New()
if err := store.SetTrustedCertificateEntry("ca", keystore.TrustedCertificateEntry{
CreationTime: time.Now(),
Certificate: keystore.Certificate{Type: "X509", Content: certificate.Raw},
}); err != nil {
t.Fatal(err)
}
if err := store.SetPrivateKeyEntry("client", keystore.PrivateKeyEntry{
CreationTime: time.Now(),
PrivateKey: privateKeyDER,
CertificateChain: []keystore.Certificate{
{Type: "X509", Content: certificate.Raw},
},
}, []byte(tlsStoreTestPassword)); err != nil {
t.Fatal(err)
}
var encoded bytes.Buffer
if err := store.Store(&encoded, []byte(tlsStoreTestPassword)); err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "client.jks")
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
trusted, err := loadTruststore(path, tlsStoreTestPassword)
if err != nil {
t.Fatal(err)
}
identity, err := loadKeystore(path, tlsStoreTestPassword)
if err != nil {
t.Fatal(err)
}
if len(trusted) != 2 || identity.Leaf == nil {
t.Fatalf("unexpected JKS contents: trusted=%d identity=%#v", len(trusted), identity)
}
}
func TestLoadPKCS12Stores(t *testing.T) {
privateKey, certificate, _ := testTLSIdentity(t)
truststore, err := pkcs12.Modern.EncodeTrustStore([]*x509.Certificate{certificate}, tlsStoreTestPassword)
if err != nil {
t.Fatal(err)
}
keystoreData, err := pkcs12.Modern.Encode(privateKey, certificate, nil, tlsStoreTestPassword)
if err != nil {
t.Fatal(err)
}
directory := t.TempDir()
truststorePath := filepath.Join(directory, "client-trust.p12")
keystorePath := filepath.Join(directory, "client-key.p12")
if err := os.WriteFile(truststorePath, truststore, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keystorePath, keystoreData, 0o600); err != nil {
t.Fatal(err)
}
tlsConfig, err := buildCassandraTLSConfig(cassandraConfig{
truststorePath: truststorePath,
truststorePassword: tlsStoreTestPassword,
keystorePath: keystorePath,
keystorePassword: tlsStoreTestPassword,
hostVerification: true,
})
if err != nil {
t.Fatal(err)
}
if tlsConfig.RootCAs == nil || len(tlsConfig.Certificates) != 1 || tlsConfig.InsecureSkipVerify {
t.Fatalf("unexpected PKCS#12 TLS config: %#v", tlsConfig)
}
}
func TestParseCassandraConfigRejectsStorePasswordWithoutPath(t *testing.T) {
_, err := parseCassandraConfig(connectParams{Host: "localhost", TruststorePassword: "secret"})
if err == nil {
t.Fatal("expected truststore path validation error")
}
}
func TestSecureConnectBundleIgnoresManualTLSStoreSettings(t *testing.T) {
config, err := parseCassandraConfig(connectParams{
Username: "token",
Password: "secret",
CACertPath: "ca.pem",
URLParams: url.Values{
"secureconnectbundle": []string{"bundle.zip"},
"truststorepath": []string{"https://ignored.example/truststore"},
"truststorepassword": []string{"ignored"},
}.Encode(),
})
if err != nil {
t.Fatal(err)
}
if config.secureConnectBundle != "bundle.zip" {
t.Fatalf("unexpected secure connect bundle: %q", config.secureConnectBundle)
}
}
func testTLSIdentity(t *testing.T) (*ecdsa.PrivateKey, *x509.Certificate, []byte) {
t.Helper()
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "cassandra-client"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
IsCA: true,
}
certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
t.Fatal(err)
}
certificate, err := x509.ParseCertificate(certificateDER)
if err != nil {
t.Fatal(err)
}
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
t.Fatal(err)
}
return privateKey, certificate, privateKeyDER
}
@@ -68,6 +68,10 @@ public final class DamengAgent extends AbstractJdbcAgent {
private static final int DBMS_OUTPUT_ENABLE_TIMEOUT_SECS = 5;
private static final int DBMS_OUTPUT_ENABLE_NETWORK_TIMEOUT_MILLIS = 5_000;
private static final Pattern DATABASE_VERSION_MAJOR_PATTERN = Pattern.compile("(\\d+)\\.");
// Word-boundary match so a type or default merely containing the letters is not mistaken
// for the IDENTITY keyword.
private static final Pattern DDL_IDENTITY_KEYWORD_PATTERN =
Pattern.compile("(?i)(?<![A-Z0-9_$#])IDENTITY(?![A-Z0-9_$#])");
private static final JdbcAgentProfile DM6_METADATA_PROFILE = new JdbcAgentProfile(
"dm6.jdbc.driver.DmDriver",
"jdbc:dm6://{host}:{port}/{database}",
@@ -109,6 +113,19 @@ public final class DamengAgent extends AbstractJdbcAgent {
private URLClassLoader externalDriverLoader;
private List<URL> externalDriverUrls;
private String externalDriverClass;
@Override
protected JdbcExecutor.ResultValueReader resultValueReader() {
return (JdbcExecutor.ColumnAwareResultValueReader) this::readDamengValue;
}
private Object readDamengValue(ResultSet resultSet, int index, int sqlType, String columnTypeName) throws SQLException {
if ("VARCHAR2".equalsIgnoreCase(columnTypeName == null ? "" : columnTypeName.trim())) {
String value = resultSet.getString(index);
return resultSet.wasNull() ? null : value;
}
return super.resultValue(resultSet, index, sqlType);
}
private volatile boolean legacyJdbcMetadata;
private volatile boolean dbmsOutputInitializationSupported = true;
private final Map<Object, Boolean> dbmsOutputInitializedConnections =
@@ -461,7 +478,21 @@ public final class DamengAgent extends AbstractJdbcAgent {
if (legacyJdbcMetadata) {
return unchecked(() -> listJdbcSchemas().stream().map(DatabaseInfo::new).toList());
}
return unchecked(() -> listVisibleUsers().stream().map(DatabaseInfo::new).toList());
// DM8 ALL_USERS is privilege-filtered: a normal user only sees itself, so prefer the
// full SYS.SYSOBJECTS catalog (mirroring listSchemas; newer/hardened builds require
// the SOI role) and fall back to ALL_USERS when the catalog is not readable.
return unchecked(() -> {
try {
return listVisibleSchemas().stream().map(DatabaseInfo::new).toList();
} catch (SQLException catalogError) {
try {
return listVisibleUsers().stream().map(DatabaseInfo::new).toList();
} catch (Exception fallbackError) {
catalogError.addSuppressed(fallbackError);
throw catalogError;
}
}
});
}
@Override
@@ -722,7 +753,11 @@ public final class DamengAgent extends AbstractJdbcAgent {
|| normalized.contains("all_views")
|| normalized.contains("all_triggers")
|| normalized.contains("all_sequences")
|| normalized.contains("systexts");
|| normalized.contains("systexts")
// DM8 may report a view's metadata failure as an internal
// index lookup error without mentioning DBMS_METADATA.
|| normalized.contains("内部索引")
|| normalized.contains("internal index");
boolean permissionDenied = normalized.contains("权限")
|| normalized.contains("privilege")
|| normalized.contains("permission")
@@ -731,6 +766,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
boolean missingObject = normalized.contains("解析失败")
|| normalized.contains("无法解析")
|| normalized.contains("不存在")
|| normalized.contains("未找到")
|| normalized.contains("not exist")
|| normalized.contains("does not exist")
|| normalized.contains("not found")
@@ -1683,6 +1719,10 @@ public final class DamengAgent extends AbstractJdbcAgent {
if (!isDamengMetadataUnavailableError(error)) {
throw error;
}
String catalogDdl = catalogTableLikeDdl(schema, table);
if (catalogDdl != null) {
return catalogDdl;
}
try {
return super.getTableDdl(schema, table);
} catch (RuntimeException fallbackError) {
@@ -1692,6 +1732,58 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
}
/**
* DBMS_METADATA.GET_DDL('TABLE', ...) is also used for views by the generic
* table-DDL endpoint. DM8 can reject that call with an internal-index error,
* even though the view definition is available in ALL_VIEWS.
*/
private String catalogTableLikeDdl(String schema, String table) throws RuntimeException {
if (schemaMatchesConnectedUser(schema)) {
try {
String materializedQuery = readCatalogText(
"SELECT QUERY FROM USER_MVIEWS WHERE MVIEW_NAME = ?",
table
);
if (notBlank(materializedQuery)) {
return buildCatalogTableLikeDdl(schema, table, "MATERIALIZED VIEW", materializedQuery);
}
} catch (RuntimeException error) {
if (!isDamengMetadataUnavailableError(error)) {
throw error;
}
}
}
String viewText = readCatalogText(
"SELECT TEXT FROM ALL_VIEWS WHERE OWNER = ? AND VIEW_NAME = ?",
schema,
table
);
if (notBlank(viewText)) {
return buildCatalogTableLikeDdl(schema, table, "VIEW", viewText);
}
return null;
}
private String readCatalogText(String sql, String... params) {
return unchecked(() -> {
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
for (int i = 0; i < params.length; i++) {
stmt.setString(i + 1, params[i]);
}
try (ResultSet rs = stmt.executeQuery()) {
return rs.next() ? coalesce(readTextColumn(rs, 1)) : "";
}
}
});
}
private static String buildCatalogTableLikeDdl(String schema, String table, String objectType, String body) {
String normalizedBody = body.trim();
String statement = "CREATE " + objectType + " " + qualifiedName(schema, table) + " AS " + normalizedBody;
return ensureStatementTerminator(statement);
}
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
if (legacyJdbcMetadata) {
@@ -1719,6 +1811,13 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
}
}
if (pkColumns.isEmpty()) {
// ALL_CONS_COLUMNS/ALL_CONSTRAINTS return no rows — rather than an error — for
// accounts that cannot see the constraint dictionary, which is indistinguishable
// from a table that genuinely has no primary key. Re-ask through the driver,
// which reports primary keys to the table owner regardless of dictionary grants.
pkColumns.addAll(primaryKeyColumnsFromJdbcMetadata(schema, table));
}
Set<String> identityColumns = identityColumns(schema, table);
List<ColumnInfo> result = new ArrayList<>();
@@ -1780,6 +1879,23 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
private Set<String> identityColumns(String schema, String table) {
try {
return identityColumnsFromSystemCatalog(schema, table);
} catch (Exception systemCatalogError) {
// SYS.SYSCOLUMNS is not granted to PUBLIC, so ordinary (non-DBA) accounts fail here
// with a permission error. Reporting that as an empty set is indistinguishable from
// "this table has no identity column", and the data grid then sends the identity
// value on INSERT — which the server rejects, while leaving it out trips the NOT NULL
// check. Fall back to the table DDL, which the table owner can always read.
//
// Any failure triggers the fallback, not just a permission error: the server message
// is localized, so matching on its text would silently stop working on a non-Chinese
// server, and every other failure mode wants the same fallback anyway.
return identityColumnsFromTableDdl(schema, table);
}
}
private Set<String> identityColumnsFromSystemCatalog(String schema, String table) throws Exception {
Set<String> result = new java.util.HashSet<>();
String sql = """
SELECT /*+ PARALLEL(1) */ c.NAME
@@ -1799,8 +1915,177 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
}
}
}
return result;
}
private Set<String> identityColumnsFromTableDdl(String schema, String table) {
String ddl = null;
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, "TABLE");
stmt.setString(2, table);
stmt.setString(3, schema);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
ddl = readTextColumn(rs, 1);
}
}
} catch (Exception ignored) {
// Some Dameng versions or users do not expose SYS.SYSCOLUMNS.
// DBMS_METADATA is unavailable on some deployments (and on views); identity
// information stays unknown, which is the best this path can do.
}
return identityColumnsFromDdlText(ddl);
}
/**
* Reads the identity columns out of a {@code CREATE TABLE} statement, e.g.
* {@code "ID" INT IDENTITY(1, 1) NOT NULL}. Quoted names and string defaults are skipped so a
* column called {@code "IDENTITY"} or a default of {@code 'IDENTITY(1,1)'} is not misread.
*/
static Set<String> identityColumnsFromDdlText(String ddl) {
Set<String> result = new java.util.HashSet<>();
for (String definition : splitTableDdlDefinitions(ddl)) {
String trimmed = definition.trim();
if (trimmed.isEmpty() || trimmed.charAt(0) != '"') {
// Table-level constraint (PRIMARY KEY(...), CHECK(...)), not a column definition.
continue;
}
StringBuilder name = new StringBuilder();
int index = 1;
while (index < trimmed.length()) {
char ch = trimmed.charAt(index);
if (ch == '"') {
if (index + 1 < trimmed.length() && trimmed.charAt(index + 1) == '"') {
name.append('"');
index += 2;
continue;
}
break;
}
name.append(ch);
index++;
}
if (index >= trimmed.length() || name.length() == 0) {
continue;
}
String rest = stripSingleQuotedLiterals(trimmed.substring(index + 1));
if (DDL_IDENTITY_KEYWORD_PATTERN.matcher(rest).find()) {
result.add(name.toString());
}
}
return result;
}
/**
* Splits the column list of a {@code CREATE TABLE} statement on its top-level commas, so that
* {@code IDENTITY(1, 1)} and {@code VARCHAR(64)} stay attached to their column.
*/
private static List<String> splitTableDdlDefinitions(String ddl) {
List<String> definitions = new ArrayList<>();
if (ddl == null) {
return definitions;
}
StringBuilder current = new StringBuilder();
int depth = 0;
boolean inLiteral = false;
boolean inQuotedName = false;
for (int i = 0; i < ddl.length(); i++) {
char ch = ddl.charAt(i);
if (inLiteral) {
current.append(ch);
if (ch == '\'') {
inLiteral = false;
}
continue;
}
if (inQuotedName) {
current.append(ch);
if (ch == '"') {
inQuotedName = false;
}
continue;
}
if (ch == '\'') {
inLiteral = true;
current.append(ch);
continue;
}
if (ch == '"') {
inQuotedName = true;
current.append(ch);
continue;
}
if (ch == '(') {
depth++;
if (depth == 1) {
// Opening paren of the column list; drop the CREATE TABLE prefix before it.
current.setLength(0);
} else {
current.append(ch);
}
continue;
}
if (ch == ')') {
depth--;
if (depth == 0) {
addTableDdlDefinition(definitions, current);
break;
}
current.append(ch);
continue;
}
if (ch == ',' && depth == 1) {
addTableDdlDefinition(definitions, current);
continue;
}
if (depth > 0) {
current.append(ch);
}
}
return definitions;
}
private static void addTableDdlDefinition(List<String> definitions, StringBuilder current) {
String definition = current.toString().trim();
current.setLength(0);
if (!definition.isEmpty()) {
definitions.add(definition);
}
}
private static String stripSingleQuotedLiterals(String text) {
StringBuilder out = new StringBuilder(text.length());
boolean inLiteral = false;
for (int i = 0; i < text.length(); i++) {
char ch = text.charAt(i);
if (inLiteral) {
if (ch == '\'') {
inLiteral = false;
}
continue;
}
if (ch == '\'') {
inLiteral = true;
out.append(' ');
continue;
}
out.append(ch);
}
return out.toString();
}
private Set<String> primaryKeyColumnsFromJdbcMetadata(String schema, String table) {
Set<String> result = new java.util.HashSet<>();
try (ResultSet rs = requireConnected().getMetaData().getPrimaryKeys(null, schema, table)) {
while (rs.next()) {
String column = rs.getString("COLUMN_NAME");
if (notBlank(column)) {
result.add(column);
}
}
} catch (Exception ignored) {
// The table is then reported without a primary key, as before this fallback existed.
}
return result;
}
@@ -1818,16 +2103,24 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
return unchecked(() -> {
List<IndexInfo> result = new ArrayList<>();
// The LEFT JOIN also matches 'U': a UNIQUE constraint owns its backing index the same
// way a primary key does, and neither can be altered with index-level DDL (#7959).
// The constraint type therefore drives two flags — IS_PK (only 'P') and
// CONSTRAINT_BACKED ('P' or 'U'), the same split `independentIndexes` already uses to
// keep constraint-backed indexes out of the generated table DDL. A unique index
// created with CREATE UNIQUE INDEX has no ALL_CONSTRAINTS row, so it stays false and
// keeps the index-level DDL path.
String sql = """
SELECT /*+ PARALLEL(1) */ i.INDEX_NAME,
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS,
i.UNIQUENESS,
CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK,
i.INDEX_TYPE
i.INDEX_TYPE,
CASE WHEN c.CONSTRAINT_TYPE IN ('P', 'U') THEN 1 ELSE 0 END AS CONSTRAINT_BACKED
FROM ALL_INDEXES i
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER
LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.TABLE_OWNER = c.OWNER
AND c.CONSTRAINT_TYPE = 'P'
AND c.CONSTRAINT_TYPE IN ('P', 'U')
WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ?
GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE
ORDER BY i.INDEX_NAME
@@ -1837,7 +2130,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new IndexInfo(
IndexInfo index = new IndexInfo(
rs.getString(1),
splitNonEmpty(coalesce(rs.getString(2)), ","),
"UNIQUE".equals(rs.getString(3)),
@@ -1846,7 +2139,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
rs.getString(5),
null,
null
));
);
index.setConstraint_backed("1".equals(rs.getString(6)));
result.add(index);
}
}
}
@@ -2,6 +2,7 @@ package com.dbx.agent.dameng;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.ObjectSource;
@@ -51,6 +52,48 @@ class DamengAgentMetadataTest {
Assertions.assertTrue(metadataCalls.contains("getTables:DBX\\_TEST"), metadataCalls.toString());
}
@Test
void listDatabasesPrefersFullSchemaCatalogOverPrivilegeFilteredAllUsers() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, databaseListConnection(sqls, null, null));
List<DatabaseInfo> databases = agent.listDatabases();
Assertions.assertEquals(List.of("APP", "E2E_NORMAL", "SYSDBA"), databases.stream().map(DatabaseInfo::getName).toList());
Assertions.assertEquals(1, sqls.size(), String.join("\n", sqls));
Assertions.assertTrue(sqls.get(0).contains("SYS.SYSOBJECTS"), sqls.get(0));
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_USERS")), String.join("\n", sqls));
}
@Test
void listDatabasesFallsBackToAllUsersWithoutSysObjectsPrivilege() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, databaseListConnection(sqls, "no SYS.SYSOBJECTS privilege", null));
List<DatabaseInfo> databases = agent.listDatabases();
// 无 SOI 角色的普通用户:ALL_USERS 受自主访问控制过滤,只能看到自己,按权限返回属正确行为。
Assertions.assertEquals(List.of("E2E_NORMAL"), databases.stream().map(DatabaseInfo::getName).toList());
Assertions.assertEquals(2, sqls.size(), String.join("\n", sqls));
Assertions.assertTrue(sqls.get(0).contains("SYS.SYSOBJECTS"), sqls.get(0));
Assertions.assertTrue(sqls.get(1).contains("ALL_USERS"), sqls.get(1));
}
@Test
void listDatabasesPreservesCatalogErrorWhenAllUsersFails() {
DamengAgent agent = new DamengAgent();
SQLException usersError = new SQLException("ALL_USERS query failed");
TestSupport.setPrivateConnection(agent, databaseListConnection(new ArrayList<>(), "no SYS.SYSOBJECTS privilege", usersError));
RuntimeException error = Assertions.assertThrows(RuntimeException.class, agent::listDatabases);
Assertions.assertEquals("no SYS.SYSOBJECTS privilege", error.getCause().getMessage());
Assertions.assertEquals(1, error.getCause().getSuppressed().length);
Assertions.assertSame(usersError, error.getCause().getSuppressed()[0]);
}
@Test
void usesColumnCommentsMetadataQuery() {
DamengAgent agent = new DamengAgent();
@@ -81,6 +124,53 @@ class DamengAgentMetadataTest {
Assertions.assertTrue(indexesSql.startsWith("SELECT /*+ PARALLEL(1) */"), indexesSql);
}
@Test
void identifiesUniqueConstraintBackingIndexesInIndexMetadataQuery() {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
agent.listIndexes("APP", "USERS");
String indexesSql = JdbcMetadataSqlFake.statements.stream()
.filter(sql -> sql.contains("ALL_INDEXES") && sql.contains("ALL_CONSTRAINTS"))
.findFirst()
.orElseThrow();
Assertions.assertTrue(indexesSql.contains("AND c.CONSTRAINT_TYPE IN ('P', 'U')"), indexesSql);
Assertions.assertTrue(indexesSql.contains("AS CONSTRAINT_BACKED"), indexesSql);
Assertions.assertTrue(indexesSql.contains("CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK"), indexesSql);
}
@Test
void marksOnlyConstraintBackedIndexesAsConstraintBacked() {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, indexMetadataConnection(List.of(
// UNIQUE constraint (虚索引) / standalone CREATE UNIQUE INDEX (实索引) / plain index.
Arrays.asList("UX_USERS_EMAIL", "EMAIL", "UNIQUE", "0", "NORMAL", "1"),
Arrays.asList("UX_USERS_CODE", "CODE", "UNIQUE", "0", "NORMAL", "0"),
Arrays.asList("IDX_USERS_NAME", "NAME", "NONUNIQUE", "0", "NORMAL", "0"),
Arrays.asList("PK_USERS", "ID", "UNIQUE", "1", "NORMAL", "1")
)));
List<IndexInfo> indexes = agent.listIndexes("APP", "USERS");
Assertions.assertEquals(
List.of("UX_USERS_EMAIL", "UX_USERS_CODE", "IDX_USERS_NAME", "PK_USERS"),
indexes.stream().map(IndexInfo::getName).toList()
);
Assertions.assertEquals(
List.of(true, false, false, true),
indexes.stream().map(IndexInfo::getConstraint_backed).toList()
);
Assertions.assertEquals(
List.of(false, false, false, true),
indexes.stream().map(IndexInfo::getIs_primary).toList()
);
Assertions.assertEquals(
List.of(true, true, false, true),
indexes.stream().map(IndexInfo::getIs_unique).toList()
);
}
@Test
void usesTableCommentsMetadataQuery() {
DamengAgent agent = new DamengAgent();
@@ -518,6 +608,96 @@ class DamengAgentMetadataTest {
Assertions.assertTrue(source.getSource().contains("CREATE MATERIALIZED VIEW"), source.getSource());
}
@Test
void fallsBackToViewCatalogWhenDbmsMetadataReportsInternalIndexError() {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
String sql = (String) args[0];
if (sql.contains("DBMS_METADATA.GET_DDL")) {
return proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
if ("executeQuery".equals(statementMethod.getName())) {
throw new SQLException("未找到对象或不允许查询系统定义的内部索引");
}
if ("close".equals(statementMethod.getName())) {
return null;
}
return defaultValue(statementMethod.getReturnType());
});
}
if (sql.contains("ALL_VIEWS")) {
return metadataStatement(List.of(List.of("SELECT 1 AS ID FROM DUAL")));
}
}
if ("close".equals(method.getName())) {
return null;
}
if ("isClosed".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
}));
ObjectSource source = agent.getObjectSource("APP", "ACTIVE_VIEW", "VIEW");
Assertions.assertTrue(source.getSource().contains("SELECT 1 AS ID FROM DUAL"), source.getSource());
Assertions.assertTrue(source.getSource().contains("系统字典视图"), source.getSource());
}
@Test
void tableDdlFallsBackToViewAndMaterializedViewCatalogDefinitions() {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
String sql = (String) args[0];
if (sql.contains("DBMS_METADATA.GET_DDL")) {
return failingMetadataStatement(new SQLException("未找到对象或不允许查询系统定义的内部索引"));
}
if (sql.contains("USER_MVIEWS")) {
String[] boundTable = {null};
return proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
if ("setString".equals(statementMethod.getName())) {
boundTable[0] = (String) statementArgs[1];
return null;
}
if ("executeQuery".equals(statementMethod.getName())) {
return metadataResultSet("ISSUE_3418_MV".equals(boundTable[0])
? List.of(List.of("SELECT 1 AS ID FROM DUAL"))
: List.of());
}
if ("close".equals(statementMethod.getName())) {
return null;
}
return defaultValue(statementMethod.getReturnType());
});
}
if (sql.contains("ALL_VIEWS")) {
return metadataStatement(List.of(List.of("SELECT 2 AS ID FROM DUAL")));
}
}
if ("close".equals(method.getName())) {
return null;
}
if ("isClosed".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
}));
setConnectedUsername(agent, "APP");
String viewDdl = agent.getTableDdl("APP", "ACTIVE_VIEW");
String materializedViewDdl = agent.getTableDdl("APP", "ISSUE_3418_MV");
Assertions.assertEquals(
"CREATE VIEW \"APP\".\"ACTIVE_VIEW\" AS SELECT 2 AS ID FROM DUAL;",
viewDdl
);
Assertions.assertEquals(
"CREATE MATERIALIZED VIEW \"APP\".\"ISSUE_3418_MV\" AS SELECT 1 AS ID FROM DUAL;",
materializedViewDdl
);
}
@Test
void readsViewSourceWithDbmsMetadataType() {
DamengAgent agent = new DamengAgent();
@@ -1028,6 +1208,148 @@ class DamengAgentMetadataTest {
Assertions.assertEquals("identity", columns.get(0).getExtra());
}
@Test
void readsIdentityColumnsFromDdlText() {
Assertions.assertEquals(
java.util.Set.of("ID"),
DamengAgent.identityColumnsFromDdlText(
"CREATE TABLE \"DBX_TEST\".\"T\"\n(\n\"ID\" INT IDENTITY(1, 1) NOT NULL,\n"
+ "\"NAME\" VARCHAR(64),\nNOT CLUSTER PRIMARY KEY(\"ID\")) STORAGE(ON \"MAIN\") ;"
)
);
}
@Test
void ignoresIdentityLookalikesInDdlText() {
// A column *named* IDENTITY whose default merely contains the word must not be reported,
// and neither must a table-level constraint that mentions it.
Assertions.assertEquals(
java.util.Set.of(),
DamengAgent.identityColumnsFromDdlText(
"CREATE TABLE \"DBX_TEST\".\"T\"\n(\n\"IDENTITY\" VARCHAR(32) DEFAULT 'IDENTITY(1,1)',\n"
+ "\"PLAIN\" INT,\n\"IDENTITY_KIND\" VARCHAR(8)) STORAGE(ON \"MAIN\") ;"
)
);
}
@Test
void readsEveryIdentityColumnFromDdlText() {
Assertions.assertEquals(
java.util.Set.of("A", "C"),
DamengAgent.identityColumnsFromDdlText(
"CREATE TABLE \"S\".\"T\"\n(\n\"A\" BIGINT IDENTITY(1, 1),\n\"B\" VARCHAR(9),\n"
+ "\"C\" INT identity(100, 5) NOT NULL,\nCLUSTER PRIMARY KEY(\"A\", \"C\")) ;"
)
);
}
@Test
void toleratesDdlTextThatCannotBeParsed() {
Assertions.assertEquals(java.util.Set.of(), DamengAgent.identityColumnsFromDdlText(null));
Assertions.assertEquals(java.util.Set.of(), DamengAgent.identityColumnsFromDdlText(""));
Assertions.assertEquals(java.util.Set.of(), DamengAgent.identityColumnsFromDdlText("CREATE TABLE \"S\".\"T\""));
}
@Test
void fallsBackToTableDdlWhenSysColumnsIsNotReadable() {
// Ordinary Dameng accounts have no SELECT on SYS.SYSCOLUMNS. Without the fallback the
// identity column looks like a plain NOT NULL column and the grid sends its value.
List<String> sqls = new ArrayList<>();
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, restrictedAccountConnection(sqls, List.of()));
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
Assertions.assertEquals(1, columns.size());
Assertions.assertEquals("identity", columns.get(0).getExtra());
Assertions.assertTrue(
sqls.stream().anyMatch(sql -> sql.contains("DBMS_METADATA.GET_DDL")),
sqls.toString()
);
}
@Test
void fallsBackToJdbcPrimaryKeysWhenConstraintDictionaryIsEmpty() {
// ALL_CONS_COLUMNS yields no rows (not an error) for those same accounts.
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, restrictedAccountConnection(new ArrayList<>(), List.of()));
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
Assertions.assertEquals(1, columns.size());
Assertions.assertTrue(columns.get(0).getIs_primary_key());
}
@Test
void keepsPrimaryKeysFromConstraintDictionaryWhenReadable() {
// The JDBC fallback must not displace the dictionary answer when that one works.
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, restrictedAccountConnection(new ArrayList<>(), List.of(List.of("ID"))));
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
Assertions.assertTrue(columns.get(0).getIs_primary_key());
}
@Test
void doesNotReadTableDdlWhenSysColumnsIsReadableButEmpty() {
// A readable-but-empty SYS.SYSCOLUMNS means "no identity column" and must stay cheap.
List<String> sqls = new ArrayList<>();
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, metadataConnection(null, null, false, List.of(), sqls));
agent.getColumns("APP", "USERS");
Assertions.assertTrue(
sqls.stream().noneMatch(sql -> sql.contains("DBMS_METADATA.GET_DDL")),
sqls.toString()
);
}
/**
* A connection shaped like a non-DBA Dameng account: SYS.SYSCOLUMNS raises a permission error
* and the constraint dictionary answers with no rows, while the table DDL and the driver's own
* primary-key metadata both still work.
*/
private static Connection restrictedAccountConnection(List<String> sqls, List<List<Object>> constraintRows) {
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
if ("getPrimaryKeys".equals(method.getName())) {
return metadataResultSet(List.of(List.of("ID")));
}
return defaultValue(method.getReturnType());
});
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
if ("getMetaData".equals(name)) {
return metadata;
}
if ("prepareStatement".equals(name)) {
String sql = (String) args[0];
sqls.add(sql);
if (sql.contains("SYS.SYSCOLUMNS")) {
return failingMetadataStatement("没有[SYSCOLUMNS]对象的查询权限");
}
if (sql.contains("DBMS_METADATA.GET_DDL")) {
return metadataStatement(List.of(List.of(
"CREATE TABLE \"APP\".\"USERS\"\n(\n\"ID\" INT IDENTITY(1, 1) NOT NULL)"
+ " STORAGE(ON \"MAIN\", CLUSTERBTR) ;"
)));
}
if (sql.contains("ALL_CONS_COLUMNS")) {
return metadataStatement(constraintRows);
}
if (sql.contains("ALL_TAB_COLUMNS")) {
return metadataStatement(defaultColumnMetadataRows("id comment"));
}
return metadataStatement(List.of());
}
if ("close".equals(name) || "isClosed".equals(name)) {
return "isClosed".equals(name) ? Boolean.FALSE : null;
}
return defaultValue(method.getReturnType());
});
}
@Test
void preservesCharacterLengthUnitsFromMetadata() {
DamengAgent agent = new DamengAgent();
@@ -1391,12 +1713,22 @@ class DamengAgentMetadataTest {
return metadataStatement(rows);
}
if (sql.contains("USER_MVIEWS")) {
if (sql.contains("SELECT QUERY")) {
return metadataStatement(
includeMaterializedView
? List.of(List.of("SELECT 1 AS ID FROM DUAL"))
: List.of()
);
}
return metadataStatement(
includeMaterializedView
? List.of(List.of("USER_SUMMARY_MV"))
: List.of()
);
}
if (sql.contains("ALL_VIEWS")) {
return metadataStatement(List.of());
}
if (sql.contains("ALL_COL_COMMENTS") && !sql.contains("ALL_TAB_COLUMNS")) {
return metadataStatement(List.of());
}
@@ -1428,6 +1760,24 @@ class DamengAgentMetadataTest {
));
}
// Serves every metadata query with the same positional rows; the index metadata query is
// the only one these tests run against it.
private static Connection indexMetadataConnection(List<List<Object>> rows) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
if ("prepareStatement".equals(name)) {
return metadataStatement(rows);
}
if ("close".equals(name)) {
return null;
}
if ("isClosed".equals(name)) {
return false;
}
return defaultValue(method.getReturnType());
});
}
private static List<Object> indexRow(String name, String columns, String uniqueness, String indexType) {
return List.of(name, columns, uniqueness, indexType);
}
@@ -1824,6 +2174,43 @@ class DamengAgentMetadataTest {
});
}
private static Connection databaseListConnection(
List<String> sqls,
String catalogError,
SQLException usersError
) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
if ("prepareStatement".equals(name)) {
String sql = (String) args[0];
sqls.add(sql);
if (sql.contains("SYS.SYSOBJECTS")) {
if (catalogError != null) {
return failingMetadataStatement(catalogError);
}
return metadataStatement(List.of(List.of("APP"), List.of("E2E_NORMAL"), List.of("SYSDBA")));
}
if (sql.contains("ALL_USERS")) {
if (usersError != null) {
return failingMetadataStatement(usersError);
}
return metadataStatement(List.of(List.of("E2E_NORMAL")));
}
throw new AssertionError("Unexpected SQL: " + sql);
}
if ("getMetaData".equals(name)) {
throw new AssertionError("listDatabases must not fall back to JDBC metadata getSchemas");
}
if ("close".equals(name)) {
return null;
}
if ("isClosed".equals(name)) {
return false;
}
return defaultValue(method.getReturnType());
});
}
private static Connection schemaConnection(List<String> params) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
@@ -3,6 +3,7 @@ package com.dbx.agent.dameng;
import com.dbx.agent.DatabaseAgent;
import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.QueryPageOptions;
import com.dbx.agent.QueryPageResult;
@@ -18,10 +19,12 @@ import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.net.SocketTimeoutException;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.SQLTransientConnectionException;
import java.sql.Statement;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
@@ -60,6 +63,24 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
assertEquals(List.of("executeQuery"), JdbcAgentFake.calls);
}
@Test
void readsVarchar2AsTextWhenDriverReportsNumericSqlType() throws Exception {
DamengAgent agent = new DamengAgent();
ResultSet resultSet = (ResultSet) Proxy.newProxyInstance(
ResultSet.class.getClassLoader(),
new Class<?>[] {ResultSet.class},
(proxy, method, args) -> switch (method.getName()) {
case "getString" -> "001";
case "wasNull" -> false;
default -> defaultValue(method.getReturnType());
}
);
JdbcExecutor.ColumnAwareResultValueReader reader = (JdbcExecutor.ColumnAwareResultValueReader) agent.resultValueReader();
assertEquals("001", reader.read(resultSet, 1, Types.NUMERIC, "VARCHAR2"));
}
@Test
void physicalConnectionsDoNotEnableDbmsOutput() throws Exception {
List<String> executedSql = new ArrayList<>();
@@ -19,9 +19,11 @@ import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.Set;
public final class Db2Agent extends AbstractJdbcAgent {
@@ -46,6 +48,13 @@ public final class Db2Agent extends AbstractJdbcAgent {
return buildUrl(params);
}
@Override
protected Properties buildConnectionProperties(ConnectParams params) {
Properties properties = super.buildConnectionProperties(params);
properties.setProperty("db2.jcc.charsetDecoderEncoder", "3");
return properties;
}
@Override
public List<DatabaseInfo> listDatabases() {
return unchecked(() -> {
@@ -218,7 +227,11 @@ public final class Db2Agent extends AbstractJdbcAgent {
@Override
public ObjectSource getObjectSource(String schema, String name, String objectType) {
return unchecked(() -> {
String sql = "SELECT TEXT FROM SYSCAT.ROUTINES WHERE ROUTINESCHEMA = ? AND ROUTINENAME = ?";
String normalizedType = objectType == null ? "" : objectType.trim().toUpperCase(Locale.ROOT);
// View definitions live in SYSCAT.VIEWS; SYSCAT.ROUTINES only covers procedures and functions.
String sql = "VIEW".equals(normalizedType)
? "SELECT TEXT FROM SYSCAT.VIEWS WHERE VIEWSCHEMA = ? AND VIEWNAME = ?"
: "SELECT TEXT FROM SYSCAT.ROUTINES WHERE ROUTINESCHEMA = ? AND ROUTINENAME = ?";
String source;
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
@@ -385,6 +398,14 @@ public final class Db2Agent extends AbstractJdbcAgent {
@Override
protected Object resultValue(ResultSet rs, int index, int sqlType) {
return unchecked(() -> {
if (sqlType == Types.CLOB || sqlType == Types.NCLOB) {
String value = rs.getString(index);
return rs.wasNull() ? null : value;
}
if (sqlType == Types.BLOB || sqlType == Types.BINARY || sqlType == Types.VARBINARY || sqlType == Types.LONGVARBINARY) {
byte[] value = rs.getBytes(index);
return rs.wasNull() ? null : JdbcExecutor.bytesToHex(value);
}
Object value = rs.getObject(index);
return rs.wasNull() ? null : value == null ? null : value.toString();
});
@@ -4,6 +4,7 @@ import com.dbx.agent.DatabaseAgent;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.ObjectSource;
import com.dbx.agent.TableInfo;
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
import com.dbx.agent.test.JdbcMetadataSqlFake;
@@ -17,6 +18,7 @@ import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.sql.Types;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
@@ -49,6 +51,47 @@ class Db2AgentTest extends JdbcFakeExecutionBehaviorTest {
assertEquals("jdbc:db2://db.example.com:50000/SAMPLE:sslConnection=true;", Db2Agent.buildUrl(params));
}
@Test
void readsClobValuesAsTextInsteadOfVendorObjectNames() {
Db2Agent agent = new Db2Agent();
ResultSet resultSet = proxy(ResultSet.class, new MethodHandler() {
@Override
public Object handle(Method method, Object[] args) {
if ("getString".equals(method.getName())) {
return "long text value";
}
if ("wasNull".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
}
});
assertEquals("long text value", agent.resultValue(resultSet, 1, Types.CLOB));
}
@Test
void readsBlobValuesAsHexInsteadOfVendorObjectNames() {
Db2Agent agent = new Db2Agent();
ResultSet resultSet = proxy(ResultSet.class, new MethodHandler() {
@Override
public Object handle(Method method, Object[] args) {
if ("getBytes".equals(method.getName())) {
return new byte[] {(byte) 0x89, 0x50, 0x4e};
}
if ("wasNull".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
}
});
assertEquals("0x89504e", agent.resultValue(resultSet, 1, Types.BLOB));
assertEquals("0x89504e", agent.resultValue(resultSet, 1, Types.BINARY));
assertEquals("0x89504e", agent.resultValue(resultSet, 1, Types.VARBINARY));
assertEquals("0x89504e", agent.resultValue(resultSet, 1, Types.LONGVARBINARY));
}
@Test
void listsAllCatalogSchemasWithoutOwnerTypeFiltering() {
Db2Agent agent = new Db2Agent();
@@ -126,6 +169,69 @@ class Db2AgentTest extends JdbcFakeExecutionBehaviorTest {
assertEquals("Test view comment", objects.get(0).getComment());
}
@Test
void viewObjectSourceReadsDefinitionFromSyscatViews() {
Db2Agent agent = new Db2Agent();
AtomicReference<String> executedSql = new AtomicReference<>();
TestSupport.setPrivateConnection(agent, preparedConnection(
executedSql,
row("TEXT", "CREATE VIEW APP.EMP_VIEW AS SELECT ID, NAME FROM APP.EMPLOYEE")
));
ObjectSource source = agent.getObjectSource("APP", "EMP_VIEW", "VIEW");
assertEquals("CREATE VIEW APP.EMP_VIEW AS SELECT ID, NAME FROM APP.EMPLOYEE", source.getSource());
assertEquals("SELECT TEXT FROM SYSCAT.VIEWS WHERE VIEWSCHEMA = ? AND VIEWNAME = ?", executedSql.get());
}
@Test
void routineObjectSourceStillReadsSyscatRoutines() {
Db2Agent agent = new Db2Agent();
AtomicReference<String> executedSql = new AtomicReference<>();
TestSupport.setPrivateConnection(agent, preparedConnection(
executedSql,
row("TEXT", "BEGIN SELECT 1 FROM SYSIBM.SYSDUMMY1; END")
));
ObjectSource source = agent.getObjectSource("APP", "MY_PROC", "PROCEDURE");
assertEquals("BEGIN SELECT 1 FROM SYSIBM.SYSDUMMY1; END", source.getSource());
assertEquals("SELECT TEXT FROM SYSCAT.ROUTINES WHERE ROUTINESCHEMA = ? AND ROUTINENAME = ?", executedSql.get());
}
private static Connection preparedConnection(AtomicReference<String> executedSql, Map<String, Object>... rows) {
final ResultSet resultSet = rows(rows);
return proxy(Connection.class, new MethodHandler() {
@Override
public Object handle(Method method, Object[] args) {
String name = method.getName();
if ("prepareStatement".equals(name)) {
executedSql.set(String.valueOf(args[0]));
return proxy(PreparedStatement.class, new MethodHandler() {
@Override
public Object handle(Method stmtMethod, Object[] stmtArgs) {
String m = stmtMethod.getName();
if ("setString".equals(m) || "setObject".equals(m) || "setInt".equals(m)) {
return null;
}
if ("executeQuery".equals(m)) {
return resultSet;
}
if ("close".equals(m)) {
return null;
}
return defaultValue(stmtMethod.getReturnType());
}
});
}
if ("isClosed".equals(name)) {
return false;
}
return defaultValue(method.getReturnType());
}
});
}
private static Connection preparedConnection(Map<String, Object>... rows) {
final ResultSet resultSet = rows(rows);
return proxy(Connection.class, new MethodHandler() {
+89 -16
View File
@@ -954,6 +954,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc16"
version = "0.4.0"
@@ -1154,6 +1169,7 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
@@ -1242,14 +1258,16 @@ dependencies = [
"calamine",
"cbc 0.1.2",
"chrono",
"crc",
"csv",
"dbx-sqlite-worker",
"deadpool-postgres",
"ed25519-dalek 2.2.0",
"encoding_rs",
"flate2",
"fs2",
"futures",
"httpdate",
"iana-time-zone",
"insta",
"json5",
"jsonwebtoken",
@@ -1275,10 +1293,12 @@ dependencies = [
"rust_decimal",
"rustls 0.23.42",
"rustls-pemfile 2.2.0",
"semver",
"serde",
"serde_json",
"serde_yaml",
"serde_yaml_ng",
"sha1 0.10.7",
"sha2 0.10.9",
"sqlparser",
"ssfmt",
@@ -1551,19 +1571,43 @@ dependencies = [
"digest 0.11.3",
"elliptic-curve",
"rfc6979",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"zeroize",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8 0.10.2",
"signature 2.2.0",
]
[[package]]
name = "ed25519"
version = "3.0.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
dependencies = [
"pkcs8",
"signature",
"pkcs8 0.11.0-rc.11",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek 4.1.3",
"ed25519 2.2.3",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]]
@@ -1573,11 +1617,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b"
dependencies = [
"curve25519-dalek 5.0.0-pre.6",
"ed25519",
"ed25519 3.0.0-rc.4",
"rand_core 0.10.1",
"serde",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"subtle",
"zeroize",
]
@@ -1602,7 +1646,7 @@ dependencies = [
"hybrid-array",
"once_cell",
"pem-rfc7468 1.0.0",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"rand_core 0.10.1",
"rustcrypto-ff",
"rustcrypto-group",
@@ -1799,6 +1843,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -2321,7 +2375,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.57.0",
"windows-core 0.62.2",
]
[[package]]
@@ -2526,7 +2580,7 @@ dependencies = [
"bcrypt-pbkdf",
"crypto-bigint",
"ecdsa",
"ed25519-dalek",
"ed25519-dalek 3.0.0-pre.6",
"hex",
"hmac 0.13.0",
"num-bigint-dig",
@@ -2538,7 +2592,7 @@ dependencies = [
"sec1",
"sha1 0.11.0",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"ssh-cipher",
"ssh-encoding",
"subtle",
@@ -3729,6 +3783,16 @@ dependencies = [
"spki 0.8.0-rc.4",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der 0.7.10",
"spki 0.7.3",
]
[[package]]
name = "pkcs8"
version = "0.11.0-rc.11"
@@ -4399,10 +4463,10 @@ dependencies = [
"crypto-primes",
"digest 0.11.3",
"pkcs1",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"rand_core 0.10.1",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"zeroize",
]
@@ -4448,7 +4512,7 @@ dependencies = [
"der 0.8.1",
"digest 0.10.7",
"ecdsa",
"ed25519-dalek",
"ed25519-dalek 3.0.0-pre.6",
"elliptic-curve",
"enum_dispatch",
"flate2",
@@ -4477,7 +4541,7 @@ dependencies = [
"pbkdf2 0.13.0",
"pkcs1",
"pkcs5",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"polyval 0.7.3",
"rand 0.10.2",
"rand_core 0.10.1",
@@ -4492,7 +4556,7 @@ dependencies = [
"sha2 0.10.9",
"sha2 0.11.0",
"sha3",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"ssh-encoding",
"subtle",
@@ -5063,6 +5127,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "signature"
version = "3.0.0"
@@ -5415,7 +5488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
+1
View File
@@ -21,6 +21,7 @@ const (
legacyAgentSessionID = "__legacy__"
maxAgentSessions = 256
rpcTimeoutSeconds = 30
statusProbeSeconds = 10
readAccessCacheTTL = 15 * time.Second
)
+1 -1
View File
@@ -72,7 +72,7 @@ func (s *etcdSession) status(params map[string]json.RawMessage) (any, error) {
statusRequests[index] = channel
go func(endpoint string, channel chan *statusResult) {
started := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), statusProbeSeconds*time.Second)
response, err := client.Maintenance.Status(ctx, endpoint)
cancel()
channel <- &statusResult{response: response, err: err, started: started}
+1 -1
View File
@@ -1,5 +1,5 @@
dependencies {
implementation 'com.gbasedbt:jdbc:3.6.3.2'
implementation 'com.gbasedbt:jdbc:3.6.5.21'
}
tasks.named('shadowJar') {
Binary file not shown.
@@ -15,6 +15,7 @@ import com.dbx.agent.TableInfo;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@@ -25,10 +26,31 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class Gbase8sAgent extends ConfiguredJdbcAgent {
private static final long METADATA_CACHE_TTL_MILLIS = 10_000L;
// Whitelist for a JDBC locale value (language_territory.codeset). The value is concatenated
// into the connection URL, so reject anything outside ordinary identifier characters to guard
// against a malformed or hostile server-reported collation.
private static final Pattern SAFE_DATABASE_LOCALE = Pattern.compile("[A-Za-z0-9_.\\-]{1,64}");
// Upper bound on the sysmaster locale-probe connection so a hung probe cannot stall connect.
private static final int LOCALE_PROBE_LOGIN_TIMEOUT_SECS = 10;
/**
* Leading directive comment emitted by the Rust admin-SQL layer for GBase 8s / Informix
* "Create Database" when the user picks a character set, e.g.
* {@code -- DBX_DB_LOCALE=zh_CN.utf8\nCREATE DATABASE mydb;}. Informix cannot express a new
* database's codeset in {@code CREATE DATABASE} — it inherits the creating session's DB_LOCALE —
* so the chosen locale is carried out-of-band and honored here by opening a sysmaster session
* pinned to that DB_LOCALE.
*/
private static final Pattern CREATE_DATABASE_LOCALE_DIRECTIVE = Pattern.compile(
"^\\s*--\\s*DBX_DB_LOCALE\\s*=\\s*(\\S+)\\s*\\r?\\n(.*)$", Pattern.DOTALL);
public static final JdbcAgentProfile GBASE8S_PROFILE = new JdbcAgentProfile(
"com.gbasedbt.jdbc.Driver",
"jdbc:gbasedbt-sqli://{host}:{port}/{database}:GBASEDBTSERVER=gbase8s",
@@ -47,6 +69,7 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
private long tableCacheTimeMillis;
private List<TableInfo> tableCache = Collections.emptyList();
private ConnectParams databaseListParams;
private final Map<String, String> collateByDatabase = new ConcurrentHashMap<>();
public Gbase8sAgent() {
super(GBASE8S_PROFILE);
@@ -121,6 +144,113 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
return buildUrl(params);
}
@Override
protected Connection openConnection(ConnectParams params) throws Exception {
return super.openConnection(withResolvedDatabaseLocale(params));
}
/**
* Return a copy of {@code params} whose {@code CLIENT_LOCALE}/{@code DB_LOCALE} match the real
* collation of the target database, so one connection profile can open every database on the
* instance regardless of locale. Falls back to the configured parameters whenever the collation
* cannot be resolved (custom connection string, catalog unavailable, or lookup error).
*/
private ConnectParams withResolvedDatabaseLocale(ConnectParams params) {
if (!params.getConnection_string().trim().isEmpty()) {
return params;
}
String database = params.getDatabase().trim().isEmpty() ? "sysmaster" : params.getDatabase().trim();
String collate = resolveCollate(params, database);
if (collate.isEmpty() || collate.equalsIgnoreCase(currentLocaleOf(params))) {
return params;
}
ConnectParams localized = new ConnectParams(
params.getHost(),
params.getPort(),
params.getDatabase(),
params.getUsername(),
params.getPassword(),
overrideLocaleParams(params.getUrl_params(), collate),
params.getConnection_string(),
params.isMysql_compat_mode(),
params.getJdbc_driver_class(),
params.getJdbc_driver_paths()
);
localized.setGbase_server(getGbaseServer(params));
return localized;
}
private String resolveCollate(ConnectParams params, String database) {
String key = params.getHost() + "|" + params.getPort() + "|" + getGbaseServer(params)
+ "|" + database.toLowerCase(Locale.ROOT);
String cached = collateByDatabase.get(key);
if (cached != null) {
return cached;
}
String collate = "";
try (Connection connection = super.openConnection(withProbeLoginTimeout(paramsForDatabase(params, "sysmaster")));
PreparedStatement stmt = connection.prepareStatement(
"SELECT dbs_collate FROM sysmaster:sysdbslocale WHERE LOWER(dbs_dbsname) = ?")) {
stmt.setString(1, database.toLowerCase(Locale.ROOT));
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
collate = trim(rs.getString(1));
}
}
} catch (Exception ignored) {
// Keep the configured locale when the collation catalog is unreachable.
}
// Only cache a successful lookup. Caching an empty result would pin a transient failure —
// or a database that was just created and is not yet listed in sysdbslocale — to the stale
// fallback until disconnect, so leave failures uncached to retry on the next connect.
if (!collate.isEmpty()) {
collateByDatabase.put(key, collate);
}
return collate;
}
/**
* Return a copy of {@code params} with a bounded {@code LOGIN_TIMEOUT} appended to its JDBC
* parameters (unless one is already configured), so the sysmaster locale probe can never hang
* the connect path.
*/
private static ConnectParams withProbeLoginTimeout(ConnectParams params) {
String urlParams = params.getUrl_params() == null ? "" : params.getUrl_params();
if (containsIgnoreCase(urlParams, "LOGIN_TIMEOUT=")) {
return params;
}
String joined = urlParams.isEmpty()
? "LOGIN_TIMEOUT=" + LOCALE_PROBE_LOGIN_TIMEOUT_SECS
: urlParams + ";LOGIN_TIMEOUT=" + LOCALE_PROBE_LOGIN_TIMEOUT_SECS;
ConnectParams copy = new ConnectParams(
params.getHost(),
params.getPort(),
params.getDatabase(),
params.getUsername(),
params.getPassword(),
joined,
params.getConnection_string(),
params.isMysql_compat_mode(),
params.getJdbc_driver_class(),
params.getJdbc_driver_paths()
);
copy.setGbase_server(params.getGbase_server());
return copy;
}
private static String currentLocaleOf(ConnectParams params) {
for (String segment : params.getUrl_params().split(";")) {
int equals = segment.indexOf('=');
if (equals < 0) {
continue;
}
if (segment.substring(0, equals).trim().equalsIgnoreCase("DB_LOCALE")) {
return segment.substring(equals + 1).trim();
}
}
return "";
}
@Override
protected void afterConnect(ConnectParams params, Connection connection) {
super.afterConnect(params, connection);
@@ -131,11 +261,18 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
@Override
protected void afterDisconnect() {
databaseListParams = null;
collateByDatabase.clear();
clearMetadataCache();
}
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
CreateDatabaseLocaleDirective directive = parseCreateDatabaseLocaleDirective(sql);
if (directive != null) {
runCreateDatabaseWithLocale(directive.statement(), directive.locale());
clearMetadataCache();
return new QueryResult(Collections.emptyList(), Collections.emptyList(), 0, 0);
}
QueryResult result = super.executeQuery(sql, schema, options);
if (mayChangeMetadata(sql)) {
clearMetadataCache();
@@ -143,6 +280,71 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
return result;
}
/**
* A {@code CREATE DATABASE} statement plus the DB_LOCALE carried by its leading directive
* comment. Package visible so the routing decision is directly testable without a live
* connection.
*/
record CreateDatabaseLocaleDirective(String locale, String statement) {
}
/**
* Parse {@code sql} as a directive-prefixed {@code CREATE DATABASE} (as emitted by the Rust
* admin-SQL layer), returning {@code null} for anything else so it falls through to the
* normal query path.
*/
static CreateDatabaseLocaleDirective parseCreateDatabaseLocaleDirective(String sql) {
Matcher directive = sql == null ? null : CREATE_DATABASE_LOCALE_DIRECTIVE.matcher(sql);
if (directive == null || !directive.matches()) {
return null;
}
String statement = directive.group(2).trim();
if (!statement.regionMatches(true, 0, "CREATE DATABASE", 0, "CREATE DATABASE".length())) {
return null;
}
return new CreateDatabaseLocaleDirective(directive.group(1).trim(), statement);
}
/**
* Run a {@code CREATE DATABASE} statement on a sysmaster session whose {@code DB_LOCALE} is
* pinned to {@code locale}, so the freshly created database inherits that codeset (Informix has
* no charset clause in {@code CREATE DATABASE}). Falls back to the configured locale when
* {@code locale} is blank or unsafe.
*/
private void runCreateDatabaseWithLocale(String statement, String locale) {
ConnectParams base = databaseListParams;
if (base == null) {
throw new IllegalStateException("Not connected");
}
ConnectParams pinned = base;
if (locale != null && SAFE_DATABASE_LOCALE.matcher(locale).matches()) {
pinned = new ConnectParams(
base.getHost(),
base.getPort(),
base.getDatabase(),
base.getUsername(),
base.getPassword(),
overrideLocaleParams(base.getUrl_params(), locale),
base.getConnection_string(),
base.isMysql_compat_mode(),
base.getJdbc_driver_class(),
base.getJdbc_driver_paths()
);
pinned.setGbase_server(base.getGbase_server());
}
try (Connection connection = super.openConnection(pinned);
Statement stmt = connection.createStatement()) {
stmt.execute(stripTrailingSemicolon(statement));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static String stripTrailingSemicolon(String sql) {
String trimmed = sql.trim();
return trimmed.endsWith(";") ? trimmed.substring(0, trimmed.length() - 1).trim() : trimmed;
}
@Override
public List<DatabaseInfo> listDatabases() {
List<DatabaseInfo> cached = cachedDatabases();
@@ -459,6 +661,62 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
return value.toLowerCase(Locale.ROOT).contains(needle.toLowerCase(Locale.ROOT));
}
/**
* Force {@code CLIENT_LOCALE} and {@code DB_LOCALE} in a {@code ;}-separated JDBC parameter
* string to {@code collate}, leaving every other parameter (and their order) untouched.
*
* <p>A GBase 8s / Informix instance can host databases with different locales, and the JDBC
* driver rejects a connection whose {@code DB_LOCALE} does not match the target database's real
* locale with "database locale information mismatch" (数据库地点信息不匹配). A single connection
* profile only pins one locale, so reusing it to open a differently-locale database fails. The
* agent therefore resolves each database's real collation from {@code sysmaster.sysdbslocale}
* and rewrites both locale parameters to it, so every database under the connection opens with
* its own locale. Setting {@code CLIENT_LOCALE} to the same value keeps client and database
* codesets identical, which avoids the cross-codeset conversion the bundled client cannot load.
*/
static String overrideLocaleParams(String jdbcParams, String collate) {
if (collate == null || collate.trim().isEmpty()) {
return jdbcParams;
}
String value = collate.trim();
// The value is concatenated into the JDBC URL; only accept an ordinary locale token so a
// malformed or hostile server-reported collation cannot inject extra parameters.
if (!SAFE_DATABASE_LOCALE.matcher(value).matches()) {
return jdbcParams;
}
List<String> segments = new ArrayList<>();
if (jdbcParams != null && !jdbcParams.isEmpty()) {
for (String segment : jdbcParams.split(";")) {
int equals = segment.indexOf('=');
String key = (equals >= 0 ? segment.substring(0, equals) : segment).trim();
if (key.equalsIgnoreCase("CLIENT_LOCALE")) {
segments.add("CLIENT_LOCALE=" + value);
} else if (key.equalsIgnoreCase("DB_LOCALE")) {
segments.add("DB_LOCALE=" + value);
} else {
segments.add(segment);
}
}
}
boolean hasClient = false;
boolean hasDb = false;
for (String segment : segments) {
String key = segment.substring(0, Math.max(segment.indexOf('='), 0)).trim();
if (key.equalsIgnoreCase("CLIENT_LOCALE")) {
hasClient = true;
} else if (key.equalsIgnoreCase("DB_LOCALE")) {
hasDb = true;
}
}
if (!hasClient) {
segments.add("CLIENT_LOCALE=" + value);
}
if (!hasDb) {
segments.add("DB_LOCALE=" + value);
}
return String.join(";", segments);
}
private List<String> queryDatabaseNamesFromSysmaster() {
ConnectParams params = databaseListParams;
if (params == null) {
@@ -620,6 +878,12 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
case 41 -> "BOOLEAN";
case 43, 52 -> "BIGINT";
case 44, 53 -> "BIGSERIAL";
// MySQL-compat extended types (SQLMODE=mysql); codes match the
// 3.6.5+ driver's IfxBigIntUnsigned/IfxTinyInt/IfxMediumInt/IfxBit.
case 66 -> "BIGINT UNSIGNED";
case 67 -> "TINYINT";
case 68 -> "MEDIUMINT";
case 69 -> "BIT";
default -> "UNKNOWN(" + baseColType(coltype) + ")";
};
}
@@ -24,6 +24,18 @@ import java.util.Map;
import java.util.Set;
class Gbase8sAgentTest {
@Test
void mapsMysqlCompatExtendedColumnTypes() {
Assertions.assertEquals("BIGINT UNSIGNED", Gbase8sAgent.mapColType(66));
Assertions.assertEquals("TINYINT", Gbase8sAgent.mapColType(67));
Assertions.assertEquals("MEDIUMINT", Gbase8sAgent.mapColType(68));
Assertions.assertEquals("BIT", Gbase8sAgent.mapColType(69));
// Nullable flag bits above the base type must not change the mapping.
Assertions.assertEquals("BIGINT UNSIGNED", Gbase8sAgent.mapColType(66 + 256));
Assertions.assertEquals("BIGINT", Gbase8sAgent.mapColType(43));
Assertions.assertEquals("UNKNOWN(99)", Gbase8sAgent.mapColType(99));
}
@Test
void declaresGbase8sProfile() {
Gbase8sAgent agent = new Gbase8sAgent();
@@ -155,6 +167,96 @@ class Gbase8sAgentTest {
);
}
@Test
void overrideLocaleParamsRewritesBothLocalesPreservingOthers() {
Assertions.assertEquals(
"GBASEDBTSERVER=gbase01;CLIENT_LOCALE=en_US.819;DB_LOCALE=en_US.819;NEWCODESET=UTF8,utf8,57372",
Gbase8sAgent.overrideLocaleParams(
"GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_CN.utf8;DB_LOCALE=zh_CN.utf8;NEWCODESET=UTF8,utf8,57372",
"en_US.819"
)
);
// Appends both when neither is present.
Assertions.assertEquals(
"GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_CN.57372;DB_LOCALE=zh_CN.57372",
Gbase8sAgent.overrideLocaleParams("GBASEDBTSERVER=gbase01", "zh_CN.57372")
);
// Blank collate is a no-op.
Assertions.assertEquals(
"DB_LOCALE=zh_CN.utf8",
Gbase8sAgent.overrideLocaleParams("DB_LOCALE=zh_CN.utf8", " ")
);
}
@Test
void overrideLocaleParamsRejectsUnsafeCollate() {
// A server-reported collation is concatenated into the JDBC URL, so anything outside the
// ordinary-locale whitelist must be ignored rather than injected.
Assertions.assertEquals(
"DB_LOCALE=zh_CN.utf8",
Gbase8sAgent.overrideLocaleParams("DB_LOCALE=zh_CN.utf8", "en_US.819;NEWCODESET=x")
);
Assertions.assertEquals(
"DB_LOCALE=zh_CN.utf8",
Gbase8sAgent.overrideLocaleParams("DB_LOCALE=zh_CN.utf8", "bad locale")
);
Assertions.assertEquals(
"DB_LOCALE=zh_CN.utf8",
Gbase8sAgent.overrideLocaleParams("DB_LOCALE=zh_CN.utf8", "a".repeat(200))
);
}
@Test
void rewritesLocaleToTargetDatabaseCollateSoCrossLocaleDatabaseOpens() {
// The reported connection pins DB_LOCALE=zh_CN.utf8 for `dcss`; opening the differently
// locale `gbase8s` database (real collate en_US.819) must rewrite the locale to en_US.819.
ConnectParams params = new ConnectParams(
"192.168.5.65",
9088,
"dcss",
"gbasedbt",
"secret",
"GBASEDBTSERVER=gbaseserver;DB_LOCALE=zh_CN.utf8;CLIENT_LOCALE=zh_CN.utf8;NEWCODESET=UTF8,utf8,57372;DELIMIDENT=y",
"",
false
);
String url = Gbase8sAgent.buildUrl(
new ConnectParams(
params.getHost(),
params.getPort(),
"gbase8s",
params.getUsername(),
params.getPassword(),
Gbase8sAgent.overrideLocaleParams(params.getUrl_params(), "en_US.819"),
params.getConnection_string(),
false
)
);
Assertions.assertEquals(
"jdbc:gbasedbt-sqli://192.168.5.65:9088/gbase8s:GBASEDBTSERVER=gbaseserver;DB_LOCALE=en_US.819;CLIENT_LOCALE=en_US.819;NEWCODESET=UTF8,utf8,57372;DELIMIDENT=y",
url
);
}
@Test
void createDatabaseLocaleDirectiveIsRoutedToLocaleSession() {
// The directive branch must fire only for a leading DBX_DB_LOCALE directive on a CREATE
// DATABASE statement; anything else returns null and falls through to the normal query
// path. Parsing is tested directly because both paths throw identically when unconnected.
Gbase8sAgent.CreateDatabaseLocaleDirective directive = Gbase8sAgent.parseCreateDatabaseLocaleDirective(
"-- DBX_DB_LOCALE=zh_CN.utf8\nCREATE DATABASE app_db;"
);
Assertions.assertNotNull(directive);
Assertions.assertEquals("zh_CN.utf8", directive.locale());
Assertions.assertEquals("CREATE DATABASE app_db;", directive.statement());
Assertions.assertNull(Gbase8sAgent.parseCreateDatabaseLocaleDirective("CREATE DATABASE app_db;"));
Assertions.assertNull(Gbase8sAgent.parseCreateDatabaseLocaleDirective(
"-- DBX_DB_LOCALE=zh_CN.utf8\nDROP DATABASE app_db;"));
Assertions.assertNull(Gbase8sAgent.parseCreateDatabaseLocaleDirective(null));
}
@Test
void omitsOwnerSchemasWhenTheDatabaseCannotUseThemInDml() {
List<String> sql = new ArrayList<>();
+1
View File
@@ -36,5 +36,6 @@ tasks.named('test') {
dependsOn tasks.named('shadowJar')
doFirst {
systemProperty 'dbx.h2.agent.jar', tasks.named('shadowJar').get().archiveFile.get().asFile.absolutePath
systemProperty 'dbx.h2.v1.driver.jar', configurations.h2V1.asPath
}
}
@@ -28,8 +28,20 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
public class H2Agent extends AbstractJdbcAgent {
private static final String QUOTED_IDENTIFIER = "\"(?:\"\"|[^\"])*\"";
private static final String QUALIFIED_IDENTIFIER = QUOTED_IDENTIFIER + "\\." + QUOTED_IDENTIFIER;
private static final Pattern CREATE_SEQUENCE = Pattern.compile("^CREATE SEQUENCE (" + QUALIFIED_IDENTIFIER + ")\\s");
private static final Pattern SEQUENCE_REFERENCE = Pattern.compile(
"'(?:''|[^'])*'|" + QUOTED_IDENTIFIER + "|--[^\\r\\n]*|/\\*.*?\\*/|\\b(?:NEXT|CURRENT)\\s+VALUE\\s+FOR\\s+(" + QUALIFIED_IDENTIFIER + ")",
Pattern.DOTALL
);
private static final Pattern CREATE_INDEX = Pattern.compile(
"^CREATE (?:UNIQUE(?: NULLS (?:DISTINCT|NOT DISTINCT|ALL DISTINCT))? )?(?:HASH |SPATIAL )?INDEX .*",
Pattern.DOTALL
);
private String databaseName = "";
private H2DriverLoader.LoadedDriver loadedDriver;
private H2DriverVersion driverVersion = H2DriverVersion.V3;
@@ -92,7 +104,29 @@ public class H2Agent extends AbstractJdbcAgent {
@Override
protected void afterConnect(ConnectParams params, Connection connection) {
databaseName = params.getDatabase();
databaseMajorVersion = unchecked(() -> connection.getMetaData().getDatabaseMajorVersion());
databaseMajorVersion = detectDatabaseMajorVersion(connection);
}
// For a remote (TCP/SSL) connection, JDBC metadata such as
// getDatabaseMajorVersion() reflects the *loaded driver's* own version, not
// the server's, whenever the driver profile could not be auto-detected from
// a local database file (see H2FileFormatDetector) and fell back to the
// newest bundled driver. H2VERSION() is evaluated by the server itself, so
// it reports the real engine version regardless of which driver connected.
private static int detectDatabaseMajorVersion(Connection connection) {
try (java.sql.Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery("SELECT H2VERSION()")) {
if (resultSet.next()) {
String version = resultSet.getString(1);
int dot = version == null ? -1 : version.indexOf('.');
if (dot > 0) {
return Integer.parseInt(version.substring(0, dot));
}
}
} catch (SQLException | NumberFormatException ignored) {
// Fall back to JDBC driver metadata below.
}
return unchecked(() -> connection.getMetaData().getDatabaseMajorVersion());
}
H2DriverVersion driverVersion() {
@@ -300,6 +334,55 @@ public class H2Agent extends AbstractJdbcAgent {
});
}
@Override
public String getTableDdl(String schema, String table) {
return unchecked(() -> {
String tableRef = JdbcIdentifiers.INSTANCE.doubleQuote(resolveSchema(schema)) + "."
+ JdbcIdentifiers.INSTANCE.doubleQuote(table);
List<String> statements = new ArrayList<>();
List<String> sequences = new ArrayList<>();
// Let H2 preserve identity/computed columns, constraints and index ordering.
// Exclude database-wide settings, users, schemas, grants and triggers.
try (var stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery("SCRIPT NODATA NOPASSWORDS NOSETTINGS TABLE " + tableRef)) {
while (rs.next()) {
String sql = rs.getString(1).trim();
if (sql.startsWith("CREATE SEQUENCE ")) {
sequences.add(sql);
} else if (sql.startsWith("CREATE MEMORY TABLE ") || sql.startsWith("CREATE CACHED TABLE ")
|| sql.startsWith("CREATE TABLE ") || sql.startsWith("ALTER TABLE ")
|| CREATE_INDEX.matcher(sql).matches()) {
statements.add(sql);
}
}
}
if (statements.isEmpty()) {
throw new IllegalStateException("H2 returned no table DDL for " + tableRef);
}
// SCRIPT TABLE can include every sequence in the schema. Keep only real
// references, skipping quoted strings, identifiers and comments as tokens.
Set<String> referenced = new HashSet<>();
for (String statement : statements) {
var references = SEQUENCE_REFERENCE.matcher(statement);
while (references.find()) {
if (references.group(1) != null) {
referenced.add(references.group(1));
}
}
}
List<String> ddl = new ArrayList<>();
for (String sequence : sequences) {
var declaration = CREATE_SEQUENCE.matcher(sequence);
if (declaration.find() && referenced.contains(declaration.group(1))) {
// Several selected tables may share one explicit sequence.
ddl.add("CREATE SEQUENCE IF NOT EXISTS " + sequence.substring("CREATE SEQUENCE ".length()));
}
}
ddl.addAll(statements);
return String.join("\n", ddl);
});
}
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
return unchecked(() -> {
@@ -327,7 +410,10 @@ public class H2Agent extends AbstractJdbcAgent {
List<ColumnInfo> result = new ArrayList<>();
String typeColumn = isVersion2OrLater() ? "DATA_TYPE" : "TYPE_NAME";
String columnSql = "SELECT COLUMN_NAME, " + typeColumn + " AS DBX_DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, "
String extraColumns = isVersion2OrLater()
? "IS_GENERATED, IS_IDENTITY, IDENTITY_GENERATION, "
: "IS_COMPUTED, SEQUENCE_NAME, ";
String columnSql = "SELECT COLUMN_NAME, " + typeColumn + " AS DBX_DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, " + extraColumns
+ "NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH, REMARKS "
+ "FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION";
try (var stmt = requireConnected().prepareStatement(columnSql)) {
@@ -336,14 +422,26 @@ public class H2Agent extends AbstractJdbcAgent {
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String columnName = rs.getString("COLUMN_NAME");
String extra = null;
if (isVersion2OrLater()) {
if ("ALWAYS".equals(rs.getString("IS_GENERATED"))) {
extra = "computed";
} else if ("YES".equals(rs.getString("IS_IDENTITY"))) {
extra = "generated " + rs.getString("IDENTITY_GENERATION").toLowerCase(Locale.ROOT) + " as identity";
}
} else if (rs.getBoolean("IS_COMPUTED")) {
extra = "computed";
} else if (rs.getString("SEQUENCE_NAME") != null) {
extra = "identity";
}
result.add(new ColumnInfo(
columnName,
rs.getString("DBX_DATA_TYPE"),
"YES".equals(rs.getString("IS_NULLABLE")),
rs.getString("COLUMN_DEFAULT"),
primaryKeys.contains(columnName),
extra,
rs.getString("REMARKS"),
null,
intOrNull(rs, "NUMERIC_PRECISION"),
intOrNull(rs, "NUMERIC_SCALE"),
intOrNull(rs, "CHARACTER_MAXIMUM_LENGTH")
@@ -565,10 +663,7 @@ public class H2Agent extends AbstractJdbcAgent {
}
private static String resolveSchema(String schema) {
if ("PUBLIC".equalsIgnoreCase(schema) || "INFORMATION_SCHEMA".equalsIgnoreCase(schema)) {
return schema.toUpperCase(Locale.ROOT);
}
return "PUBLIC";
return schema == null || schema.isBlank() ? "PUBLIC" : schema;
}
private static Integer intOrNull(ResultSet rs, String column) throws Exception {
@@ -12,6 +12,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;
@@ -99,6 +100,123 @@ class H2AgentProcessTest {
}
}
// Regression test for a legacy (H2 1.x) *remote* server: the file-format
// auto-detector (H2FileFormatDetector) cannot inspect a TCP database's
// storage file, so driver selection falls back to the newest bundled
// driver (H2 2.4.240). That mismatched driver's own JDBC metadata
// (getDatabaseMajorVersion()) then reports its own version rather than the
// server's, which used to make the agent send an H2 2.x-only
// INFORMATION_SCHEMA.ROUTINES query to a database that doesn't have that
// catalog table, failing list_objects entirely (dbx#8356).
@Test
@Timeout(45)
void listObjectsAutoDetectsLegacyServerOverTcp() throws Exception {
Path h2V1Jar = Path.of(System.getProperty("dbx.h2.v1.driver.jar"));
Assertions.assertTrue(Files.isRegularFile(h2V1Jar), () -> "Missing bundled H2 1.4.200 driver jar: " + h2V1Jar);
Path agentJar = Path.of(System.getProperty("dbx.h2.agent.jar"));
Assertions.assertTrue(Files.isRegularFile(agentJar), () -> "Missing shaded H2 Agent: " + agentJar);
try (LegacyTcpServer server = new LegacyTcpServer(h2V1Jar)) {
try (RpcProcess rpc = new RpcProcess(agentJar)) {
String sessionId = "session-legacy-tcp";
JsonObject params = sessionParams(sessionId);
params.addProperty("host", "127.0.0.1");
params.addProperty("port", server.port());
// A fresh, unique database per run: the TCP server process's
// working directory (and any .mv.db file it writes) persists
// across separate test invocations, so a fixed name would leak
// schema objects between runs.
params.addProperty("database", "./legacy-tcp-test-" + java.util.UUID.randomUUID());
params.addProperty("username", "sa");
params.addProperty("password", "");
params.addProperty("connection_string", "");
params.addProperty("ssl", false);
// driver_profile intentionally omitted: exercise the "auto" path.
JsonObject connected = rpc.result(rpc.request("open_session", params));
Assertions.assertTrue(connected.get("ok").getAsBoolean());
rpc.result(rpc.request("execute_query", queryParams(sessionId, "CREATE TABLE LEGACY_TABLE (ID INT PRIMARY KEY)")));
rpc.result(rpc.request("execute_query", queryParams(sessionId, "CREATE ALIAS LEGACY_FUNC FOR \"java.lang.Integer.reverse\"")));
JsonArray objects = rpc.resultArray(rpc.request("list_objects", metadataParams(sessionId, "PUBLIC")));
List<String> names = objects.asList().stream().map(value -> value.getAsJsonObject().get("name").getAsString()).toList();
Assertions.assertTrue(names.contains("LEGACY_TABLE"), names.toString());
Assertions.assertTrue(names.contains("LEGACY_FUNC"), names.toString());
Assertions.assertTrue(rpc.result(rpc.request("close_session", sessionParams(sessionId))).get("ok").getAsBoolean());
}
}
}
private static final class LegacyTcpServer implements AutoCloseable {
private final Process process;
private final int port;
private LegacyTcpServer(Path h2V1Jar) throws Exception {
this.port = findFreePort();
Path java = Path.of(System.getProperty("java.home"), "bin", "java");
process = new ProcessBuilder(
java.toString(), "-cp", h2V1Jar.toString(), "org.h2.tools.Server",
"-tcp", "-tcpPort", Integer.toString(port), "-ifNotExists"
).redirectErrorStream(true).start();
// -tcpDaemon is intentionally omitted: it marks the *listener* thread
// as a daemon for embedding inside a host JVM that outlives it. Here
// the server *is* the whole process, so a daemon-only listener thread
// would let the JVM exit (dropping sessions) the moment main() returns.
drainOutput();
waitUntilListening();
}
private void drainOutput() {
Thread thread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
while (reader.readLine() != null) {
// Discard: only draining to prevent the pipe buffer from filling.
}
} catch (java.io.IOException ignored) {
}
}, "legacy-h2-server-stdout");
thread.setDaemon(true);
thread.start();
}
private int port() {
return port;
}
private static int findFreePort() throws Exception {
try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) {
return socket.getLocalPort();
}
}
private void waitUntilListening() throws Exception {
long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos();
while (System.nanoTime() < deadline) {
try (java.net.Socket probe = new java.net.Socket()) {
probe.connect(new java.net.InetSocketAddress("127.0.0.1", port), 200);
return;
} catch (java.io.IOException notReady) {
Thread.sleep(100);
}
}
throw new AssertionError("Legacy H2 TCP server never started listening on port " + port);
}
@Override
public void close() {
process.destroy();
try {
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
}
private static JsonObject connectParams(String sessionId, String profile) {
JsonObject params = sessionParams(sessionId);
params.addProperty("host", "");
@@ -355,6 +355,128 @@ class H2AgentMigrationTest {
}
class H2VersionMatrixTest {
@Test
void tableDdlPreservesGeneratedColumnsConstraintsAndComments() {
for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) {
ConnectParams params = new ConnectParams("", 0, "mem:ddl-" + profile, "sa", "", "", "", false);
params.setDriver_profile(profile);
H2Agent agent = new H2Agent();
agent.connect(params);
try {
ExecuteQueryOptions options = new ExecuteQueryOptions();
agent.executeQuery("CREATE SCHEMA \"Source\"; CREATE SCHEMA \"Target\"", null, options);
agent.executeQuery("CREATE SEQUENCE \"Source\".UNUSED; CREATE SEQUENCE \"Source\".SHARED START WITH 10", null, options);
agent.executeQuery("CREATE TABLE \"Source\".PARENT (ID INT PRIMARY KEY, TOKEN BIGINT DEFAULT NEXT VALUE FOR \"Source\".SHARED)", null, options);
agent.executeQuery("CREATE TABLE \"Source\".CHILD (ID INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "
+ "PARENT_ID INT, QTY INT DEFAULT 1 CHECK (QTY > 0), TOTAL INT AS (QTY * 2), RAW_BYTES BINARY(8), "
+ "TOKEN BIGINT DEFAULT NEXT VALUE FOR \"Source\".SHARED, NOTE VARCHAR(128) DEFAULT 'NEXT VALUE FOR \"Source\".\"UNUSED\"', "
+ "CONSTRAINT FK_PARENT FOREIGN KEY (PARENT_ID) REFERENCES \"Source\".PARENT(ID) ON DELETE CASCADE)", null, options);
agent.executeQuery("COMMENT ON COLUMN \"Source\".CHILD.QTY IS 'quantity'; COMMENT ON TABLE \"Source\".CHILD IS 'child table'", null, options);
agent.executeQuery("CREATE INDEX \"Source\".IDX_QTY ON \"Source\".CHILD(QTY DESC)", null, options);
List<ColumnInfo> columns = agent.getColumns("Source", "CHILD");
Assertions.assertTrue(columns.get(0).getExtra().contains("identity"), profile);
Assertions.assertEquals("computed", columns.get(3).getExtra(), profile);
Assertions.assertEquals("quantity", columns.get(2).getComment(), profile);
for (String table : List.of("PARENT", "CHILD")) {
String ddl = agent.getTableDdl("Source", table);
Assertions.assertFalse(ddl.contains("CREATE USER"), ddl);
Assertions.assertFalse(ddl.contains("CREATE SCHEMA"), ddl);
Assertions.assertFalse(ddl.contains("INSERT INTO"), ddl);
Assertions.assertFalse(ddl.contains("CREATE SEQUENCE IF NOT EXISTS \"Source\".\"UNUSED\""), ddl);
if (table.equals("PARENT")) {
Assertions.assertFalse(ddl.contains("SYSTEM_SEQUENCE_"), ddl);
}
agent.executeQuery(ddl.replace("\"Source\".", "\"Target\"."), null, options);
}
agent.executeQuery("INSERT INTO \"Target\".PARENT(ID) VALUES (1)", null, options);
agent.executeQuery("INSERT INTO \"Target\".CHILD(PARENT_ID) VALUES (1)", null, options);
Assertions.assertEquals(List.of(List.of(1, 1, 2)), agent.executeQuery("SELECT ID, QTY, TOTAL FROM \"Target\".CHILD", null, options).getRows(), profile);
Assertions.assertEquals("11", agent.executeQuery("SELECT TOKEN FROM \"Target\".CHILD", null, options).getRows().get(0).get(0).toString(), profile);
Assertions.assertThrows(RuntimeException.class, () -> agent.executeQuery("INSERT INTO \"Target\".CHILD(QTY) VALUES (-1)", null, options), profile);
Assertions.assertTrue(agent.getTableDdl("Target", "CHILD").contains("\"QTY\" DESC"), profile);
Assertions.assertEquals("quantity", agent.getColumns("Target", "CHILD").get(2).getComment(), profile);
agent.executeQuery("DELETE FROM \"Target\".PARENT", null, options);
Assertions.assertTrue(agent.executeQuery("SELECT * FROM \"Target\".CHILD", null, options).getRows().isEmpty(), profile);
} finally {
agent.disconnect();
}
}
}
@Test
void generatedAlwaysIdentityAcceptsExplicitInsertWithOverride() {
for (String profile : List.of("h2-v2", "h2-v3")) {
H2Agent agent = connect(profile);
try {
ExecuteQueryOptions options = new ExecuteQueryOptions();
agent.executeQuery("CREATE TABLE ALWAYS_ID (ID INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, V INT)", null, options);
Assertions.assertEquals("generated always as identity", agent.getColumns("PUBLIC", "ALWAYS_ID").get(0).getExtra());
agent.executeQuery("INSERT INTO ALWAYS_ID(ID, V) OVERRIDING SYSTEM VALUE VALUES (42, 7)", null, options);
Assertions.assertEquals(List.of(List.of(42, 7)), agent.executeQuery("SELECT ID, V FROM ALWAYS_ID", null, options).getRows());
} finally {
agent.disconnect();
}
}
}
@Test
void transferMetadataAndWritesRespectCustomSchemasAcrossBundledVersions() {
for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) {
ConnectParams params = new ConnectParams("", 0, "mem:transfer-" + profile, "sa", "", "", "", false);
params.setDriver_profile(profile);
H2Agent agent = new H2Agent();
agent.connect(params);
try {
ExecuteQueryOptions options = new ExecuteQueryOptions();
agent.executeQuery("CREATE SCHEMA \"TransferTarget\"", null, options);
agent.executeQuery("CREATE TABLE PUBLIC.ITEMS (PUBLIC_ONLY INT)", null, options);
agent.executeQuery(
"CREATE TABLE \"TransferTarget\".ITEMS (ID INT, PART INT, NAME VARCHAR(1024), AMOUNT DECIMAL(12, 2), PRIMARY KEY (ID, PART))",
null, options
);
agent.executeQuery("CREATE INDEX \"TransferTarget\".IDX_NAME ON \"TransferTarget\".ITEMS(NAME)", null, options);
agent.executeQuery("CREATE UNIQUE HASH INDEX \"TransferTarget\".IDX_PART ON \"TransferTarget\".ITEMS(PART)", null, options);
Assertions.assertTrue(agent.listSchemas().contains("TransferTarget"), profile);
Assertions.assertTrue(agent.listTables("TransferTarget").stream().anyMatch(table -> table.getName().equals("ITEMS")), profile);
Assertions.assertTrue(agent.listTables("MISSING_SCHEMA").isEmpty(), profile);
Assertions.assertEquals(List.of("PUBLIC_ONLY"), agent.getColumns(null, "ITEMS").stream().map(ColumnInfo::getName).toList(), profile);
List<ColumnInfo> columns = agent.getColumns("TransferTarget", "ITEMS");
Assertions.assertEquals(List.of("ID", "PART", "NAME", "AMOUNT"), columns.stream().map(ColumnInfo::getName).toList(), profile);
Assertions.assertEquals(1024, columns.get(2).getCharacter_maximum_length(), profile);
Assertions.assertEquals(12, columns.get(3).getNumeric_precision(), profile);
Assertions.assertEquals(2, columns.get(3).getNumeric_scale(), profile);
Assertions.assertTrue(agent.listIndexes("TransferTarget", "ITEMS").stream().anyMatch(index -> index.getName().equals("IDX_NAME")), profile);
agent.executeQuery("CREATE SCHEMA \"TransferCopy\"", null, options);
String ddl = agent.getTableDdl("TransferTarget", "ITEMS");
agent.executeQuery(ddl.replace("\"TransferTarget\".", "\"TransferCopy\"."), null, options);
Assertions.assertEquals(1024, agent.getColumns("TransferCopy", "ITEMS").get(2).getCharacter_maximum_length(), profile);
Assertions.assertTrue(agent.listIndexes("TransferCopy", "ITEMS").stream().anyMatch(index -> index.getName().equals("IDX_NAME")), profile);
Assertions.assertTrue(agent.listIndexes("TransferCopy", "ITEMS").stream().anyMatch(index -> index.getName().equals("IDX_PART") && index.getIs_unique()), profile);
agent.executeQuery("INSERT INTO \"TransferCopy\".ITEMS VALUES (1, 1, 'first', 1.00)", null, options);
Assertions.assertThrows(RuntimeException.class, () -> agent.executeQuery("INSERT INTO \"TransferCopy\".ITEMS VALUES (2, 1, 'duplicate', 2.00)", null, options), profile);
agent.executeQuery("INSERT INTO \"TransferTarget\".ITEMS VALUES (1, 1, 'old', 1.00)", null, options);
agent.executeQuery(
"MERGE INTO \"TransferTarget\".\"ITEMS\" (\"ID\", \"PART\", \"NAME\", \"AMOUNT\") KEY (\"ID\", \"PART\") VALUES "
+ "(1, 1, 'updated', 12.34), (1, 2, 'new', 56.78)",
null, options
);
QueryResult result = agent.executeQuery("SELECT NAME, CAST(AMOUNT AS VARCHAR) FROM \"TransferTarget\".ITEMS ORDER BY PART", null, options);
Assertions.assertEquals(List.of(List.of("updated", "12.34"), List.of("new", "56.78")), result.getRows(), profile);
agent.executeQuery("TRUNCATE TABLE \"TransferTarget\".ITEMS", null, options);
Assertions.assertTrue(agent.executeQuery("SELECT * FROM \"TransferTarget\".ITEMS", null, options).getRows().isEmpty(), profile);
agent.executeQuery("CREATE TABLE \"TransferTarget\".PAYLOADS (LABEL VARCHAR(128), DATA JSON, RAW_BYTES VARBINARY)", null, options);
agent.executeQuery("INSERT INTO \"TransferTarget\".PAYLOADS VALUES ('C:\\tmp\\o''clock', JSON '[1,2]', X'00ff')", null, options);
QueryResult payload = agent.executeQuery("SELECT * FROM \"TransferTarget\".PAYLOADS", null, options);
Assertions.assertEquals(List.of(List.of("C:\\tmp\\o'clock", "[1,2]", "0x00ff")), payload.getRows(), profile);
} finally {
agent.disconnect();
}
}
}
@Test
void metadataAndExecutionWorkAcrossBundledVersions() {
for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) {
@@ -2,11 +2,14 @@ package com.dbx.agent.iris;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.StandardJdbcMetadata;
import com.intersystems.jdbc.IRISConnection;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
@@ -32,12 +35,54 @@ public final class IrisAgent extends ConfiguredJdbcAgent {
super(IRIS_PROFILE);
}
// The InterSystems driver's default query prefetch block (32768 bytes) means
// one server round trip per ~32KB of result data. Large result sets over
// high-latency links then spend most of their wall time waiting on the
// network instead of transferring rows, while other clients that negotiate
// larger fetch blocks finish several times faster. Statement.setFetchSize
// does not help here: intersystems-jdbc stores that value but never sends
// it to the server. The connection-level query prefetch size is the only
// effective knob, so raise it for every DBX IRIS connection. This is
// advisory: servers that reject the value keep the driver default, and the
// driver only round-trips when the value actually changes.
static final int IRIS_QUERY_PREFETCH_SIZE = 262144;
@Override
protected void afterConnect(ConnectParams params, Connection connection) {
super.afterConnect(params, connection);
applyQueryPrefetchSize(connection);
}
static void applyQueryPrefetchSize(Connection connection) {
if (!(connection instanceof IRISConnection)) {
return;
}
try {
((IRISConnection) connection).setQueryPrefetchSize(IRIS_QUERY_PREFETCH_SIZE);
} catch (Exception ignored) {
// Performance tuning only; keep the connection usable with the
// driver default when a server refuses the larger prefetch.
}
}
@Override
protected Object resultValue(ResultSet rs, int index, int sqlType) {
if (sqlType == Types.OTHER) {
return unchecked(() -> JdbcExecutor.normalizeResultValue(rs.getObject(index)));
switch (sqlType) {
case Types.BIGINT:
case Types.INTEGER:
case Types.SMALLINT:
case Types.TINYINT:
// InterSystems can expose values outside the Java primitive range
// implied by its JDBC metadata. Preserve them without narrowing.
return unchecked(() -> {
Object value = rs.getBigDecimal(index);
return rs.wasNull() ? null : value;
});
case Types.OTHER:
return unchecked(() -> JdbcExecutor.normalizeResultValue(rs.getObject(index)));
default:
return super.resultValue(rs, index, sqlType);
}
return super.resultValue(rs, index, sqlType);
}
@Override
@@ -0,0 +1,86 @@
package com.dbx.agent.iris;
import com.dbx.agent.ConnectParams;
import com.intersystems.jdbc.IRISConnection;
import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
class IrisAgentQueryPrefetchTest {
/** Records setQueryPrefetchSize calls without touching driver internals. */
static final class RecordingIrisConnection extends IRISConnection {
final List<Integer> prefetchSizes = new ArrayList<>();
@Override
public void setQueryPrefetchSize(int size) {
prefetchSizes.add(size);
}
}
static final class FailingIrisConnection extends IRISConnection {
@Override
public void setQueryPrefetchSize(int size) throws SQLException {
throw new SQLException("server rejected prefetch size");
}
}
@Test
void afterConnectAppliesLargerQueryPrefetch() {
RecordingIrisConnection connection = new RecordingIrisConnection();
new IrisAgent().afterConnect(new ConnectParams(), connection);
assertEquals(Collections.singletonList(IrisAgent.IRIS_QUERY_PREFETCH_SIZE), connection.prefetchSizes);
}
@Test
void afterConnectKeepsNonIrisConnectionsUntouched() {
Connection connection = proxyConnection();
assertDoesNotThrow(() -> new IrisAgent().afterConnect(new ConnectParams(), connection));
}
@Test
void prefetchFailureDoesNotBreakConnect() {
FailingIrisConnection connection = new FailingIrisConnection();
assertDoesNotThrow(() -> new IrisAgent().afterConnect(new ConnectParams(), connection));
}
@Test
void directApplyIsBoundedToIrisConnections() {
// A plain java.sql.Connection (for example a test or wrapper instance)
// must not be cast or fail when the prefetch tuning runs.
assertDoesNotThrow(() -> IrisAgent.applyQueryPrefetchSize(proxyConnection()));
}
private static Connection proxyConnection() {
return (Connection) Proxy.newProxyInstance(
IrisAgentQueryPrefetchTest.class.getClassLoader(),
new Class<?>[] { Connection.class },
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
Class<?> returnType = method.getReturnType();
if (returnType == boolean.class) {
return false;
}
if (returnType.isPrimitive() && returnType != void.class) {
return 0;
}
return null;
}
}
);
}
}
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
@@ -76,8 +77,38 @@ class IrisAgentTest {
assertEquals(Collections.singletonList("getObject"), calls);
}
@Test
void readsOutOfRangeIntegerThroughBigDecimal() {
BigDecimal valueOutsideIntegerRange = new BigDecimal("2147483648");
List<String> calls = new ArrayList<>();
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if ("getBigDecimal".equals(method.getName())) {
calls.add("getBigDecimal");
return valueOutsideIntegerRange;
}
if (method.getName().startsWith("get")) {
calls.add(method.getName());
throw new SQLException("Numeric value out of range");
}
if ("wasNull".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
Object value = new IrisAgent().resultValue(resultSet, 1, Types.INTEGER);
assertEquals(valueOutsideIntegerRange, value);
assertEquals(Collections.singletonList("getBigDecimal"), calls);
}
@Test
void preservesStringPathForStandardValues() {
assertStringPath(Types.VARCHAR);
assertStringPath(Types.LONGVARCHAR);
}
private static void assertStringPath(int sqlType) {
List<String> calls = new ArrayList<>();
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if ("getString".equals(method.getName())) {
@@ -90,7 +121,7 @@ class IrisAgentTest {
return defaultValue(method.getReturnType());
});
Object value = new IrisAgent().resultValue(resultSet, 1, Types.VARCHAR);
Object value = new IrisAgent().resultValue(resultSet, 1, sqlType);
assertEquals("ordinary", value);
assertEquals(Collections.singletonList("getString"), calls);
@@ -316,9 +316,13 @@ func runQueryBenchmark(process *agentProcess, duration time.Duration, concurrenc
total += value
}
count := operations.Load()
sampleCount := len(merged)
if sampleCount < 1 {
sampleCount = 1
}
return benchmarkResult{
Concurrency: concurrency, Operations: count, Errors: failures.Load(), DurationMS: float64(elapsed.Microseconds()) / 1000,
QPS: float64(count) / elapsed.Seconds(), MeanMS: total / float64(max(1, len(merged))),
QPS: float64(count) / elapsed.Seconds(), MeanMS: total / float64(sampleCount),
P50MS: percentile(merged, 0.50), P95MS: percentile(merged, 0.95), P99MS: percentile(merged, 0.99),
PeakRSSKB: peakRSS.Load(),
}
+1 -1
View File
@@ -1,5 +1,5 @@
module github.com/t8y2/dbx/agents/drivers/kingbase-go
go 1.22
go 1.20
require gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3
+133 -3
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
@@ -501,9 +502,17 @@ func (s *server) testConnection(cp connectParams) error {
type kingbaseDBOpener func(connectParams, string) (*sql.DB, error)
func openAndPingDB(cp connectParams, timeout time.Duration, opener kingbaseDBOpener) (*sql.DB, error) {
if endpoints := clusterConnectEndpoints(cp); len(endpoints) > 1 {
return openAndPingClusterEndpoints(cp, timeout, endpoints, opener)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return pingDBWithSSLModes(ctx, cp, opener)
}
// pingDBWithSSLModes runs the historical sslmode attempt sequence
// ("prefer" degrades to require then disable) for one set of fields.
func pingDBWithSSLModes(ctx context.Context, cp connectParams, opener kingbaseDBOpener) (*sql.DB, error) {
sslMode := effectiveSSLMode(cp)
attempts := []string{sslMode}
if sslMode == "prefer" {
@@ -528,6 +537,111 @@ func openAndPingDB(cp connectParams, timeout time.Duration, opener kingbaseDBOpe
return nil, errors.New("kingbase connection failed")
}
type kingbaseEndpoint struct {
host string
port int
}
// clusterConnectEndpoints splits the host field into ordered endpoints for
// cluster (multi-IP) configurations. DBX stores cluster hosts as
// comma-separated entries, each optionally embedding its own `:port`
// (mirroring the vastbase/openGauss driver semantics); semicolons are also
// accepted because Kingbase deployments paste both separators. A native
// connection_string builds its own DSN, so the host field is only split for
// the field-based path, and a host without any separator yields a single
// endpoint which keeps the legacy single-host flow untouched.
func clusterConnectEndpoints(cp connectParams) []kingbaseEndpoint {
if value := strings.TrimSpace(cp.ConnectionString); value != "" && !isKingbaseJDBCURL(value) {
return nil
}
defaultPort := cp.Port
if defaultPort <= 0 {
defaultPort = 54321
}
return splitHostEndpoints(cp.Host, defaultPort)
}
// splitHostEndpoints parses `host1[:port1][,;]host2[:port2]...`. Entries
// without an embedded port use fallbackPort; bracketed IPv6 literals may
// carry a port after the closing bracket.
func splitHostEndpoints(rawHost string, fallbackPort int) []kingbaseEndpoint {
parts := strings.FieldsFunc(rawHost, func(r rune) bool { return r == ',' || r == ';' })
endpoints := make([]kingbaseEndpoint, 0, len(parts))
for _, part := range parts {
host, port, ok := parseHostEndpoint(strings.TrimSpace(part), fallbackPort)
if !ok {
continue
}
endpoints = append(endpoints, kingbaseEndpoint{host: host, port: port})
}
return endpoints
}
func parseHostEndpoint(entry string, fallbackPort int) (string, int, bool) {
if entry == "" {
return "", 0, false
}
if strings.HasPrefix(entry, "[") {
if close := strings.Index(entry, "]"); close > 0 {
host := entry[1:close]
suffix := entry[close+1:]
portText, hasPort := strings.CutPrefix(suffix, ":")
if !hasPort && suffix == "" {
return host, fallbackPort, true
}
if hasPort {
if port, err := strconv.Atoi(portText); err == nil && validEndpointPort(port) {
return host, port, true
}
}
// Malformed bracketed entries such as `[::1]x` keep their raw
// text so the eventual dial error stays honest.
return entry, fallbackPort, true
}
}
if strings.Count(entry, ":") == 1 {
if host, rawPort, ok := strings.Cut(entry, ":"); ok && host != "" {
if port, err := strconv.Atoi(rawPort); err == nil && validEndpointPort(port) {
return host, port, true
}
}
}
// Bare IPv6 literals and hostnames (including entries with a non-numeric
// port suffix) are forwarded verbatim, exactly like the single-host path.
return entry, fallbackPort, true
}
func validEndpointPort(port int) bool {
return port > 0 && port <= 65535
}
// openAndPingClusterEndpoints tries each configured endpoint in order and
// keeps the first connection that completes the sslmode sequence, matching
// the failover behavior the openGauss-family drivers implement natively.
// Following libpq semantics, every endpoint gets its own full timeout
// budget so one unreachable node cannot starve the remaining candidates.
func openAndPingClusterEndpoints(
cp connectParams,
timeout time.Duration,
endpoints []kingbaseEndpoint,
opener kingbaseDBOpener,
) (*sql.DB, error) {
failures := make([]error, 0, len(endpoints))
for _, endpoint := range endpoints {
endpointParams := cp
endpointParams.Host = endpoint.host
endpointParams.Port = endpoint.port
ctx, cancel := context.WithTimeout(context.Background(), timeout)
db, err := pingDBWithSSLModes(ctx, endpointParams, opener)
cancel()
if err == nil {
return db, nil
}
failures = append(failures, fmt.Errorf("%s:%d: %w", endpoint.host, endpoint.port, err))
}
return nil, fmt.Errorf("kingbase connection failed after trying %d endpoints: %w", len(endpoints), errors.Join(failures...))
}
func shouldRetryKingbaseWithoutSSL(err error) bool {
if errors.Is(err, gokb.ErrSSLNotSupported) {
return true
@@ -766,11 +880,27 @@ func (s *server) closeAllQuerySessions() {
}
}
// minInt/maxInt replace the Go 1.21 builtins so the module stays go 1.20
// compatible for the Windows 7 toolchain build.
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func readQuerySessionPage(session *querySession, pageSize int) (queryPageResult, error) {
if pageSize <= 0 {
pageSize = 100
}
capacity := min(pageSize, session.remaining)
capacity := minInt(pageSize, session.remaining)
result := queryPageResult{Columns: session.columns, ColumnTypes: session.columnTypes, Rows: make([][]any, 0, capacity)}
for len(result.Rows) < pageSize && session.remaining > 0 {
if session.pending != nil {
@@ -811,7 +941,7 @@ func readRows(rows *sql.Rows, maxRows int) (queryResult, error) {
}
columns = nonNilStrings(columns)
columnTypes := columnTypeNames(rows)
result := queryResult{Columns: columns, ColumnTypes: columnTypes, Rows: make([][]any, 0, min(maxRows, 1024))}
result := queryResult{Columns: columns, ColumnTypes: columnTypes, Rows: make([][]any, 0, minInt(maxRows, 1024))}
for rows.Next() {
if len(result.Rows) >= maxRows {
result.Truncated = true
@@ -1700,7 +1830,7 @@ func hasTopLevelSQLKeyword(sqlText string, index int, target string) bool {
case '(':
depth++
case ')':
depth = max(depth-1, 0)
depth = maxInt(depth-1, 0)
default:
if depth == 0 && isSQLWordStartByte(sqlText[index]) {
keyword, next := sqlKeywordAt(sqlText, index)
+369 -6
View File
@@ -352,6 +352,75 @@ func (state *connectionAttemptState) connectionStrings() []string {
return append([]string(nil), state.dsns...)
}
// failoverState records every opener attempt (per endpoint host + sslmode)
// and simulates ping failures keyed as "<sslMode>@<host>".
type failoverState struct {
mu sync.Mutex
opened []string
dsns []string
pingErrors map[string]error
}
type failoverOpener struct {
state *failoverState
}
type failoverConnector struct {
state *failoverState
host string
sslMode string
}
type failoverDriver struct{}
type failoverConn struct {
state *failoverState
host string
sslMode string
}
func (o failoverOpener) open(cp connectParams, sslMode string) (*sql.DB, error) {
dsn := buildDSNWithSSLMode(cp, sslMode)
o.state.mu.Lock()
o.state.opened = append(o.state.opened, sslMode+"@"+cp.Host)
o.state.dsns = append(o.state.dsns, dsn)
o.state.mu.Unlock()
return sql.OpenDB(failoverConnector{state: o.state, host: cp.Host, sslMode: sslMode}), nil
}
func (connector failoverConnector) Connect(context.Context) (driver.Conn, error) {
conn := failoverConn{state: connector.state, host: connector.host, sslMode: connector.sslMode}
return &conn, nil
}
func (failoverConnector) Driver() driver.Driver { return failoverDriver{} }
func (failoverDriver) Open(string) (driver.Conn, error) { return nil, driver.ErrSkip }
func (*failoverConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
func (*failoverConn) Close() error { return nil }
func (*failoverConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
func (connection failoverConn) Ping(context.Context) error {
connection.state.mu.Lock()
defer connection.state.mu.Unlock()
return connection.state.pingErrors[connection.sslMode+"@"+connection.host]
}
func (state *failoverState) attempts() []string {
state.mu.Lock()
defer state.mu.Unlock()
return append([]string(nil), state.opened...)
}
func (state *failoverState) connectionStrings() []string {
state.mu.Lock()
defer state.mu.Unlock()
return append([]string(nil), state.dsns...)
}
func openFakeDB(t *testing.T, rowCount int) (*sql.DB, *fakeDriverState) {
t.Helper()
registerTestDriver.Do(func() { sql.Register("kingbase-agent-test", fakeDriver{}) })
@@ -961,6 +1030,300 @@ func TestOpenAndPingDBSSLDefaultsToVerifyFull(t *testing.T) {
}
}
func TestSplitHostEndpoints(t *testing.T) {
tests := []struct {
name string
host string
fallbackPort int
expected []kingbaseEndpoint
}{
{
name: "single host stays whole",
host: "172.22.232.10",
fallbackPort: 54321,
expected: []kingbaseEndpoint{{host: "172.22.232.10", port: 54321}},
},
{
name: "single host with custom fallback port",
host: "db.example.com",
fallbackPort: 6000,
expected: []kingbaseEndpoint{{host: "db.example.com", port: 6000}},
},
{
name: "comma separated cluster",
host: "172.22.232.10,172.22.232.11",
fallbackPort: 54321,
expected: []kingbaseEndpoint{
{host: "172.22.232.10", port: 54321},
{host: "172.22.232.11", port: 54321},
},
},
{
name: "semicolon separated cluster",
host: "172.22.232.10; 172.22.232.11",
fallbackPort: 54321,
expected: []kingbaseEndpoint{
{host: "172.22.232.10", port: 54321},
{host: "172.22.232.11", port: 54321},
},
},
{
name: "per entry ports",
host: "10.0.0.1:1000,10.0.0.2:2000",
fallbackPort: 54321,
expected: []kingbaseEndpoint{
{host: "10.0.0.1", port: 1000},
{host: "10.0.0.2", port: 2000},
},
},
{
name: "mixed embedded and fallback ports",
host: "10.0.0.1:1000,10.0.0.2",
fallbackPort: 54322,
expected: []kingbaseEndpoint{
{host: "10.0.0.1", port: 1000},
{host: "10.0.0.2", port: 54322},
},
},
{
name: "bracketed ipv6 without port",
host: "[2001:db8::1],[2001:db8::2]",
fallbackPort: 54321,
expected: []kingbaseEndpoint{
{host: "2001:db8::1", port: 54321},
{host: "2001:db8::2", port: 54321},
},
},
{
name: "bracketed ipv6 with port",
host: "[2001:db8::1]:6000,[2001:db8::2]:6001",
fallbackPort: 54321,
expected: []kingbaseEndpoint{
{host: "2001:db8::1", port: 6000},
{host: "2001:db8::2", port: 6001},
},
},
{
name: "bare ipv6 literal",
host: "::1",
fallbackPort: 54321,
expected: []kingbaseEndpoint{{host: "::1", port: 54321}},
},
{
name: "invalid port suffix stays part of the host",
host: "db.example.com:notaport",
fallbackPort: 54321,
expected: []kingbaseEndpoint{{host: "db.example.com:notaport", port: 54321}},
},
{
name: "out of range port falls back",
host: "10.0.0.1:70000",
fallbackPort: 54321,
expected: []kingbaseEndpoint{{host: "10.0.0.1:70000", port: 54321}},
},
{
name: "empty entries are dropped",
host: "10.0.0.1,, ;10.0.0.2",
fallbackPort: 54321,
expected: []kingbaseEndpoint{
{host: "10.0.0.1", port: 54321},
{host: "10.0.0.2", port: 54321},
},
},
{
name: "empty host yields no endpoints",
host: " ",
fallbackPort: 54321,
expected: []kingbaseEndpoint{},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
endpoints := splitHostEndpoints(test.host, test.fallbackPort)
if len(endpoints) != len(test.expected) {
t.Fatalf("unexpected endpoint count: got %#v want %#v", endpoints, test.expected)
}
for index, endpoint := range endpoints {
if endpoint != test.expected[index] {
t.Fatalf("endpoint %d mismatch: got %#v want %#v", index, endpoint, test.expected[index])
}
}
})
}
}
func TestClusterConnectEndpointsIgnoresNativeConnectionString(t *testing.T) {
if endpoints := clusterConnectEndpoints(connectParams{
Host: "10.0.0.1,10.0.0.2",
Port: 54321,
ConnectionString: "host=cluster.example.com port=54321",
}); endpoints != nil {
t.Fatalf("native connection string must not be split: %#v", endpoints)
}
// The JDBC URL the host app always passes is ignored by the DSN builder,
// so the host field must still be split for it.
endpoints := clusterConnectEndpoints(connectParams{
Host: "10.0.0.1,10.0.0.2",
Port: 54321,
ConnectionString: "jdbc:kingbase8://10.0.0.1:54321/test",
})
if len(endpoints) != 2 {
t.Fatalf("jdbc url must not block cluster splitting: %#v", endpoints)
}
}
func TestOpenAndPingDBSingleHostDSNUnchanged(t *testing.T) {
state := &failoverState{pingErrors: map[string]error{
"require@172.22.232.10": gokb.ErrSSLNotSupported,
}}
opener := failoverOpener{state: state}
db, err := openAndPingDB(connectParams{Host: "172.22.232.10", Port: 54321, Username: "system", Database: "test"}, time.Second, opener.open)
if err != nil {
t.Fatal(err)
}
defer db.Close()
dsns := state.connectionStrings()
if len(dsns) != 2 {
t.Fatalf("single host must keep the prefer fallback sequence: %v", dsns)
}
for _, dsn := range dsns {
if !strings.Contains(dsn, "host='172.22.232.10' port=54321 ") {
t.Fatalf("single host DSN changed: %s", dsn)
}
}
}
func TestOpenAndPingDBMultiHostFailsOverToReachableEndpoint(t *testing.T) {
state := &failoverState{pingErrors: map[string]error{
"require@203.0.113.10": errors.New("dial tcp 203.0.113.10:54321: connect: connection refused"),
}}
opener := failoverOpener{state: state}
db, err := openAndPingDB(connectParams{Host: "203.0.113.10,198.51.100.20", Port: 54321}, time.Second, opener.open)
if err != nil {
t.Fatal(err)
}
defer db.Close()
attempts := state.attempts()
if strings.Join(attempts, " -> ") != "require@203.0.113.10 -> require@198.51.100.20" {
t.Fatalf("unexpected failover order: %v", attempts)
}
dsns := state.connectionStrings()
if len(dsns) != 2 {
t.Fatalf("unexpected DSN count: %v", dsns)
}
if !strings.Contains(dsns[0], "host='203.0.113.10' port=54321") {
t.Fatalf("first DSN must target the first endpoint: %s", dsns[0])
}
if !strings.Contains(dsns[1], "host='198.51.100.20' port=54321") {
t.Fatalf("second DSN must target the second endpoint: %s", dsns[1])
}
}
func TestOpenAndPingDBMultiHostAppliesConfiguredPortToEveryEndpoint(t *testing.T) {
// Regression for #7885: the whole comma-joined host string used to reach
// gokb as one hostname (`lookup ip1,ip2: no such host`) while the
// configured port was never applied per endpoint.
state := &failoverState{pingErrors: map[string]error{
"require@10.10.0.1": errors.New("dial tcp: connection refused"),
"disable@10.10.0.1": errors.New("dial tcp: connection refused"),
}}
opener := failoverOpener{state: state}
db, err := openAndPingDB(connectParams{Host: "10.10.0.1,10.10.0.2", Port: 54321}, time.Second, opener.open)
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, dsn := range state.connectionStrings() {
if strings.Contains(dsn, "10.10.0.1,") || strings.Contains(dsn, ",10.10.0.2") {
t.Fatalf("comma-joined host leaked into DSN: %s", dsn)
}
if !strings.Contains(dsn, "port=54321") {
t.Fatalf("configured port missing from DSN: %s", dsn)
}
}
}
func TestOpenAndPingDBMultiHostPerEntryPorts(t *testing.T) {
state := &failoverState{pingErrors: map[string]error{
"require@10.0.0.1": errors.New("dial tcp: connection refused"),
}}
opener := failoverOpener{state: state}
db, err := openAndPingDB(connectParams{Host: "10.0.0.1:1000,10.0.0.2:2000", Port: 54321}, time.Second, opener.open)
if err != nil {
t.Fatal(err)
}
defer db.Close()
dsns := state.connectionStrings()
if !strings.Contains(dsns[0], "host='10.0.0.1' port=1000") {
t.Fatalf("first endpoint must use its embedded port: %s", dsns[0])
}
if !strings.Contains(dsns[1], "host='10.0.0.2' port=2000") {
t.Fatalf("second endpoint must use its embedded port: %s", dsns[1])
}
}
func TestOpenAndPingDBMultiHostSemicolonSeparatorFailsOver(t *testing.T) {
state := &failoverState{pingErrors: map[string]error{
"require@10.0.0.9": errors.New("dial tcp: connection refused"),
}}
opener := failoverOpener{state: state}
db, err := openAndPingDB(connectParams{Host: "10.0.0.9;10.0.0.10", Port: 54321}, time.Second, opener.open)
if err != nil {
t.Fatal(err)
}
defer db.Close()
attempts := state.attempts()
if len(attempts) != 2 || attempts[1] != "require@10.0.0.10" {
t.Fatalf("semicolon separated hosts must fail over in order: %v", attempts)
}
}
func TestOpenAndPingDBMultiHostAllEndpointsFail(t *testing.T) {
refused := errors.New("dial tcp: connection refused")
state := &failoverState{pingErrors: map[string]error{
"require@10.0.0.1": refused,
"require@10.0.0.2": refused,
}}
opener := failoverOpener{state: state}
db, err := openAndPingDB(connectParams{Host: "10.0.0.1,10.0.0.2", Port: 54321}, time.Second, opener.open)
if db != nil {
db.Close()
}
if err == nil {
t.Fatal("expected failure when every endpoint is down")
}
if !errors.Is(err, refused) {
t.Fatalf("aggregated error must wrap endpoint failures: %v", err)
}
for _, endpoint := range []string{"10.0.0.1:54321", "10.0.0.2:54321"} {
if !strings.Contains(err.Error(), endpoint) {
t.Fatalf("error must mention %s: %v", endpoint, err)
}
}
if !strings.Contains(err.Error(), "trying 2 endpoints") {
t.Fatalf("error must report the endpoint count: %v", err)
}
}
func TestOpenAndPingDBMultiHostKeepsSSLFallbackPerEndpoint(t *testing.T) {
state := &failoverState{pingErrors: map[string]error{
"require@10.0.0.1": gokb.ErrSSLNotSupported,
"disable@10.0.0.1": errors.New("dial tcp: connection refused"),
"require@10.0.0.2": gokb.ErrSSLNotSupported,
}}
opener := failoverOpener{state: state}
db, err := openAndPingDB(connectParams{Host: "10.0.0.1,10.0.0.2", Port: 54321}, time.Second, opener.open)
if err != nil {
t.Fatal(err)
}
defer db.Close()
attempts := state.attempts()
expected := "require@10.0.0.1 -> disable@10.0.0.1 -> require@10.0.0.2 -> disable@10.0.0.2"
if strings.Join(attempts, " -> ") != expected {
t.Fatalf("unexpected attempt sequence: %v", attempts)
}
}
func TestConnectAndTestConnectionSharePreferFallback(t *testing.T) {
for _, test := range []struct {
name string
@@ -1085,7 +1448,7 @@ func TestListIndexesFallsBackWhenWithOrdinalityIsUnsupported(t *testing.T) {
server := newServer()
server.db = openMetadataDB(t, state)
for range 2 {
for i := 0; i < 2; i++ {
indexes, err := server.listIndexes("PUBLIC", "orders")
if err != nil {
t.Fatal(err)
@@ -1576,7 +1939,7 @@ func TestListTriggerDefinitionsFallsBackToSingleArgument(t *testing.T) {
server := newServer()
server.db = openMetadataDB(t, state)
for range 2 {
for i := 0; i < 2; i++ {
definitions, err := server.listTriggerDefinitions("PUBLIC", "orders")
if err != nil {
t.Fatal(err)
@@ -1610,7 +1973,7 @@ func TestListTriggersFallsBackToV7InternalPredicate(t *testing.T) {
server := newServer()
server.db = openMetadataDB(t, state)
for range 2 {
for i := 0; i < 2; i++ {
triggers, err := server.listTriggers("PUBLIC", "orders")
if err != nil {
t.Fatal(err)
@@ -3079,7 +3442,7 @@ func TestObjectSourceFallsBackToV7RoutineDDLAndCachesChoice(t *testing.T) {
server := newServer()
server.db = openMetadataDB(t, state)
for range 2 {
for i := 0; i < 2; i++ {
source, err := server.getObjectSource("PUBLIC", "format_name", "FUNCTION")
if err != nil {
t.Fatal(err)
@@ -3911,7 +4274,7 @@ func TestSchemaConnSkipsInitialAndRepeatedEmptySchema(t *testing.T) {
server := newServer()
server.db = db
for range 2 {
for i := 0; i < 2; i++ {
conn, err := server.schemaConn(context.Background(), "")
if err != nil {
t.Fatal(err)
@@ -4037,7 +4400,7 @@ func TestExecuteQueryReappliesSchemaForRepeatedRequests(t *testing.T) {
server := newServer()
server.db = db
for range 2 {
for i := 0; i < 2; i++ {
if _, err := server.executeQuery(queryOptions{SQL: "SELECT 1", Schema: "sdy_smartsite", MaxRows: 10}); err != nil {
t.Fatal(err)
}
@@ -10,10 +10,14 @@ import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mongodb.ConnectionString;
import com.mongodb.MongoBulkWriteException;
import com.mongodb.MongoCredential;
import com.mongodb.MongoClientSettings;
import com.mongodb.ServerAddress;
import com.mongodb.bulk.BulkWriteError;
import com.mongodb.bulk.WriteConcernError;
import com.mongodb.client.AggregateIterable;
import com.mongodb.client.FindIterable;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
@@ -26,6 +30,7 @@ import com.mongodb.client.model.CollationMaxVariable;
import com.mongodb.client.model.CollationStrength;
import com.mongodb.client.model.CountOptions;
import com.mongodb.client.model.InsertManyOptions;
import com.mongodb.client.model.ReplaceOptions;
import com.mongodb.client.model.UpdateOptions;
import com.mongodb.client.result.UpdateResult;
import java.io.BufferedReader;
@@ -57,6 +62,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -80,11 +86,22 @@ public final class MongoAgent {
private static final JsonWriterSettings EXTENDED_JSON_SETTINGS = JsonWriterSettings.builder()
.outputMode(JsonMode.RELAXED)
.build();
// Driver 3.x names the canonical Extended JSON v2 output "EXTENDED"; RELAXED is its lossy
// sibling (plain numbers, ISO dates), so exports must use this one to keep BSON types.
private static final JsonWriterSettings CANONICAL_JSON_SETTINGS = JsonWriterSettings.builder()
.outputMode(JsonMode.EXTENDED)
.build();
private static final String LEGACY_SESSION_ID = "__legacy__";
private static final String DEFAULT_ID_INDEX_NAME = "_id_";
private static final int CLONE_INSERT_BATCH_SIZE = 1_000;
private static final int DEFAULT_FIND_CURSOR_BATCH_SIZE = 1_000;
private static final int MAX_FIND_CURSORS_PER_OWNER = 16;
private static final int MAX_FIND_CURSORS_TOTAL = 64;
private static final int MAX_SESSIONS = 256;
private static final ThreadLocal<MongoClient> CURRENT_CLIENT = new ThreadLocal<>();
private static final ThreadLocal<Set<String>> CURRENT_FIND_CURSOR_OWNER = new ThreadLocal<>();
private static final ConcurrentHashMap<String, MongoCursor<Document>> FIND_CURSORS = new ConcurrentHashMap<>();
private static final Set<String> LEGACY_FIND_CURSOR_IDS = ConcurrentHashMap.newKeySet();
private static MongoClient legacyClient;
private MongoAgent() {
@@ -1488,8 +1505,174 @@ public final class MongoAgent {
MongoClient client = requireClient();
String database = params.get("database").getAsString();
String collection = params.get("collection").getAsString();
client.getDatabase(database).getCollection(collection).insertMany(documents);
return Collections.singletonMap("affected_rows", documents.size());
InsertManyOptions options = new InsertManyOptions();
if (params.has("ordered") && !params.get("ordered").isJsonNull()) {
JsonElement ordered = params.get("ordered");
if (!ordered.isJsonPrimitive() || !ordered.getAsJsonPrimitive().isBoolean()) {
throw new IllegalArgumentException("ordered must be a boolean");
}
options.ordered(ordered.getAsBoolean());
}
try {
client.getDatabase(database).getCollection(collection).insertMany(documents, options);
return Collections.singletonMap("affected_rows", documents.size());
} catch (MongoBulkWriteException error) {
return bulkInsertResult(error);
}
}
static Map<String, Object> bulkInsertResult(MongoBulkWriteException error) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("affected_rows", error.getWriteResult().getInsertedCount());
List<Map<String, Object>> errors = new ArrayList<>();
for (BulkWriteError writeError : error.getWriteErrors()) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("index", writeError.getIndex());
item.put("code", writeError.getCode());
item.put("message", writeError.getMessage());
errors.add(item);
}
// The driver raises the same exception for write concern failures only (for example a
// wtimeout): there are no per-document errors then, but the batch must not look
// successful, so report the write concern error as a batch-wide rejection without index.
WriteConcernError writeConcernError = error.getWriteConcernError();
if (errors.isEmpty() && writeConcernError != null) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("code", writeConcernError.getCode());
item.put("message", writeConcernError.getMessage());
errors.add(item);
}
result.put("errors", errors);
return result;
}
private static Object startFindCursor(JsonObject params) {
Set<String> owner = CURRENT_FIND_CURSOR_OWNER.get();
if (owner != null && owner.size() >= MAX_FIND_CURSORS_PER_OWNER) {
throw new IllegalStateException("MongoDB find cursor limit reached: " + MAX_FIND_CURSORS_PER_OWNER);
}
// The per-owner budget keeps one session from starving the others; the process-wide cap
// only bounds how many server cursors this agent can hold in total.
if (FIND_CURSORS.size() >= MAX_FIND_CURSORS_TOTAL) {
throw new IllegalStateException("MongoDB find cursor limit reached: " + MAX_FIND_CURSORS_TOTAL);
}
MongoClient client = requireClient();
String database = params.get("database").getAsString();
String collection = params.get("collection").getAsString();
Document filterDoc = documentOrNull(params, "filter");
Document projectionDoc = documentOrNull(params, "projection");
Document sortDoc = documentOrNull(params, "sort");
Collation collation = collationOrNull(documentOrNull(params, "collation"));
int batchSize = findCursorBatchSize(params);
FindIterable<Document> iterable = client.getDatabase(database)
.getCollection(collection)
.find(filterDoc == null ? new Document() : filterDoc)
.batchSize(batchSize);
if (projectionDoc != null) {
iterable = iterable.projection(projectionDoc);
}
if (sortDoc != null) {
iterable = iterable.sort(sortDoc);
}
if (collation != null) {
iterable = iterable.collation(collation);
}
String cursorId = UUID.randomUUID().toString();
FIND_CURSORS.put(cursorId, iterable.iterator());
if (owner != null) {
owner.add(cursorId);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("cursor_id", cursorId);
result.put("batch_size", batchSize);
return result;
}
private static Object fetchFindCursor(JsonObject params) {
String cursorId = requiredCursorId(params);
MongoCursor<Document> cursor = FIND_CURSORS.get(cursorId);
if (cursor == null) {
throw new IllegalStateException("Find cursor not found");
}
int limit = params.has("limit") ? params.get("limit").getAsInt() : DEFAULT_FIND_CURSOR_BATCH_SIZE;
if (limit <= 0) {
throw new IllegalArgumentException("Find cursor limit must be a positive integer");
}
List<JsonObject> documents = new ArrayList<>();
try {
while (documents.size() < limit && cursor.hasNext()) {
documents.add(bsonToCanonicalExtendedJson(cursor.next()));
}
} catch (RuntimeException error) {
// A cursor that fails mid-iteration (server-side idle timeout, lost connection) can
// never be resumed, so drop it instead of leaking a slot in the cursor budgets.
closeFindCursorById(cursorId);
throw error;
}
boolean exhausted = !cursor.hasNext();
if (exhausted) {
closeFindCursorById(cursorId);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("documents", documents);
result.put("exhausted", exhausted);
return result;
}
private static Object closeFindCursor(JsonObject params) {
closeFindCursorById(requiredCursorId(params));
return Collections.singletonMap("ok", true);
}
private static String requiredCursorId(JsonObject params) {
String cursorId = stringOrNull(params, "cursor_id");
if (cursorId == null || cursorId.isBlank()) {
throw new IllegalArgumentException("cursor_id is required");
}
return cursorId;
}
private static int findCursorBatchSize(JsonObject params) {
if (!params.has("batch_size") || params.get("batch_size").isJsonNull()) {
return DEFAULT_FIND_CURSOR_BATCH_SIZE;
}
int batchSize = params.get("batch_size").getAsInt();
if (batchSize <= 0) {
throw new IllegalArgumentException("batch_size must be a positive integer");
}
return batchSize;
}
private static void closeFindCursorById(String cursorId) {
MongoCursor<Document> cursor = FIND_CURSORS.remove(cursorId);
if (cursor != null) {
try {
cursor.close();
} catch (RuntimeException ignored) {
// Closing a server cursor is best-effort; the client may already be gone.
}
}
Set<String> owner = CURRENT_FIND_CURSOR_OWNER.get();
if (owner != null) {
owner.remove(cursorId);
}
LEGACY_FIND_CURSOR_IDS.remove(cursorId);
}
private static void closeFindCursors(Set<String> cursorIds) {
for (String cursorId : new ArrayList<>(cursorIds)) {
closeFindCursorById(cursorId);
}
cursorIds.clear();
}
static void resetFindCursorsForTests() {
for (String cursorId : new ArrayList<>(FIND_CURSORS.keySet())) {
closeFindCursorById(cursorId);
}
LEGACY_FIND_CURSOR_IDS.clear();
}
static Object parseId(String id) {
@@ -1609,10 +1792,17 @@ public final class MongoAgent {
}
Document options = Document.parse(optionsJson);
for (String key : options.keySet()) {
if (!"arrayFilters".equals(key)) {
if (!"arrayFilters".equals(key) && !"upsert".equals(key)) {
throw new IllegalArgumentException("Unsupported update option: " + key);
}
}
Object rawUpsert = options.get("upsert");
if (rawUpsert != null) {
if (!(rawUpsert instanceof Boolean)) {
throw new IllegalArgumentException("upsert must be a boolean");
}
result.upsert((Boolean) rawUpsert);
}
Object rawFilters = options.get("arrayFilters");
if (rawFilters == null) {
return result;
@@ -1675,6 +1865,56 @@ public final class MongoAgent {
return true;
}
private static Object replaceDocument(JsonObject params) {
MongoClient c = requireClient();
String database = params.get("database").getAsString();
String collection = params.get("collection").getAsString();
String filterJson = params.get("filter_json").getAsString();
String replacementJson = params.get("replacement_json").getAsString();
String optionsJson = params.has("options_json") && !params.get("options_json").isJsonNull()
? params.get("options_json").getAsString()
: null;
var col = c.getDatabase(database).getCollection(collection);
Document filter = documentForWrite(filterJson);
Document replacement = documentForWrite(replacementJson);
requireReplacementDocument(replacement);
ReplaceOptions options = replaceOptionsForWrite(optionsJson);
var result = col.replaceOne(filter, replacement, options);
return Collections.singletonMap("modified_count", result.getModifiedCount());
}
/** replaceOne swaps the whole document; update operators here mean updateOne was intended. */
static void requireReplacementDocument(Document doc) {
for (String key : doc.keySet()) {
if (key.startsWith("$")) {
throw new IllegalArgumentException(
"Replacement document must not contain update operators such as " + key);
}
}
}
static ReplaceOptions replaceOptionsForWrite(String optionsJson) {
ReplaceOptions result = new ReplaceOptions();
if (optionsJson == null || optionsJson.trim().isEmpty()) {
return result;
}
Document options = Document.parse(optionsJson);
for (String key : options.keySet()) {
if (!"upsert".equals(key)) {
throw new IllegalArgumentException("Unsupported replace option: " + key);
}
}
Object rawUpsert = options.get("upsert");
if (rawUpsert != null) {
if (!(rawUpsert instanceof Boolean)) {
throw new IllegalArgumentException("upsert must be a boolean");
}
result.upsert((Boolean) rawUpsert);
}
return result;
}
static void requireBulkUpdateOperatorDocument(Document doc) {
if (!isUpdateOperatorDocument(doc)) {
// updateOne/updateMany are shell-style bulk updates here; replacements stay on the
@@ -1729,6 +1969,10 @@ public final class MongoAgent {
return preserveUnsafeLongsForJsonClients(doc, relaxed).getAsJsonObject();
}
static JsonObject bsonToCanonicalExtendedJson(Document doc) {
return JsonParser.parseString(doc.toJson(CANONICAL_JSON_SETTINGS)).getAsJsonObject();
}
private static JsonElement preserveUnsafeLongsForJsonClients(Object bsonValue, JsonElement relaxedValue) {
if (
bsonValue instanceof Long longValue &&
@@ -1856,12 +2100,17 @@ public final class MongoAgent {
case AgentProtocol.MONGO_METHOD_DROP_DATABASE -> dropDatabase(params);
case AgentProtocol.MONGO_METHOD_INSERT_DOCUMENT -> insertDocument(params);
case AgentProtocol.MONGO_METHOD_INSERT_DOCUMENTS -> insertDocuments(params);
case AgentProtocol.MONGO_METHOD_START_FIND_CURSOR -> startFindCursor(params);
case AgentProtocol.MONGO_METHOD_FETCH_FIND_CURSOR -> fetchFindCursor(params);
case AgentProtocol.MONGO_METHOD_CLOSE_FIND_CURSOR -> closeFindCursor(params);
case AgentProtocol.MONGO_METHOD_UPDATE_DOCUMENT -> updateDocument(params);
case AgentProtocol.MONGO_METHOD_UPDATE_DOCUMENTS -> updateDocuments(params);
case AgentProtocol.MONGO_METHOD_REPLACE_DOCUMENT -> replaceDocument(params);
case AgentProtocol.MONGO_METHOD_DELETE_DOCUMENT -> deleteDocument(params);
case AgentProtocol.MONGO_METHOD_DELETE_DOCUMENTS -> deleteDocuments(params);
case AgentProtocol.MONGO_METHOD_RUN_COMMAND -> runCommand(params);
case AgentProtocol.METHOD_DISCONNECT, AgentProtocol.METHOD_SHUTDOWN -> {
closeFindCursors(LEGACY_FIND_CURSOR_IDS);
closeLegacyClient();
if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
System.exit(0);
@@ -1909,6 +2158,7 @@ public final class MongoAgent {
static String handleRequest(String line, MongoClient client) {
if (client != null) {
CURRENT_CLIENT.set(client);
CURRENT_FIND_CURSOR_OWNER.set(LEGACY_FIND_CURSOR_IDS);
}
try {
JsonObject req = JsonParser.parseString(line).getAsJsonObject();
@@ -1936,6 +2186,7 @@ public final class MongoAgent {
} finally {
if (client != null) {
CURRENT_CLIENT.remove();
CURRENT_FIND_CURSOR_OWNER.remove();
}
}
}
@@ -2061,6 +2312,7 @@ public final class MongoAgent {
private static final class Session {
private final MongoClient client;
private final Set<String> findCursorIds = ConcurrentHashMap.newKeySet();
private Session(MongoClient client) {
this.client = client;
@@ -2068,10 +2320,12 @@ public final class MongoAgent {
private synchronized Object handle(String method, JsonObject params) {
CURRENT_CLIENT.set(client);
CURRENT_FIND_CURSOR_OWNER.set(findCursorIds);
try {
return dispatch(method, params);
} finally {
CURRENT_CLIENT.remove();
CURRENT_FIND_CURSOR_OWNER.remove();
}
}
@@ -2085,6 +2339,7 @@ public final class MongoAgent {
}
private synchronized void close() {
closeFindCursors(findCursorIds);
client.close();
}
}
@@ -1,5 +1,6 @@
package com.dbx.agent.mongodb;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -12,7 +13,12 @@ import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mongodb.MongoBulkWriteException;
import com.mongodb.MongoClientSettings;
import com.mongodb.ServerAddress;
import com.mongodb.bulk.BulkWriteError;
import com.mongodb.bulk.BulkWriteResult;
import com.mongodb.bulk.WriteConcernError;
import com.mongodb.client.AggregateIterable;
import com.mongodb.client.FindIterable;
import com.mongodb.client.ListCollectionsIterable;
@@ -40,8 +46,12 @@ import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.bson.BsonDocument;
import org.bson.BsonRegularExpression;
import org.bson.Document;
import org.bson.types.Binary;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -99,6 +109,11 @@ class MongoAgentTest {
Files.writeString(clientKeyPath, pkcs8Pem);
}
@AfterEach
void resetFindCursors() {
MongoAgent.resetFindCursorsForTests();
}
// ─── existing tests ───
@Test
@@ -128,6 +143,7 @@ class MongoAgentTest {
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_CLONE_COLLECTION));
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_RUN_COMMAND));
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_INSERT_DOCUMENTS));
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_FIND_CURSOR));
}
@Test
@@ -150,6 +166,7 @@ class MongoAgentTest {
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_CLONE_COLLECTION));
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_RUN_COMMAND));
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_INSERT_DOCUMENTS));
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_FIND_CURSOR));
}
@Test
@@ -894,6 +911,10 @@ class MongoAgentTest {
assertEquals("12", batches.get(0).get(1).getString("refid"));
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_INSERT_DOCUMENTS));
assertTrue(AgentProtocol.MONGO_LEGACY_CAPABILITIES.contains(AgentProtocol.CAPABILITY_MONGO_INSERT_DOCUMENTS));
assertTrue(AgentProtocol.MONGO_LEGACY_CAPABILITIES.contains(AgentProtocol.CAPABILITY_MONGO_FIND_CURSOR));
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_START_FIND_CURSOR));
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_FETCH_FIND_CURSOR));
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_CLOSE_FIND_CURSOR));
}
@Test
@@ -939,6 +960,293 @@ class MongoAgentTest {
}
}
@Test
void documentForWritePreservesExtendedJsonBsonTypes() {
Document document = MongoAgent.documentForWrite(
"{\"_id\":{\"$oid\":\"507f1f77bcf86cd799439011\"},"
+ "\"count\":{\"$numberLong\":\"9007199254740993\"},"
+ "\"when\":{\"$date\":\"2020-01-02T03:04:05.000Z\"}}"
);
assertEquals(new ObjectId("507f1f77bcf86cd799439011"), document.getObjectId("_id"));
assertEquals(9007199254740993L, document.get("count"));
assertTrue(document.get("when") instanceof Date);
assertEquals(Date.from(java.time.Instant.parse("2020-01-02T03:04:05.000Z")), document.getDate("when"));
}
@Test
void insertDocumentsRpcHonoursExplicitUnorderedInserts() {
List<List<Document>> batches = new ArrayList<>();
List<String> calls = new ArrayList<>();
List<Boolean> ordered = new ArrayList<>();
MongoClient client = recordingInsertMongoClient(calls, batches, ordered);
JsonObject params = new JsonObject();
params.addProperty("database", "app");
params.addProperty("collection", "users");
params.addProperty("docs_json", "[{\"name\":\"Ada\"}]");
params.addProperty("ordered", false);
JsonObject request = new JsonObject();
request.addProperty("jsonrpc", "2.0");
request.addProperty("id", 44);
request.addProperty("method", "insert_documents");
request.add("params", params);
JsonObject response = JsonParser.parseString(MongoAgent.handleRequest(request.toString(), client)).getAsJsonObject();
assertFalse(response.has("error"), response.toString());
assertEquals(List.of(false), ordered);
assertEquals(1, response.getAsJsonObject("result").get("affected_rows").getAsInt());
}
@Test
void insertDocumentsRpcPreservesExtendedJsonTypesInTheInsertManyBatch() {
List<List<Document>> batches = new ArrayList<>();
List<String> calls = new ArrayList<>();
MongoClient client = recordingInsertMongoClient(calls, batches);
String documents = "[{\"_id\":{\"$oid\":\"507f1f77bcf86cd799439011\"},"
+ "\"count\":{\"$numberLong\":\"9007199254740993\"}}]";
JsonObject response = insertDocumentsRpc(client, 45, "users", documents);
assertFalse(response.has("error"), response.toString());
assertEquals(1, batches.size());
Document written = batches.get(0).get(0);
assertEquals(new ObjectId("507f1f77bcf86cd799439011"), written.getObjectId("_id"));
assertEquals(9007199254740993L, written.get("count"));
assertTrue(written.get("count") instanceof Long);
}
@Test
void insertDocumentsRpcParsesCanonicalBinaryAndRegularExpression() {
List<List<Document>> batches = new ArrayList<>();
MongoClient client = recordingInsertMongoClient(new ArrayList<>(), batches);
String documents = "[{\"bin\":{\"$binary\":{\"base64\":\"AQID\",\"subType\":\"00\"}},"
+ "\"re\":{\"$regularExpression\":{\"pattern\":\"^test$\",\"options\":\"i\"}}}]";
JsonObject response = insertDocumentsRpc(client, 46, "users", documents);
assertFalse(response.has("error"), response.toString());
Document written = batches.get(0).get(0);
assertTrue(written.get("bin") instanceof Binary, String.valueOf(written.get("bin")));
Binary binary = (Binary) written.get("bin");
assertEquals(0, binary.getType());
assertArrayEquals(new byte[] {1, 2, 3}, binary.getData());
assertTrue(written.get("re") instanceof BsonRegularExpression, String.valueOf(written.get("re")));
BsonRegularExpression regex = (BsonRegularExpression) written.get("re");
assertEquals("^test$", regex.getPattern());
assertEquals("i", regex.getOptions());
}
@Test
void bulkInsertResultKeepsInsertedCountAndPerDocumentErrors() {
BulkWriteResult writeResult = BulkWriteResult.acknowledged(1, 0, 0, 0, List.of());
MongoBulkWriteException error = new MongoBulkWriteException(
writeResult,
List.of(new BulkWriteError(11000, "E11000 duplicate key", new BsonDocument(), 1)),
null,
new ServerAddress()
);
Map<String, Object> result = MongoAgent.bulkInsertResult(error);
assertEquals(1, result.get("affected_rows"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> errors = (List<Map<String, Object>>) result.get("errors");
assertEquals(1, errors.size());
assertEquals(1, errors.get(0).get("index"));
assertEquals(11000, errors.get(0).get("code"));
assertEquals("E11000 duplicate key", errors.get(0).get("message"));
}
@Test
void bulkInsertResultReportsWriteConcernFailures() {
// The driver raises MongoBulkWriteException with no write errors when only the write
// concern failed, so the batch must not come back looking successful.
MongoBulkWriteException error = new MongoBulkWriteException(
BulkWriteResult.acknowledged(0, 0, 0, 0, List.of()),
List.of(),
new WriteConcernError(64, "WriteConcernFailed", "waiting for replication timed out", new BsonDocument()),
new ServerAddress()
);
Map<String, Object> result = MongoAgent.bulkInsertResult(error);
assertEquals(0, result.get("affected_rows"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> errors = (List<Map<String, Object>>) result.get("errors");
assertEquals(1, errors.size());
assertEquals(64, errors.get(0).get("code"));
assertEquals("waiting for replication timed out", errors.get(0).get("message"));
assertFalse(errors.get(0).containsKey("index"));
}
@Test
void findCursorRpcPagesExtendedJsonDocumentsThenExhausts() {
List<String> calls = new ArrayList<>();
List<Document> documents = List.of(
new Document("_id", new ObjectId("507f1f77bcf86cd799439011"))
.append("n", 1L)
.append("bin", new Binary((byte) 0, new byte[] {1, 2, 3}))
.append("re", new BsonRegularExpression("^test$", "i")),
new Document("_id", new ObjectId("507f1f77bcf86cd799439012")).append("n", 2L)
);
MongoClient client = recordingFindMongoClient(calls, documents);
JsonObject start = findCursorRpc(client, 50, "start_find_cursor", cursorParams("users", 1, null));
assertFalse(start.has("error"), start.toString());
String cursorId = start.getAsJsonObject("result").get("cursor_id").getAsString();
assertEquals(1, start.getAsJsonObject("result").get("batch_size").getAsInt());
JsonObject first = findCursorRpc(client, 51, "fetch_find_cursor", cursorParams(null, 0, cursorId));
assertFalse(first.has("error"), first.toString());
assertEquals(1, first.getAsJsonObject("result").getAsJsonArray("documents").size());
assertFalse(first.getAsJsonObject("result").get("exhausted").getAsBoolean());
JsonObject firstDocument = first.getAsJsonObject("result").getAsJsonArray("documents").get(0).getAsJsonObject();
assertEquals(
"507f1f77bcf86cd799439011",
firstDocument.getAsJsonObject("_id").get("$oid").getAsString()
);
assertEquals("1", firstDocument.getAsJsonObject("n").get("$numberLong").getAsString());
JsonObject binary = firstDocument.getAsJsonObject("bin").getAsJsonObject("$binary");
assertEquals("AQID", binary.get("base64").getAsString(), firstDocument.toString());
assertEquals("00", binary.get("subType").getAsString(), firstDocument.toString());
JsonObject regex = firstDocument.getAsJsonObject("re").getAsJsonObject("$regularExpression");
assertEquals("^test$", regex.get("pattern").getAsString(), firstDocument.toString());
assertEquals("i", regex.get("options").getAsString(), firstDocument.toString());
JsonObject second = findCursorRpc(client, 52, "fetch_find_cursor", cursorParams(null, 0, cursorId));
assertFalse(second.has("error"), second.toString());
assertEquals(1, second.getAsJsonObject("result").getAsJsonArray("documents").size());
assertTrue(second.getAsJsonObject("result").get("exhausted").getAsBoolean());
JsonObject missing = findCursorRpc(client, 53, "fetch_find_cursor", cursorParams(null, 0, cursorId));
assertTrue(missing.has("error"), missing.toString());
assertEquals("Find cursor not found", missing.getAsJsonObject("error").get("message").getAsString());
assertTrue(calls.contains("find"));
assertTrue(calls.contains("batchSize:1"));
assertTrue(calls.contains("iterator"));
assertTrue(calls.contains("close"));
}
@Test
void closeFindCursorRpcIsIdempotent() {
List<String> calls = new ArrayList<>();
MongoClient client = recordingFindMongoClient(calls, List.of(new Document("n", 1)));
JsonObject start = findCursorRpc(client, 54, "start_find_cursor", cursorParams("users", 10, null));
String cursorId = start.getAsJsonObject("result").get("cursor_id").getAsString();
JsonObject closed = findCursorRpc(client, 55, "close_find_cursor", cursorParams(null, 0, cursorId));
JsonObject again = findCursorRpc(client, 56, "close_find_cursor", cursorParams(null, 0, cursorId));
assertFalse(closed.has("error"), closed.toString());
assertFalse(again.has("error"), again.toString());
assertEquals(1, calls.stream().filter("close"::equals).count());
}
@Test
void bsonToCanonicalExtendedJsonKeepsInt64DistinctFromJsonNumber() {
JsonObject json = MongoAgent.bsonToCanonicalExtendedJson(
new Document("n", 5L)
.append("bin", new Binary((byte) 0, new byte[] {1, 2, 3}))
.append("re", new BsonRegularExpression("^test$", "i"))
);
assertEquals("5", json.getAsJsonObject("n").get("$numberLong").getAsString());
assertFalse(json.get("n").isJsonPrimitive(), json.toString());
JsonObject binary = json.getAsJsonObject("bin").getAsJsonObject("$binary");
assertEquals("AQID", binary.get("base64").getAsString(), json.toString());
assertEquals("00", binary.get("subType").getAsString(), json.toString());
assertFalse(json.getAsJsonObject("bin").has("$type"), json.toString());
JsonObject regex = json.getAsJsonObject("re").getAsJsonObject("$regularExpression");
assertEquals("^test$", regex.get("pattern").getAsString(), json.toString());
assertEquals("i", regex.get("options").getAsString(), json.toString());
assertFalse(json.getAsJsonObject("re").has("$regex"), json.toString());
}
@Test
void disconnectClosesOpenFindCursors() {
List<String> calls = new ArrayList<>();
MongoClient client = recordingFindMongoClient(calls, List.of(new Document("n", 1L)));
JsonObject start = findCursorRpc(client, 57, "start_find_cursor", cursorParams("users", 10, null));
String cursorId = start.getAsJsonObject("result").get("cursor_id").getAsString();
JsonObject disconnect = JsonParser.parseString(MongoAgent.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":58,\"method\":\"disconnect\",\"params\":{}}"
)).getAsJsonObject();
JsonObject fetch = findCursorRpc(client, 59, "fetch_find_cursor", cursorParams(null, 0, cursorId));
assertFalse(disconnect.has("error"), disconnect.toString());
assertTrue(fetch.has("error"), fetch.toString());
assertEquals("Find cursor not found", fetch.getAsJsonObject("error").get("message").getAsString());
assertTrue(calls.contains("close"));
}
@Test
void fetchFailureReleasesTheCursorSlot() {
List<String> calls = new ArrayList<>();
MongoCursor<Document> failing = new MongoCursor<>() {
@Override
public void close() {
calls.add("close");
}
@Override
public boolean hasNext() {
throw new IllegalStateException("cursor id 42 not found");
}
@Override
public Document next() {
throw new IllegalStateException("cursor id 42 not found");
}
@Override
public Document tryNext() {
throw new IllegalStateException("cursor id 42 not found");
}
@Override
public ServerAddress getServerAddress() {
return new ServerAddress();
}
@Override
public com.mongodb.ServerCursor getServerCursor() {
return null;
}
};
MongoClient client = findMongoClientWithCursor(calls, failing);
JsonObject start = findCursorRpc(client, 60, "start_find_cursor", cursorParams("users", 10, null));
String cursorId = start.getAsJsonObject("result").get("cursor_id").getAsString();
JsonObject fetch = findCursorRpc(client, 61, "fetch_find_cursor", cursorParams(null, 0, cursorId));
JsonObject again = findCursorRpc(client, 62, "fetch_find_cursor", cursorParams(null, 0, cursorId));
assertTrue(fetch.has("error"), fetch.toString());
assertTrue(calls.contains("close"));
// A dead cursor is dropped, so the slot is free again instead of being held until disconnect.
assertEquals("Find cursor not found", again.getAsJsonObject("error").get("message").getAsString());
}
@Test
void startFindCursorEnforcesPerOwnerBudget() {
List<String> calls = new ArrayList<>();
MongoClient client = recordingFindMongoClient(calls, List.of(new Document("n", 1)));
for (int index = 0; index < 16; index++) {
JsonObject started = findCursorRpc(client, 100 + index, "start_find_cursor", cursorParams("users", 100, null));
assertFalse(started.has("error"), started.toString());
}
JsonObject rejected = findCursorRpc(client, 200, "start_find_cursor", cursorParams("users", 100, null));
assertTrue(rejected.has("error"), rejected.toString());
assertEquals(
"MongoDB find cursor limit reached: 16",
rejected.getAsJsonObject("error").get("message").getAsString()
);
}
@Test
void updateDocumentsRpcUsesDocumentOverloads() {
List<String> calls = new ArrayList<>();
@@ -981,13 +1289,83 @@ class MongoAgentTest {
assertEquals(322678, ((Document) options.getArrayFilters().get(0)).getInteger("item.id"));
}
@Test
void parsesUpsertUpdateOption() {
assertTrue(MongoAgent.updateOptionsForWrite("{\"upsert\":true}").isUpsert());
assertFalse(MongoAgent.updateOptionsForWrite("{\"upsert\":false}").isUpsert());
assertFalse(MongoAgent.updateOptionsForWrite("{}").isUpsert());
}
@Test
void parsesUpsertAlongsideArrayFilters() {
UpdateOptions options = MongoAgent.updateOptionsForWrite(
"{\"upsert\":true,\"arrayFilters\":[{\"item.id\":322678}]}"
);
assertTrue(options.isUpsert());
assertEquals(1, options.getArrayFilters().size());
}
@Test
void rejectsNonBooleanUpsertUpdateOption() {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> MongoAgent.updateOptionsForWrite("{\"upsert\":\"yes\"}")
);
assertEquals("upsert must be a boolean", error.getMessage());
}
@Test
void replaceDocumentMethodIsRecognizedOverJsonRpc() {
String response = MongoAgent.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"replace_document\","
+ "\"params\":{\"database\":\"app\",\"collection\":\"orders\",\"filter_json\":\"{\\\"_id\\\":1}\","
+ "\"replacement_json\":\"{\\\"name\\\":\\\"new\\\"}\"}}");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertEquals(11, json.get("id").getAsInt());
assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString());
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_REPLACE_DOCUMENT));
assertTrue(AgentProtocol.MONGO_LEGACY_CAPABILITIES.contains(AgentProtocol.CAPABILITY_MONGO_REPLACE_DOCUMENT));
}
@Test
void parsesReplaceOptions() {
assertTrue(MongoAgent.replaceOptionsForWrite("{\"upsert\":true}").isUpsert());
assertFalse(MongoAgent.replaceOptionsForWrite("{\"upsert\":false}").isUpsert());
assertFalse(MongoAgent.replaceOptionsForWrite(null).isUpsert());
assertFalse(MongoAgent.replaceOptionsForWrite("{}").isUpsert());
IllegalArgumentException unsupported = assertThrows(
IllegalArgumentException.class,
() -> MongoAgent.replaceOptionsForWrite("{\"arrayFilters\":[]}")
);
assertEquals("Unsupported replace option: arrayFilters", unsupported.getMessage());
IllegalArgumentException notBoolean = assertThrows(
IllegalArgumentException.class,
() -> MongoAgent.replaceOptionsForWrite("{\"upsert\":\"yes\"}")
);
assertEquals("upsert must be a boolean", notBoolean.getMessage());
}
@Test
void rejectsReplacementDocumentsWithUpdateOperators() {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> MongoAgent.requireReplacementDocument(Document.parse("{\"$set\":{\"a\":1}}"))
);
assertEquals("Replacement document must not contain update operators such as $set", error.getMessage());
// A plain document, including one with nested `$`-keys inside values, is fine.
MongoAgent.requireReplacementDocument(Document.parse("{\"name\":\"new\",\"meta\":{\"$ref\":\"x\"}}"));
}
@Test
void rejectsUnsupportedUpdateOptions() {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> MongoAgent.updateOptionsForWrite("{\"upsert\":true}")
() -> MongoAgent.updateOptionsForWrite("{\"collation\":{\"locale\":\"en\"}}")
);
assertEquals("Unsupported update option: upsert", error.getMessage());
assertEquals("Unsupported update option: collation", error.getMessage());
}
@Test
@@ -1388,9 +1766,140 @@ class MongoAgentTest {
return JsonParser.parseString(MongoAgent.handleRequest(request.toString(), client)).getAsJsonObject();
}
private static JsonObject cursorParams(String collection, int batchSize, String cursorId) {
JsonObject params = new JsonObject();
params.addProperty("database", "app");
if (collection != null) {
params.addProperty("collection", collection);
}
if (batchSize > 0) {
params.addProperty("batch_size", batchSize);
}
if (cursorId != null) {
params.addProperty("cursor_id", cursorId);
params.addProperty("limit", 1);
}
return params;
}
private static JsonObject findCursorRpc(MongoClient client, int id, String method, JsonObject params) {
JsonObject request = new JsonObject();
request.addProperty("jsonrpc", "2.0");
request.addProperty("id", id);
request.addProperty("method", method);
request.add("params", params);
return JsonParser.parseString(MongoAgent.handleRequest(request.toString(), client)).getAsJsonObject();
}
@SuppressWarnings("unchecked")
private static MongoClient recordingFindMongoClient(List<String> calls, List<Document> documents) {
MongoCursor<Document> cursor = new MongoCursor<>() {
private int index;
private boolean closed;
@Override
public void close() {
closed = true;
calls.add("close");
}
@Override
public boolean hasNext() {
return !closed && index < documents.size();
}
@Override
public Document next() {
return documents.get(index++);
}
@Override
public Document tryNext() {
return hasNext() ? next() : null;
}
@Override
public com.mongodb.ServerAddress getServerAddress() {
return new com.mongodb.ServerAddress();
}
@Override
public com.mongodb.ServerCursor getServerCursor() {
return null;
}
};
return findMongoClientWithCursor(calls, cursor);
}
/** Same proxy chain as [recordingFindMongoClient], for cursors that fail mid-iteration. */
@SuppressWarnings("unchecked")
private static MongoClient findMongoClientWithCursor(List<String> calls, MongoCursor<Document> cursor) {
FindIterable<Document> iterable = (FindIterable<Document>) Proxy.newProxyInstance(
FindIterable.class.getClassLoader(),
new Class<?>[] {FindIterable.class},
(proxy, method, args) -> {
switch (method.getName()) {
case "batchSize" -> {
calls.add("batchSize:" + args[0]);
return proxy;
}
case "projection", "sort", "collation", "filter", "skip", "limit" -> {
calls.add(method.getName());
return proxy;
}
case "iterator" -> {
calls.add("iterator");
return cursor;
}
default -> throw new UnsupportedOperationException(method.getName());
}
}
);
MongoCollection<Document> collection = (MongoCollection<Document>) Proxy.newProxyInstance(
MongoCollection.class.getClassLoader(),
new Class<?>[] {MongoCollection.class},
(proxy, method, args) -> {
if ("find".equals(method.getName())) {
calls.add("find");
return iterable;
}
throw new UnsupportedOperationException(method.getName());
}
);
MongoDatabase database = (MongoDatabase) Proxy.newProxyInstance(
MongoDatabase.class.getClassLoader(),
new Class<?>[] {MongoDatabase.class},
(proxy, method, args) -> {
if ("getCollection".equals(method.getName())) {
calls.add("getCollection:" + args[0]);
return collection;
}
throw new UnsupportedOperationException(method.getName());
}
);
return (MongoClient) Proxy.newProxyInstance(
MongoClient.class.getClassLoader(),
new Class<?>[] {MongoClient.class},
(proxy, method, args) -> {
if ("getDatabase".equals(method.getName())) {
calls.add("getDatabase:" + args[0]);
return database;
}
throw new UnsupportedOperationException(method.getName());
}
);
}
@SuppressWarnings("unchecked")
private static MongoClient recordingInsertMongoClient(
List<String> calls, List<List<Document>> batches
) {
return recordingInsertMongoClient(calls, batches, null);
}
@SuppressWarnings("unchecked")
private static MongoClient recordingInsertMongoClient(
List<String> calls, List<List<Document>> batches, List<Boolean> ordered
) {
MongoCollection<Document> collection = (MongoCollection<Document>) Proxy.newProxyInstance(
MongoCollection.class.getClassLoader(),
@@ -1400,6 +1909,13 @@ class MongoAgentTest {
List<Document> batch = ((List<Document>) args[0]).stream().map(Document::new).toList();
batches.add(batch);
calls.add("insertMany:" + batch.size());
if (ordered != null) {
boolean isOrdered = true;
if (args.length > 1 && args[1] instanceof InsertManyOptions options) {
isOrdered = options.isOrdered();
}
ordered.add(isOrdered);
}
return null;
}
throw new UnsupportedOperationException(method.getName());
@@ -14,6 +14,7 @@ import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.ObjectSource;
import com.dbx.agent.OracleObjectPrivilege;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
@@ -556,7 +557,141 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
// Table comment is optional; DDL generation should still succeed without it.
}
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, java.util.Collections.emptyList(), false, true, tableComment);
String ddl = DdlBuilder.buildTableDdl(
schema,
table,
getColumns(schema, table),
indexes,
foreignKeys,
java.util.Collections.emptyList(),
false,
true,
tableComment
);
try {
String owner = normalizeSchema(schema);
return DdlBuilder.appendTrailingSql(ddl, queryObjectGrantSql(owner, table));
} catch (RuntimeException | SQLException ignored) {
// Privilege metadata is optional; CREATE TABLE DDL should still succeed without it.
return ddl;
}
}
private String queryObjectGrantSql(String owner, String table) throws SQLException {
List<OracleObjectPrivilege> privileges = new ArrayList<>();
privileges.addAll(queryTablePrivileges(owner, table));
try {
privileges.addAll(queryColumnPrivileges(owner, table));
} catch (SQLException ignored) {
// Column privileges are optional when table-level grants are available.
}
return DdlBuilder.buildOracleObjectGrantSql(owner, table, privileges);
}
private List<OracleObjectPrivilege> queryTablePrivileges(String owner, String table) throws SQLException {
// DBA_TAB_PRIVS shows every object grant (OWNER column). ALL_TAB_PRIVS is limited to
// grants where the session user is owner/grantor/grantee (or PUBLIC), so admins
// browsing another schema often see nothing unless we prefer the DBA view.
return queryFirstAvailablePrivileges(tablePrivilegeQueries(), owner, table, null);
}
private List<OracleObjectPrivilege> queryColumnPrivileges(String owner, String table) throws SQLException {
return queryFirstAvailablePrivileges(columnPrivilegeQueries(), owner, table, "COLUMN_NAME");
}
private List<OracleObjectPrivilege> queryFirstAvailablePrivileges(
List<String> queries,
String owner,
String table,
String columnNameField
) throws SQLException {
SQLException firstError = null;
for (String sql : queries) {
try {
return queryPrivileges(sql, owner, table, columnNameField);
} catch (SQLException error) {
if (firstError == null) {
firstError = error;
} else {
firstError.addSuppressed(error);
}
}
}
throw firstError == null ? new SQLException("No privilege views available") : firstError;
}
private static List<String> tablePrivilegeQueries() {
return List.of(
"""
SELECT GRANTEE, PRIVILEGE, GRANTABLE
FROM DBA_TAB_PRIVS
WHERE OWNER = ? AND TABLE_NAME = ?
ORDER BY GRANTEE, PRIVILEGE
""".stripIndent().trim(),
"""
SELECT GRANTEE, PRIVILEGE, GRANTABLE
FROM SYS.DBA_TAB_PRIVS
WHERE OWNER = ? AND TABLE_NAME = ?
ORDER BY GRANTEE, PRIVILEGE
""".stripIndent().trim(),
"""
SELECT GRANTEE, PRIVILEGE, GRANTABLE
FROM ALL_TAB_PRIVS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY GRANTEE, PRIVILEGE
""".stripIndent().trim()
);
}
private static List<String> columnPrivilegeQueries() {
return List.of(
"""
SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME
FROM DBA_COL_PRIVS
WHERE OWNER = ? AND TABLE_NAME = ?
ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE
""".stripIndent().trim(),
"""
SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME
FROM SYS.DBA_COL_PRIVS
WHERE OWNER = ? AND TABLE_NAME = ?
ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE
""".stripIndent().trim(),
"""
SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME
FROM ALL_COL_PRIVS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE
""".stripIndent().trim()
);
}
private List<OracleObjectPrivilege> queryPrivileges(
String sql,
String owner,
String table,
String columnNameField
) throws SQLException {
List<OracleObjectPrivilege> result = new ArrayList<>();
try (var stmt = requireConnection().prepareStatement(sql)) {
stmt.setString(1, owner);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new OracleObjectPrivilege(
rs.getString("GRANTEE"),
rs.getString("PRIVILEGE"),
isYes(rs.getString("GRANTABLE")),
columnNameField == null ? null : rs.getString(columnNameField)
));
}
}
}
return result;
}
private static boolean isYes(String value) {
return value != null && "YES".equalsIgnoreCase(value.trim());
}
@Override
@@ -150,6 +150,46 @@ class OceanBaseOracleAgentTest {
Assertions.assertEquals(List.of(), queryTimeouts);
}
@Test
void supportsCommitAndRollbackForInteractiveTransactions() {
List<String> calls = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, transactionConnection(calls));
agent.beginManualTransaction(null);
Assertions.assertEquals(List.of("setAutoCommit:false"), calls);
agent.commitManualTransaction();
Assertions.assertEquals(List.of("setAutoCommit:false", "commit", "setAutoCommit:true"), calls);
agent.beginManualTransaction(null);
agent.rollbackManualTransaction();
Assertions.assertEquals(
List.of("setAutoCommit:false", "commit", "setAutoCommit:true", "setAutoCommit:false", "rollback", "setAutoCommit:true"),
calls
);
}
@Test
void failedCommitKeepsTransactionOpenSoItCanBeRolledBack() throws SQLException {
List<String> calls = new ArrayList<>();
boolean[] failCommit = {true};
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
Connection connection = transactionConnection(calls, failCommit);
TestSupport.setPrivateConnection(agent, connection);
agent.beginManualTransaction(null);
Assertions.assertThrows(RuntimeException.class, agent::commitManualTransaction);
Assertions.assertFalse(connection.getAutoCommit());
failCommit[0] = false;
Assertions.assertDoesNotThrow(agent::rollbackManualTransaction);
Assertions.assertTrue(connection.getAutoCommit());
Assertions.assertEquals(
List.of("setAutoCommit:false", "commit", "rollback", "setAutoCommit:true"),
calls
);
}
@Test
void synchronizesSessionTimeoutForEveryQueryEntryPoint() {
List<String> sql = new ArrayList<>();
@@ -439,7 +479,15 @@ class OceanBaseOracleAgentTest {
columnResultSet(new Object[][]{
{"CREATED_AT", "TIMESTAMP", "N", null, null, null, null, "SYSDATE", "Created timestamp", 0},
{"INTERNAL_NOTE", "VARCHAR2", "Y", null, null, 100, 100, null, " ", 0}
})
}),
resultSet(
new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"},
new Object[][]{}
),
resultSet(
new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"},
new Object[][]{}
)
));
String ddl = agent.getTableDdl("APP", "AUDIT_LOG");
@@ -451,6 +499,147 @@ class OceanBaseOracleAgentTest {
);
Assertions.assertTrue(ddl.contains("\"INTERNAL_NOTE\" VARCHAR2(100)"), ddl);
Assertions.assertFalse(ddl.contains("\"INTERNAL_NOTE\" IS"), ddl);
Assertions.assertFalse(ddl.contains("GRANT "), ddl);
}
@Test
void tableDdlAppendsObjectGrantsFromDictionaryViews() {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql,
resultSet(
new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"},
new Object[][]{}
),
resultSet(
new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"},
new Object[][]{}
),
resultSet(
new String[]{"COMMENTS"},
new Object[][]{{null}}
),
columnResultSet(new Object[][]{
{"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1},
{"NAME", "VARCHAR2", "Y", null, null, 100, 100, null, null, 0}
}),
resultSet(
new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"},
new Object[][]{
{"READER", "SELECT", "NO"},
{"READER", "INSERT", "NO"},
{"ADMIN", "SELECT", "YES"}
}
),
resultSet(
new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"},
new Object[][]{
{"ANALYST", "NAME", "UPDATE", "NO"}
}
)
));
String ddl = agent.getTableDdl("APP", "USERS");
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
Assertions.assertTrue(ddl.contains("GRANT SELECT, INSERT ON \"APP\".\"USERS\" TO \"READER\";"), ddl);
Assertions.assertTrue(
ddl.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"ADMIN\" WITH GRANT OPTION;"),
ddl
);
Assertions.assertTrue(
ddl.contains("GRANT UPDATE (\"NAME\") ON \"APP\".\"USERS\" TO \"ANALYST\";"),
ddl
);
Assertions.assertTrue(
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")),
String.valueOf(sql)
);
Assertions.assertTrue(
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_COL_PRIVS")),
String.valueOf(sql)
);
}
@Test
void tableDdlFallsBackToAllTabPrivsWhenDbaViewUnavailable() {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, dbaFallbackPrivilegeConnection(sql,
resultSet(
new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"},
new Object[][]{}
),
resultSet(
new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"},
new Object[][]{}
),
resultSet(
new String[]{"COMMENTS"},
new Object[][]{{null}}
),
columnResultSet(new Object[][]{
{"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1}
}),
resultSet(
new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"},
new Object[][]{
{"READER", "SELECT", "NO"}
}
),
resultSet(
new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"},
new Object[][]{}
)
));
String ddl = agent.getTableDdl("APP", "USERS");
Assertions.assertTrue(ddl.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"READER\";"), ddl);
Assertions.assertTrue(
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")),
String.valueOf(sql)
);
Assertions.assertTrue(
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM ALL_TAB_PRIVS")),
String.valueOf(sql)
);
Assertions.assertTrue(
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM SYS.DBA_TAB_PRIVS")),
String.valueOf(sql)
);
}
@Test
void tableDdlKeepsCreateTableWhenPrivilegeQueriesFail() {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, privilegeFailingDdlConnection(sql,
resultSet(
new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"},
new Object[][]{}
),
resultSet(
new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"},
new Object[][]{}
),
resultSet(
new String[]{"COMMENTS"},
new Object[][]{{null}}
),
columnResultSet(new Object[][]{
{"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1}
})
));
String ddl = agent.getTableDdl("APP", "USERS");
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
Assertions.assertFalse(ddl.contains("GRANT "), ddl);
Assertions.assertTrue(
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")),
String.valueOf(sql)
);
}
private static ResultSet columnResultSet(Object[][] rows) {
@@ -496,6 +685,95 @@ class OceanBaseOracleAgentTest {
});
}
private static Connection privilegeFailingDdlConnection(List<String> sql, ResultSet... resultSets) {
int[] resultSetIndex = {0};
return proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
String statementSql = String.valueOf(args[0]);
sql.add(statementSql);
String normalized = statementSql.toUpperCase(Locale.ROOT);
if (normalized.contains("DBA_TAB_PRIVS")
|| normalized.contains("ALL_TAB_PRIVS")
|| normalized.contains("DBA_COL_PRIVS")
|| normalized.contains("ALL_COL_PRIVS")) {
PreparedStatement failing = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
if ("executeQuery".equals(statementMethod.getName())) {
throw new SQLException("privilege view unavailable");
}
if ("setString".equals(statementMethod.getName())
|| "setInt".equals(statementMethod.getName())
|| "close".equals(statementMethod.getName())) {
return null;
}
return defaultValue(statementMethod.getReturnType());
});
return failing;
}
PreparedStatement statement = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
if ("executeQuery".equals(statementMethod.getName())) {
int current = Math.min(resultSetIndex[0], resultSets.length - 1);
resultSetIndex[0] += 1;
return resultSets[current];
}
if ("setString".equals(statementMethod.getName())
|| "setInt".equals(statementMethod.getName())
|| "close".equals(statementMethod.getName())) {
return null;
}
return defaultValue(statementMethod.getReturnType());
});
return statement;
}
if ("isClosed".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
}
private static Connection dbaFallbackPrivilegeConnection(List<String> sql, ResultSet... resultSets) {
int[] resultSetIndex = {0};
return proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
String statementSql = String.valueOf(args[0]);
sql.add(statementSql);
String normalized = statementSql.toUpperCase(Locale.ROOT);
if (normalized.contains("DBA_TAB_PRIVS") || normalized.contains("DBA_COL_PRIVS")) {
PreparedStatement failing = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
if ("executeQuery".equals(statementMethod.getName())) {
throw new SQLException("DBA privilege view unavailable");
}
if ("setString".equals(statementMethod.getName())
|| "setInt".equals(statementMethod.getName())
|| "close".equals(statementMethod.getName())) {
return null;
}
return defaultValue(statementMethod.getReturnType());
});
return failing;
}
PreparedStatement statement = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
if ("executeQuery".equals(statementMethod.getName())) {
int current = Math.min(resultSetIndex[0], resultSets.length - 1);
resultSetIndex[0] += 1;
return resultSets[current];
}
if ("setString".equals(statementMethod.getName())
|| "setInt".equals(statementMethod.getName())
|| "close".equals(statementMethod.getName())) {
return null;
}
return defaultValue(statementMethod.getReturnType());
});
return statement;
}
if ("isClosed".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
}
private static Connection schemaConnection(List<String> sql, ResultSet resultSet) {
Statement statement = proxy(Statement.class, (method, args) -> {
if ("executeQuery".equals(method.getName())) {
@@ -609,6 +887,37 @@ class OceanBaseOracleAgentTest {
return executionConnection(sql, new ArrayList<>(), List.of());
}
private static Connection transactionConnection(List<String> calls) {
return transactionConnection(calls, new boolean[] {false});
}
private static Connection transactionConnection(List<String> calls, boolean[] failCommit) {
boolean[] autoCommit = {true};
return proxy(Connection.class, (method, args) -> {
switch (method.getName()) {
case "getAutoCommit":
return autoCommit[0];
case "setAutoCommit":
autoCommit[0] = (Boolean) args[0];
calls.add("setAutoCommit:" + autoCommit[0]);
return null;
case "commit":
calls.add("commit");
if (failCommit[0]) {
throw new SQLException("commit failed");
}
return null;
case "rollback":
calls.add("rollback");
return null;
case "isClosed":
return false;
default:
return defaultValue(method.getReturnType());
}
});
}
private static Connection executionConnection(
List<String> sql,
List<Integer> queryTimeouts,
+189 -72
View File
@@ -30,7 +30,11 @@ import (
const protocolVersion = 1
const multiSessionProtocolVersion = 2
const defaultMaxRows = 1000
const oracleDefaultPrefetchRows = "100"
// A normal data-grid page contains 100 rows. Prefetching a little more than
// one page reduces round trips while reading subsequent pages without
// buffering the much larger export limit.
const oracleDefaultPrefetchRows = "256"
const oracleCharsetZHS32GB18030 = 854
const legacyAgentSessionID = "__legacy__"
const maxAgentSessions = 256
@@ -636,19 +640,28 @@ func newRuntimeServer() *runtimeServer {
return &runtimeServer{sessions: map[string]*agentSession{}}
}
func (r *runtimeServer) handleLine(line string) (response, bool) {
func (r *runtimeServer) handleLine(line string) (resp response, shutdown bool) {
var req request
// Last-resort guard: a panic anywhere on the request path must not kill
// the agent process and break the RPC stream, so convert it to a readable
// error response instead of an end-of-stream failure.
defer func() {
if recovered := recover(); recovered != nil {
resp = errorResponse(req.ID, fmt.Errorf("agent request panic: %v", recovered))
shutdown = false
}
}()
if err := json.Unmarshal([]byte(line), &req); err != nil {
return errorResponse(nil, err), false
}
if len(req.ID) == 0 {
req.ID = json.RawMessage("1")
}
result, shutdown, err := r.dispatch(req.Method, req.Params)
result, shouldShutdown, err := r.dispatch(req.Method, req.Params)
if err != nil {
return errorResponse(req.ID, err), false
}
return response{JSONRPC: "2.0", ID: req.ID, Result: result}, shutdown
return response{JSONRPC: "2.0", ID: req.ID, Result: result}, shouldShutdown
}
func (r *runtimeServer) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) {
@@ -3388,6 +3401,15 @@ func (s *server) getViewSource(schema, name string) (string, error) {
return "", err
}
viewName := strings.TrimSpace(name)
var source string
viewsErr := db.QueryRow(
"SELECT TEXT FROM ALL_VIEWS WHERE OWNER = :1 AND VIEW_NAME = :2",
schema, viewName,
).Scan(&source)
if viewsErr == nil && strings.TrimSpace(source) != "" {
return strings.TrimSpace(source), nil
}
var ddl string
metadataErr := db.QueryRow(
"SELECT DBMS_METADATA.GET_DDL('VIEW', :1, :2) FROM DUAL",
@@ -3397,22 +3419,11 @@ func (s *server) getViewSource(schema, name string) (string, error) {
return strings.TrimSpace(ddl), nil
}
var source string
fallbackErr := db.QueryRow(
"SELECT TEXT FROM ALL_VIEWS WHERE OWNER = :1 AND VIEW_NAME = :2",
schema, viewName,
).Scan(&source)
if fallbackErr == nil && strings.TrimSpace(source) != "" {
return strings.TrimSpace(source), nil
}
if fallbackErr != nil && !errors.Is(fallbackErr, sql.ErrNoRows) {
if metadataErr != nil {
return "", fmt.Errorf(
"failed to load view source for %s.%s: DBMS_METADATA: %v; ALL_VIEWS: %w",
schema, viewName, metadataErr, fallbackErr,
)
}
return "", fmt.Errorf("failed to load view source for %s.%s from ALL_VIEWS: %w", schema, viewName, fallbackErr)
if viewsErr != nil && !errors.Is(viewsErr, sql.ErrNoRows) && metadataErr != nil {
return "", fmt.Errorf(
"failed to load view source for %s.%s: ALL_VIEWS: %v; DBMS_METADATA: %w",
schema, viewName, viewsErr, metadataErr,
)
}
return "", fmt.Errorf("view source not found: %s.%s", schema, viewName)
}
@@ -3762,36 +3773,72 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes
HasMore: false,
}, err
}
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded(sqlText, opts.TimeoutSecs, opts.DeferLOBs)
result, session, err := s.runPagedOracleSelect(sqlText, opts, pageSize, start)
if err != nil {
return queryPageResult{}, err
}
columns, err := rows.Columns()
if err != nil {
s.closeRows(rows)
return queryPageResult{}, err
}
columnTypes := columnTypeNames(rows)
maxRows := opts.MaxRows
if maxRows <= 0 {
maxRows = defaultMaxRows
}
session := &querySession{rows: rows, columns: columns, columnTypes: columnTypes, remaining: maxRows}
result, err := readQuerySessionPage(session, pageSize)
result.ExecutionTimeMS = time.Since(start).Milliseconds()
if err != nil {
s.closeRows(rows)
return queryPageResult{}, err
}
if result.HasMore {
if session != nil {
sessionID := s.storeQuerySession(session)
result.SessionID = &sessionID
} else {
s.closeRows(rows)
}
return result, nil
}
// runPagedOracleSelect executes a SELECT through the value-rewrite path and
// reads its first page. It returns the live session when more pages remain.
// When the first page panics while decoding an unsupported column type (before
// any row was streamed to the client), it retries once with the placeholder
// projection so the remaining columns stay readable; panics on later pages
// surface as errors instead.
func (s *server) runPagedOracleSelect(sqlText string, opts queryOptions, pageSize int, start time.Time) (queryPageResult, *querySession, error) {
for attempt := 0; ; attempt++ {
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded(sqlText, opts.TimeoutSecs, opts.DeferLOBs)
if err != nil {
return queryPageResult{}, nil, err
}
columns, err := rows.Columns()
if err != nil {
s.closeRows(rows)
return queryPageResult{}, nil, err
}
columnTypes := columnTypeNames(rows)
maxRows := opts.MaxRows
if maxRows <= 0 {
maxRows = defaultMaxRows
}
session := &querySession{rows: rows, columns: columns, columnTypes: columnTypes, remaining: maxRows}
result, err := readQuerySessionPage(session, pageSize)
result.ExecutionTimeMS = time.Since(start).Milliseconds()
if err != nil {
s.closeRows(rows)
var panicErr oracleDriverPanicError
if attempt == 0 && errors.As(err, &panicErr) {
if placeholder, ok := oraclePlaceholderRetrySQL(sqlText, s.loadOracleColumnMeta); ok {
sqlText = placeholder
continue
}
}
return queryPageResult{}, nil, err
}
if result.HasMore {
return result, session, nil
}
s.closeRows(rows)
return result, nil, nil
}
}
// oraclePlaceholderRetrySQL returns the placeholder projection used to retry a
// query whose first page panicked while decoding values of an unsupported
// column type. It reports false when no placeholder rewrite applies.
func oraclePlaceholderRetrySQL(sqlText string, loadColumns oracleColumnMetaLoader) (string, bool) {
placeholder, err := rewriteOracleSelectSQL(sqlText, loadColumns, true)
if err != nil || placeholder == sqlText {
return "", false
}
return placeholder, true
}
func (s *server) fetchQueryPage(sessionID string, pageSize int) (queryPageResult, error) {
session := s.sessions[sessionID]
if session == nil {
@@ -3828,32 +3875,13 @@ func (s *server) startTableRead(opts queryOptions, pageSize int) (queryPageResul
if !isQuerySQL(sqlText) {
return queryPageResult{}, errors.New("table read requires a SELECT query")
}
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded(sqlText, opts.TimeoutSecs, opts.DeferLOBs)
result, session, err := s.runPagedOracleSelect(sqlText, opts, pageSize, start)
if err != nil {
return queryPageResult{}, err
}
columns, err := rows.Columns()
if err != nil {
s.closeRows(rows)
return queryPageResult{}, err
}
columnTypes := columnTypeNames(rows)
maxRows := opts.MaxRows
if maxRows <= 0 {
maxRows = defaultMaxRows
}
session := &querySession{rows: rows, columns: columns, columnTypes: columnTypes, remaining: maxRows}
result, err := readQuerySessionPage(session, pageSize)
result.ExecutionTimeMS = time.Since(start).Milliseconds()
if err != nil {
s.closeRows(rows)
return queryPageResult{}, err
}
if result.HasMore {
if session != nil {
sessionID := s.storeTableReadSession(session)
result.SessionID = &sessionID
} else {
s.closeRows(rows)
}
return result, nil
}
@@ -3912,11 +3940,21 @@ func (s *server) closeAllQuerySessions() {
}
}
func readQuerySessionPage(session *querySession, pageSize int) (queryPageResult, error) {
func readQuerySessionPage(session *querySession, pageSize int) (result queryPageResult, err error) {
// go-ora can panic while decoding row values for column types it does not
// support (for example custom object types). Convert those panics to errors
// so row iteration failures never terminate the agent process and break
// the RPC stream.
defer func() {
if recovered := recover(); recovered != nil {
result = queryPageResult{}
err = oracleDriverPanicError{value: recovered}
}
}()
if pageSize <= 0 {
pageSize = defaultMaxRows
}
result := queryPageResult{Columns: session.columns, ColumnTypes: session.columnTypes, Rows: [][]any{}, SessionID: nil, HasMore: false}
result = queryPageResult{Columns: session.columns, ColumnTypes: session.columnTypes, Rows: [][]any{}, SessionID: nil, HasMore: false}
for len(result.Rows) < pageSize && session.remaining > 0 {
if session.pending != nil {
result.Rows = append(result.Rows, session.pending)
@@ -3938,6 +3976,13 @@ func readQuerySessionPage(session *querySession, pageSize int) (queryPageResult,
result.Truncated = true
return result, nil
}
// A full page is enough evidence that another page may exist. Do not read
// one extra row just to decide has_more: with a prefetch boundary this
// forces another Oracle round trip before the first page can be displayed.
if len(result.Rows) >= pageSize {
result.HasMore = true
return result, nil
}
if session.rows.Next() {
row, err := scanRow(session.rows, len(session.columns), session.columnTypes)
if err != nil {
@@ -4124,7 +4169,20 @@ func (s *server) queryRowsWithOracleValueRewriteIfNeeded(sqlText string, timeout
if errors.As(err, &panicErr) {
rewritten, rewriteErr := rewriteOracleSelectSQL(sqlText, s.loadOracleColumnMeta, false)
if rewriteErr == nil && rewritten != sqlText {
return s.queryRowsWithTimeout(rewritten, nil, timeoutSecs)
rewrittenRows, rewrittenErr := s.queryRowsWithTimeout(rewritten, nil, timeoutSecs)
if rewrittenErr == nil {
return rewrittenRows, nil
}
// When the server's ArcSDE extproc agent is broken, the
// SDE.ST_AsText rewrite itself fails with ORA-28595 and would
// hide the remaining columns; retry once with the placeholder
// projection so the query stays readable.
if placeholder, ok := oracleExtprocFallbackSQL(sqlText, s.loadOracleColumnMeta, rewrittenErr); ok {
if placeholderRows, placeholderErr := s.queryRowsWithTimeout(placeholder, nil, timeoutSecs); placeholderErr == nil {
return placeholderRows, nil
}
}
return nil, rewrittenErr
}
}
return nil, err
@@ -4156,6 +4214,34 @@ func oracleColumnTypeNamesContainXMLType(typeNames []string) bool {
return false
}
// isOracleExtprocAgentFailure reports whether err is an Oracle extproc agent
// failure (ORA-28595, typically raised through SDE.ST_GEOMETRY_SHAPELIB_PKG or
// SDE.ST_GEOMETRY_OPERATORS) that makes every SDE.ST_GEOMETRY function call
// unusable on the server. Matching stays deliberately narrow: the ORA code
// must be present; SDE package names alone never trigger it.
func isOracleExtprocAgentFailure(err error) bool {
if err == nil {
return false
}
// Accept both the "ORA-28595" form and a bare "28595" code.
return strings.Contains(strings.ToUpper(err.Error()), "28595")
}
// oracleExtprocFallbackSQL returns the placeholder projection to retry with
// when the panic-fallback SDE.ST_AsText rewrite failed because of a broken
// extproc agent (ORA-28595). It reports false when the error is unrelated or
// no placeholder rewrite applies.
func oracleExtprocFallbackSQL(sqlText string, loadColumns oracleColumnMetaLoader, rewriteErr error) (string, bool) {
if !isOracleExtprocAgentFailure(rewriteErr) {
return "", false
}
placeholder, err := rewriteOracleSelectSQL(sqlText, loadColumns, true)
if err != nil || placeholder == sqlText {
return "", false
}
return placeholder, true
}
func (s *server) rewriteXMLTypeSelectSQL(sqlText string) (string, error) {
return rewriteOracleXMLTypeSelectSQL(sqlText, s.loadOracleColumnMeta)
}
@@ -4445,8 +4531,8 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR
for _, column := range columns {
columnRef := oracleColumnRef(tableRef.AliasText, column.Name)
outputAlias := quoteIdentifier(column.Name)
if isOracleSTGeometry(column) && !deferLOBs {
rewritten = append(rewritten, oracleSTGeometryExpression(columnRef, outputAlias))
if isOracleGeometryType(column) && !deferLOBs {
rewritten = append(rewritten, oracleGeometryExpression(column, columnRef, outputAlias))
} else if isOracleXMLType(column.DataType) && !deferLOBs {
rewritten = append(rewritten, oracleXMLSerializeExpression(columnRef, outputAlias))
} else if deferLOBs {
@@ -4471,8 +4557,8 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR
outputAlias = quoteIdentifier(meta.Name)
}
columnRef := oracleColumnRef(qualifier, meta.Name)
if isOracleSTGeometry(meta) && !deferLOBs {
rewritten = append(rewritten, oracleSTGeometryExpression(columnRef, outputAlias))
if isOracleGeometryType(meta) && !deferLOBs {
rewritten = append(rewritten, oracleGeometryExpression(meta, columnRef, outputAlias))
changed = true
sourceIndex++
continue
@@ -4502,8 +4588,8 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR
func oracleDeferredLOBExpressions(columnRef, outputAlias string, sourceIndex int, column oracleColumnMeta) ([]string, bool) {
kind, placeholder, ok := oracleDeferredLOBKind(column.DataType)
valueRef := columnRef
if isOracleSTGeometry(column) {
kind, placeholder, ok = "C", "<ST_GEOMETRY>", true
if isOracleGeometryType(column) {
kind, placeholder, ok = "C", oracleGeometryPlaceholder(column), true
}
if !ok {
return nil, false
@@ -4518,6 +4604,24 @@ func oracleSTGeometryExpression(columnRef, alias string) string {
return fmt.Sprintf("SDE.ST_AsText(%s) AS %s", columnRef, alias)
}
func oracleSDOGeometryExpression(columnRef, alias string) string {
return fmt.Sprintf("SDO_UTIL.TO_WKTGEOMETRY(%s) AS %s", columnRef, alias)
}
func oracleGeometryExpression(column oracleColumnMeta, columnRef, alias string) string {
if isOracleSDOGeometry(column) {
return oracleSDOGeometryExpression(columnRef, alias)
}
return oracleSTGeometryExpression(columnRef, alias)
}
func oracleGeometryPlaceholder(column oracleColumnMeta) string {
if isOracleSDOGeometry(column) {
return "<SDO_GEOMETRY>"
}
return "<ST_GEOMETRY>"
}
func oracleDeferredLOBKind(dataType string) (kind, placeholder string, ok bool) {
switch strings.ToUpper(strings.TrimSpace(dataType)) {
case "CLOB":
@@ -4615,7 +4719,7 @@ func oracleQualifierMatchesTable(qualifier string, tableRef oracleTableRef) bool
func oracleColumnsNeedValueRewrite(columns []oracleColumnMeta, deferLOBs bool) bool {
for _, column := range columns {
if isOracleSTGeometry(column) || isOracleXMLType(column.DataType) || (deferLOBs && isOracleDeferredLOBType(column.DataType)) {
if isOracleGeometryType(column) || isOracleXMLType(column.DataType) || (deferLOBs && isOracleDeferredLOBType(column.DataType)) {
return true
}
}
@@ -4651,6 +4755,19 @@ func isOracleSTGeometry(column oracleColumnMeta) bool {
return dataType == "ST_GEOMETRY" && strings.EqualFold(strings.TrimSpace(column.DataTypeOwner), "SDE")
}
func isOracleSDOGeometry(column oracleColumnMeta) bool {
// Oracle Spatial's MDSYS.SDO_GEOMETRY is likewise an unregistered object
// type for go-ora and panics on scan; SDO_UTIL.TO_WKTGEOMETRY provides
// the supported CLOB (WKT) representation for reads. Most schemas only
// see it through the PUBLIC synonym, so DATA_TYPE_OWNER is commonly
// "PUBLIC" rather than "MDSYS" and isn't checked here.
return strings.ToUpper(strings.TrimSpace(column.DataType)) == "SDO_GEOMETRY"
}
func isOracleGeometryType(column oracleColumnMeta) bool {
return isOracleSTGeometry(column) || isOracleSDOGeometry(column)
}
func leadingSQLSelectListStart(sqlText string) int {
trimmed := trimLeadingSQLComments(sqlText)
prefixLen := len(sqlText) - len(trimmed)
+463 -30
View File
@@ -6,6 +6,7 @@ import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/url"
@@ -1182,6 +1183,27 @@ func TestBuildDSNUsesStableDefaultPrefetchRows(t *testing.T) {
}
}
func TestReadQuerySessionPageDoesNotLookAheadAfterFullPage(t *testing.T) {
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{{
queryContains: "SELECT * FROM TEST_VIEW",
args: []driver.Value{},
columns: []string{"ID"},
rows: [][]driver.Value{{int64(1)}, {int64(2)}},
}})
s := newServer()
s.db = db
result, err := s.executeQueryPage(queryOptions{SQL: "SELECT * FROM TEST_VIEW"}, 2)
if err != nil {
t.Fatal(err)
}
if len(result.Rows) != 2 || !result.HasMore || result.SessionID == nil {
t.Fatalf("full page should keep a lazy session without look-ahead: %+v", result)
}
if !s.closeQuerySession(*result.SessionID) {
t.Fatal("expected the query session to close")
}
}
func TestBuildDSNPreservesConfiguredPrefetchRows(t *testing.T) {
tests := []connectParams{
{
@@ -2361,6 +2383,196 @@ func TestRewriteOracleSTGeometrySkipsSetQueries(t *testing.T) {
}
}
func TestRewriteOracleSDOGeometrySelectStar(t *testing.T) {
sqlText, err := rewriteOracleSelectSQL(
`SELECT * FROM TEST_GEOM`,
func(schema, table string) ([]oracleColumnMeta, error) {
return []oracleColumnMeta{
{Name: "ID", DataType: "NUMBER"},
{Name: "GEOM", DataType: "SDO_GEOMETRY", DataTypeOwner: "PUBLIC"},
{Name: "NOTE", DataType: "VARCHAR2"},
}, nil
},
false,
)
if err != nil {
t.Fatal(err)
}
want := `SELECT "ID", SDO_UTIL.TO_WKTGEOMETRY("GEOM") AS "GEOM", "NOTE" FROM TEST_GEOM`
if sqlText != want {
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
}
}
func TestRewriteOracleSDOGeometryExplicitColumn(t *testing.T) {
sqlText, err := rewriteOracleSelectSQL(
`SELECT t.GEOM AS shape FROM TEST_GEOM t`,
func(schema, table string) ([]oracleColumnMeta, error) {
return []oracleColumnMeta{{Name: "GEOM", DataType: "SDO_GEOMETRY", DataTypeOwner: "PUBLIC"}}, nil
},
false,
)
if err != nil {
t.Fatal(err)
}
want := `SELECT SDO_UTIL.TO_WKTGEOMETRY(t."GEOM") AS shape FROM TEST_GEOM t`
if sqlText != want {
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
}
}
func TestRewriteOracleSDOGeometryAsDeferredValue(t *testing.T) {
sqlText, err := rewriteOracleSelectSQL(
`SELECT t.ID, t.GEOM FROM TEST_GEOM t`,
func(schema, table string) ([]oracleColumnMeta, error) {
return []oracleColumnMeta{
{Name: "ID", DataType: "NUMBER"},
{Name: "GEOM", DataType: "SDO_GEOMETRY", DataTypeOwner: "PUBLIC"},
}, nil
},
true,
)
if err != nil {
t.Fatal(err)
}
want := `SELECT t.ID, CASE WHEN t."GEOM" IS NULL THEN NULL ELSE '<SDO_GEOMETRY>' END AS "GEOM", CASE WHEN t."GEOM" IS NULL THEN NULL ELSE 'D:1' END AS "__DBX_LARGE_VALUE_BYTES_C_1" FROM TEST_GEOM t`
if sqlText != want {
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
}
}
func TestOracleSDOGeometryDoesNotRequireOwner(t *testing.T) {
if !isOracleSDOGeometry(oracleColumnMeta{DataType: "SDO_GEOMETRY", DataTypeOwner: "PUBLIC"}) {
t.Fatal("expected SDO_GEOMETRY via PUBLIC synonym to be recognized")
}
if !isOracleSDOGeometry(oracleColumnMeta{DataType: "sdo_geometry"}) {
t.Fatal("expected SDO_GEOMETRY to be recognized regardless of owner or case")
}
if isOracleSDOGeometry(oracleColumnMeta{DataType: "VARCHAR2"}) {
t.Fatal("unexpected rewrite for an unrelated data type")
}
}
func TestIsOracleExtprocAgentFailure(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{
name: "ORA-28595 with SDE package context",
err: errors.New(`ORA-28595: Extproc agent: Invalid DLL Path SDE.ST_GEOMETRY_SHAPELIB_PKG`),
want: true,
},
{
name: "ORA-28595 with operator package context",
err: errors.New(`ORA-28595: Extproc agent: Invalid DLL Path SDE.ST_GEOMETRY_OPERATORS`),
want: true,
},
{
name: "lowercase ORA-28595",
err: errors.New("ora-28595: extproc agent: invalid dll path"),
want: true,
},
{
name: "bare ORA code",
err: errors.New("28595: Extproc agent: Invalid DLL Path"),
want: true,
},
{
name: "wrapped ORA-28595",
err: fmt.Errorf("query failed: %w", errors.New("ORA-28595: Extproc agent: Invalid DLL Path")),
want: true,
},
{
name: "unrelated ORA error",
err: errors.New("ORA-00942: table or view does not exist"),
want: false,
},
{
name: "connection error",
err: errors.New("ORA-12170: TNS:Connect timeout occurred"),
want: false,
},
{
name: "SDE package without ORA-28595",
err: errors.New("SDE.ST_GEOMETRY_SHAPELIB_PKG raised an error"),
want: false,
},
{
name: "nil error",
err: nil,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isOracleExtprocAgentFailure(tc.err); got != tc.want {
t.Fatalf("isOracleExtprocAgentFailure(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
func TestOracleExtprocFallbackSQL(t *testing.T) {
sdeColumns := func(schema, table string) ([]oracleColumnMeta, error) {
return []oracleColumnMeta{
{Name: "ID", DataType: "NUMBER"},
{Name: "GEOM", DataType: "ST_GEOMETRY", DataTypeOwner: "SDE"},
}, nil
}
t.Run("returns placeholder projection for extproc failure", func(t *testing.T) {
sqlText, ok := oracleExtprocFallbackSQL(
`SELECT * FROM SDE.TEST_GEOM`,
sdeColumns,
errors.New("ORA-28595: Extproc agent: Invalid DLL Path"),
)
if !ok {
t.Fatal("expected placeholder fallback SQL")
}
if !strings.Contains(sqlText, "'<ST_GEOMETRY>'") {
t.Fatalf("expected placeholder expression, got %s", sqlText)
}
if strings.Contains(sqlText, "SDE.ST_AsText") {
t.Fatalf("placeholder projection must not call SDE.ST_AsText, got %s", sqlText)
}
})
t.Run("keeps ST_AsText error for unrelated failures", func(t *testing.T) {
sqlText, ok := oracleExtprocFallbackSQL(
`SELECT * FROM SDE.TEST_GEOM`,
sdeColumns,
errors.New("ORA-12170: TNS:Connect timeout occurred"),
)
if ok {
t.Fatalf("unexpected fallback SQL: %s", sqlText)
}
})
t.Run("no fallback without rewriteable columns", func(t *testing.T) {
sqlText, ok := oracleExtprocFallbackSQL(
`SELECT * FROM SDE.TEST_GEOM`,
func(schema, table string) ([]oracleColumnMeta, error) {
return []oracleColumnMeta{{Name: "ID", DataType: "NUMBER"}}, nil
},
errors.New("ORA-28595: Extproc agent: Invalid DLL Path"),
)
if ok {
t.Fatalf("unexpected fallback SQL: %s", sqlText)
}
})
t.Run("no fallback when column metadata fails", func(t *testing.T) {
sqlText, ok := oracleExtprocFallbackSQL(
`SELECT * FROM SDE.TEST_GEOM`,
func(schema, table string) ([]oracleColumnMeta, error) {
return nil, errors.New("ORA-00942: table or view does not exist")
},
errors.New("ORA-28595: Extproc agent: Invalid DLL Path"),
)
if ok {
t.Fatalf("unexpected fallback SQL: %s", sqlText)
}
})
}
func TestRewriteOracleXMLTypeSkipsJoins(t *testing.T) {
called := false
sqlText, err := rewriteOracleXMLTypeSelectSQL(
@@ -2545,15 +2757,15 @@ func fakeOracleColumnLoader(columns []oracleColumnMeta) oracleColumnMetaLoader {
}
}
func TestGetObjectSourceUsesOriginalViewNameWithDBMSMetadata(t *testing.T) {
func TestGetObjectSourceUsesOriginalViewNameWithAllViews(t *testing.T) {
for _, viewName := range []string{"vEnginWJZ", "V_ENGINE_WJZ"} {
t.Run(viewName, func(t *testing.T) {
ddl := `CREATE OR REPLACE FORCE VIEW "ZTZS_ERP2"."` + viewName + `" AS SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
source := `SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
args: []driver.Value{viewName, "ZTZS_ERP2"},
rows: [][]driver.Value{{ddl}},
queryContains: "FROM ALL_VIEWS",
args: []driver.Value{"ZTZS_ERP2", viewName},
rows: [][]driver.Value{{source}},
},
})
s := newServer()
@@ -2563,7 +2775,7 @@ func TestGetObjectSourceUsesOriginalViewNameWithDBMSMetadata(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if result["source"] != ddl {
if result["source"] != source {
t.Fatalf("unexpected view source: %#v", result["source"])
}
if scripted.next != len(scripted.steps) {
@@ -2770,19 +2982,19 @@ func TestGetObjectSourcePreservesQuotedSynonymName(t *testing.T) {
}
}
func TestGetObjectSourceFallsBackToAllViewsWithOriginalName(t *testing.T) {
func TestGetObjectSourceFallsBackToDBMSMetadataWithOriginalName(t *testing.T) {
const viewName = "vEnginWJZ"
const source = `SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
const ddl = `CREATE OR REPLACE FORCE VIEW "ZTZS_ERP2"."vEnginWJZ" AS SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
args: []driver.Value{viewName, "ZTZS_ERP2"},
err: errors.New("ORA-31603: object not found"),
},
{
queryContains: "FROM ALL_VIEWS",
args: []driver.Value{"ZTZS_ERP2", viewName},
rows: [][]driver.Value{{source}},
err: errors.New("ORA-00942: table or view does not exist"),
},
{
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
args: []driver.Value{viewName, "ZTZS_ERP2"},
rows: [][]driver.Value{{ddl}},
},
})
s := newServer()
@@ -2792,7 +3004,7 @@ func TestGetObjectSourceFallsBackToAllViewsWithOriginalName(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if result["source"] != source {
if result["source"] != ddl {
t.Fatalf("unexpected fallback source: %#v", result["source"])
}
if scripted.next != len(scripted.steps) {
@@ -2802,16 +3014,16 @@ func TestGetObjectSourceFallsBackToAllViewsWithOriginalName(t *testing.T) {
func TestGetObjectSourceRejectsMissingViewSource(t *testing.T) {
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
args: []driver.Value{"vEnginWJZ", "ZTZS_ERP2"},
err: errors.New("ORA-31603: object not found"),
},
{
queryContains: "FROM ALL_VIEWS",
args: []driver.Value{"ZTZS_ERP2", "vEnginWJZ"},
rows: nil,
},
{
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
args: []driver.Value{"vEnginWJZ", "ZTZS_ERP2"},
err: errors.New("ORA-31603: object not found"),
},
})
s := newServer()
s.db = db
@@ -2850,12 +3062,15 @@ func contains(values []string, target string) bool {
}
type oracleViewSourceQueryStep struct {
queryContains string
args []driver.Value
columns []string
rows [][]driver.Value
err error
exec bool
queryContains string
args []driver.Value
columns []string
rows [][]driver.Value
err error
exec bool
panicText string
nextPanicText string
columnsPanicText string
}
type oracleViewSourceDriver struct {
@@ -2899,6 +3114,9 @@ func (c *oracleViewSourceConn) QueryContext(
if !strings.Contains(query, step.queryContains) {
return nil, errors.New("unexpected query: " + query)
}
if step.panicText != "" {
panic(step.panicText)
}
values := make([]driver.Value, len(args))
for index, arg := range args {
values[index] = arg.Value
@@ -2913,7 +3131,12 @@ func (c *oracleViewSourceConn) QueryContext(
if len(columns) == 0 {
columns = []string{"SOURCE"}
}
return &oracleViewSourceRows{columns: columns, values: step.rows}, nil
return &oracleViewSourceRows{
columns: columns,
values: step.rows,
nextPanicText: step.nextPanicText,
columnsPanicText: step.columnsPanicText,
}, nil
}
func (c *oracleViewSourceConn) ExecContext(
@@ -2943,12 +3166,17 @@ func (c *oracleViewSourceConn) ExecContext(
}
type oracleViewSourceRows struct {
columns []string
values [][]driver.Value
next int
columns []string
values [][]driver.Value
next int
nextPanicText string
columnsPanicText string
}
func (r *oracleViewSourceRows) Columns() []string {
if r.columnsPanicText != "" {
panic(r.columnsPanicText)
}
return r.columns
}
@@ -2957,6 +3185,9 @@ func (r *oracleViewSourceRows) Close() error {
}
func (r *oracleViewSourceRows) Next(dest []driver.Value) error {
if r.nextPanicText != "" {
panic(r.nextPanicText)
}
if r.next >= len(r.values) {
return io.EOF
}
@@ -3172,6 +3403,208 @@ func TestOracleQueryRowsRecoversDriverPanic(t *testing.T) {
}
}
func TestOracleQueryRowsFallsBackToPlaceholderOnExtprocFailure(t *testing.T) {
metadataStep := oracleViewSourceQueryStep{
queryContains: "ALL_TAB_COLUMNS",
args: []driver.Value{"SDE", "TEST_GEOM"},
columns: []string{"COLUMN_NAME", "DATA_TYPE", "DATA_TYPE_OWNER"},
rows: [][]driver.Value{
{"ID", "NUMBER", nil},
{"GEOM", "ST_GEOMETRY", "SDE"},
},
}
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{queryContains: "SELECT * FROM SDE.TEST_GEOM", args: []driver.Value{}, panicText: "simulated go-ora panic on SDE object column"},
metadataStep,
{queryContains: "SDE.ST_AsText", args: []driver.Value{}, err: errors.New("ORA-28595: Extproc agent: Invalid DLL Path")},
metadataStep,
{
queryContains: "'<ST_GEOMETRY>'",
args: []driver.Value{},
columns: []string{"ID", "GEOM", "__DBX_LARGE_VALUE_BYTES_C_1"},
rows: [][]driver.Value{{int64(1), "<ST_GEOMETRY>", "D:1"}},
},
})
s := newServer()
s.db = db
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded("SELECT * FROM SDE.TEST_GEOM", 0, false)
if err != nil {
t.Fatalf("expected placeholder fallback to serve rows, got %v", err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(columns, []string{"ID", "GEOM", "__DBX_LARGE_VALUE_BYTES_C_1"}) {
t.Fatalf("unexpected placeholder columns: %v", columns)
}
if !rows.Next() {
t.Fatalf("expected one placeholder row, got %v", rows.Err())
}
var id int64
var geom, marker string
if err := rows.Scan(&id, &geom, &marker); err != nil {
t.Fatal(err)
}
if id != 1 || geom != "<ST_GEOMETRY>" || marker != "D:1" {
t.Fatalf("unexpected placeholder row: %v %v %v", id, geom, marker)
}
}
func TestOracleQueryRowsKeepsSTAsTextErrorWhenPlaceholderAlsoFails(t *testing.T) {
metadataStep := oracleViewSourceQueryStep{
queryContains: "ALL_TAB_COLUMNS",
args: []driver.Value{"SDE", "TEST_GEOM"},
columns: []string{"COLUMN_NAME", "DATA_TYPE", "DATA_TYPE_OWNER"},
rows: [][]driver.Value{
{"ID", "NUMBER", nil},
{"GEOM", "ST_GEOMETRY", "SDE"},
},
}
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{queryContains: "SELECT * FROM SDE.TEST_GEOM", args: []driver.Value{}, panicText: "simulated go-ora panic on SDE object column"},
metadataStep,
{queryContains: "SDE.ST_AsText", args: []driver.Value{}, err: errors.New("ORA-28595: Extproc agent: Invalid DLL Path")},
metadataStep,
{queryContains: "'<ST_GEOMETRY>'", args: []driver.Value{}, err: errors.New("ORA-01013: user requested cancel of current operation")},
})
s := newServer()
s.db = db
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded("SELECT * FROM SDE.TEST_GEOM", 0, false)
if rows != nil {
t.Fatal("failed fallback path must not return rows")
}
if err == nil || !strings.Contains(err.Error(), "ORA-28595") {
t.Fatalf("expected the original ST_AsText ORA-28595 error, got %v", err)
}
}
func TestReadQuerySessionPageConvertsPanicToError(t *testing.T) {
// A nil *sql.Rows makes rows.Next() panic with a nil pointer dereference,
// which stands in for go-ora panicking while decoding an unknown type.
session := &querySession{rows: nil, columns: []string{"A"}, columnTypes: []string{"NUMBER"}, remaining: 1}
result, err := readQuerySessionPage(session, 10)
var panicErr oracleDriverPanicError
if !errors.As(err, &panicErr) {
t.Fatalf("expected oracle driver panic error, got %v", err)
}
if len(result.Rows) != 0 {
t.Fatalf("panic path must not return rows, got %v", result.Rows)
}
}
func TestExecuteQueryPageRetriesWithPlaceholderOnRowIterationPanic(t *testing.T) {
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "SELECT * FROM SDE.TEST_GEOM",
args: []driver.Value{},
columns: []string{"ID", "GEOM"},
rows: [][]driver.Value{{int64(1), "raw geometry bytes"}},
nextPanicText: "simulated go-ora decode panic on custom type column",
},
{
queryContains: "ALL_TAB_COLUMNS",
args: []driver.Value{"SDE", "TEST_GEOM"},
columns: []string{"COLUMN_NAME", "DATA_TYPE", "DATA_TYPE_OWNER"},
rows: [][]driver.Value{
{"ID", "NUMBER", nil},
{"GEOM", "ST_GEOMETRY", "SDE"},
},
},
{
queryContains: "'<ST_GEOMETRY>'",
args: []driver.Value{},
columns: []string{"ID", "GEOM", "__DBX_LARGE_VALUE_BYTES_C_1"},
rows: [][]driver.Value{{int64(1), "<ST_GEOMETRY>", "D:1"}},
},
})
s := newServer()
s.db = db
result, err := s.executeQueryPage(queryOptions{SQL: "SELECT * FROM SDE.TEST_GEOM"}, 10)
if err != nil {
t.Fatalf("expected placeholder retry to serve the first page, got %v", err)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
if len(result.Rows) != 1 {
t.Fatalf("expected one placeholder row, got %v", result.Rows)
}
want := []any{int64(1), "<ST_GEOMETRY>", "D:1"}
if !reflect.DeepEqual(result.Rows[0], want) {
t.Fatalf("unexpected row: %v, want %v", result.Rows[0], want)
}
if result.SessionID != nil || result.HasMore {
t.Fatalf("fully drained page must not keep a session: %v %v", result.SessionID, result.HasMore)
}
}
func TestExecuteQueryPageSurfacesPanicWhenPlaceholderNotApplicable(t *testing.T) {
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "SELECT * FROM SDE.TEST_GEOM",
args: []driver.Value{},
columns: []string{"ID"},
rows: [][]driver.Value{{int64(1)}},
nextPanicText: "simulated go-ora decode panic on custom type column",
},
{
queryContains: "ALL_TAB_COLUMNS",
args: []driver.Value{"SDE", "TEST_GEOM"},
columns: []string{"COLUMN_NAME", "DATA_TYPE", "DATA_TYPE_OWNER"},
rows: [][]driver.Value{{"ID", "NUMBER", nil}},
},
})
s := newServer()
s.db = db
result, err := s.executeQueryPage(queryOptions{SQL: "SELECT * FROM SDE.TEST_GEOM"}, 10)
var panicErr oracleDriverPanicError
if !errors.As(err, &panicErr) {
t.Fatalf("expected oracle driver panic error, got %v", err)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
if len(result.Rows) != 0 {
t.Fatalf("failed query must not return rows, got %v", result.Rows)
}
}
func TestRuntimeHandleLineRecoversRequestPanic(t *testing.T) {
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "SELECT * FROM SDE.TEST_GEOM",
args: []driver.Value{},
columnsPanicText: "simulated go-ora panic while describing columns",
},
})
runtime := newRuntimeServer()
session := newServer()
session.db = db
runtime.sessions["agent-session-1"] = &agentSession{server: session}
resp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":5,"method":"execute_query_page","params":{"agentSessionId":"agent-session-1","sql":"SELECT * FROM SDE.TEST_GEOM","pageSize":10}}`)
if shutdown {
t.Fatal("recovered panic must not shut down the agent")
}
if resp.Error == nil {
t.Fatalf("expected panic error response, got %+v", resp)
}
if !strings.Contains(resp.Error.Message, "agent request panic") {
t.Fatalf("unexpected error message: %s", resp.Error.Message)
}
if string(resp.ID) != "5" {
t.Fatalf("panic response must keep the request id, got %s", resp.ID)
}
// The RPC stream must survive the panic and keep serving requests.
followUp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":6,"method":"handshake","params":{}}`)
if shutdown || followUp.Error != nil {
t.Fatalf("handshake after recovered panic failed: shutdown=%v error=%v", shutdown, followUp.Error)
}
}
func TestManualTransactionBeginCommitRollback(t *testing.T) {
db, driver := openOracleManualTxTestDB(t)
s := newServer()
+17 -12
View File
@@ -16,7 +16,7 @@ import (
)
const sendMessageRequestCode = 10
const queryMessageNotFoundCode = 208
const queryMessageNotFoundCode = 22
type messageQueueTarget struct {
BrokerName string
@@ -208,9 +208,6 @@ func (a *rocketMQAgent) queryMessageByKey(params map[string]any) (any, error) {
messages, err := queryMessagesByKey(ctx, client, config.ConnectTimeout, topic, key, maxNum,
int64Value(params, 0, "begin"), int64Value(params, time.Now().UnixMilli(), "end"))
if err != nil {
if isEmptyMessageQueryError(err) {
return emptyMessageQuery(), nil
}
return nil, err
}
return messageQueryResult(topic, messages), nil
@@ -296,11 +293,14 @@ func (a *rocketMQAgent) queryMessageByTopic(params map[string]any) (any, error)
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
maxNum := min(max(1, intValue(params, 32, "maxNum")), 200)
beginTimestamp := int64Value(params, 0, "begin")
endTimestamp := int64Value(params, time.Now().UnixMilli(), "end")
messages, err := client.QueryMessageByTime(ctx, topic,
int64Value(params, 0, "begin"), int64Value(params, time.Now().UnixMilli(), "end"), maxNum)
beginTimestamp, endTimestamp, maxNum)
if err != nil {
return nil, err
}
messages = filterMessagesByStoreTimestamp(messages, beginTimestamp, endTimestamp)
return messageQueryResult(topic, messages), nil
}
@@ -491,10 +491,6 @@ func messageQueryResult(topic string, messages []*admin.MessageExt) map[string]a
return map[string]any{"messages": rows, "indexLastUpdateTimestamp": int64(0)}
}
func emptyMessageQuery() map[string]any {
return map[string]any{"messages": []map[string]any{}, "indexLastUpdateTimestamp": int64(0)}
}
func sortMessageRows(messages []map[string]any) {
sort.Slice(messages, func(i, j int) bool {
leftTime := anyInt64(messages[i]["timestamp"])
@@ -556,9 +552,18 @@ func anyInt64(value any) int64 {
}
}
func isEmptyMessageQueryError(err error) bool {
message := strings.ToLower(err.Error())
return strings.Contains(message, "208") || strings.Contains(message, "no message") || strings.Contains(message, "未找到消息")
func filterMessagesByStoreTimestamp(messages []*admin.MessageExt, beginTimestamp, endTimestamp int64) []*admin.MessageExt {
filtered := make([]*admin.MessageExt, 0, len(messages))
for _, message := range messages {
if beginTimestamp > 0 && message.StoreTimestamp < beginTimestamp {
continue
}
if endTimestamp > 0 && message.StoreTimestamp > endTimestamp {
continue
}
filtered = append(filtered, message)
}
return filtered
}
func isReservedMessageProperty(key string) bool {
+104
View File
@@ -54,6 +54,110 @@ func TestBuildQueryMessageCommandUsesBrokerHeaderNames(t *testing.T) {
}
}
func TestInvokeRemotingAllowCodesAcceptsQueryMessageNotFound(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
done := make(chan error, 1)
go func() {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
done <- acceptErr
return
}
defer connection.Close()
frame, readErr := readRemotingFrame(connection)
if readErr != nil {
done <- readErr
return
}
request, decodeErr := remoting.Decode(frame[4:])
if decodeErr != nil {
done <- decodeErr
return
}
response := &remoting.RemotingCommand{Code: queryMessageNotFoundCode, Opaque: request.Opaque, Flag: 1}
encoded, encodeErr := response.Encode()
if encodeErr == nil {
_, encodeErr = connection.Write(encoded)
}
done <- encodeErr
}()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := invokeRemotingAllowCodes(ctx, listener.Addr().String(), time.Second,
remoting.NewRequest(remoting.QueryMessage, nil), remoting.Success, queryMessageNotFoundCode); err != nil {
t.Fatal(err)
}
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestInvokeRemotingAllowCodesPreservesOtherErrors(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
done := make(chan error, 1)
go func() {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
done <- acceptErr
return
}
defer connection.Close()
frame, readErr := readRemotingFrame(connection)
if readErr != nil {
done <- readErr
return
}
request, decodeErr := remoting.Decode(frame[4:])
if decodeErr != nil {
done <- decodeErr
return
}
response := &remoting.RemotingCommand{Code: remoting.ConsumerNotOnline, Opaque: request.Opaque, Flag: 1}
encoded, encodeErr := response.Encode()
if encodeErr == nil {
_, encodeErr = connection.Write(encoded)
}
done <- encodeErr
}()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, invokeErr := invokeRemotingAllowCodes(ctx, listener.Addr().String(), time.Second,
remoting.NewRequest(remoting.QueryMessage, nil), remoting.Success, queryMessageNotFoundCode)
if invokeErr == nil || !strings.Contains(invokeErr.Error(), "206") {
t.Fatalf("expected ConsumerNotOnline to remain an error, got %v", invokeErr)
}
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestFilterMessagesByStoreTimestampUsesInclusiveBounds(t *testing.T) {
messages := []*admin.MessageExt{
{MsgId: "before", StoreTimestamp: 99},
{MsgId: "begin", StoreTimestamp: 100},
{MsgId: "middle", StoreTimestamp: 150},
{MsgId: "end", StoreTimestamp: 200},
{MsgId: "after", StoreTimestamp: 201},
}
filtered := filterMessagesByStoreTimestamp(messages, 100, 200)
if len(filtered) != 3 {
t.Fatalf("expected three messages in range, got %d", len(filtered))
}
for index, messageID := range []string{"begin", "middle", "end"} {
if filtered[index].MsgId != messageID {
t.Fatalf("unexpected message at index %d: %s", index, filtered[index].MsgId)
}
}
}
func TestInvokeRemotingAllowCodes(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -71,6 +71,15 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
enableLegacyTlsAlgorithms();
}
@Override
public boolean supportsConnectionPooling() {
// The mssql-jdbc -> jTDS fallback is session state on this Agent
// instance. A shared JDBC pool could hand a jTDS connection to another
// session that still believes it is using mssql-jdbc, and SQL Server
// 2000 is particularly prone to resetting those reused connections.
return false;
}
@Override
protected String buildJdbcUrl(ConnectParams params) {
return sqlServer2000Mode ? jtdsUrl(params) : legacyTlsUrl(params);
@@ -87,7 +96,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
return connection;
} catch (SQLException error) {
logConnectionEvent("mssql-jdbc failed", params, error);
if (isSqlServer2000Unsupported(error)) {
if (shouldFallbackToJtds(error)) {
logConnectionEvent("switching to jTDS 1.3.1 fallback", params, null);
try {
super.loadDriver(jtdsDriverParams());
@@ -194,7 +203,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
.replace('\n', ' ');
}
static boolean isSqlServer2000Unsupported(Throwable error) {
static boolean shouldFallbackToJtds(Throwable error) {
Throwable current = error;
while (current != null) {
String message = current.getMessage();
@@ -206,16 +215,29 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
// "sql server 8" substring match, so accept both shapes.
boolean version8Rejection = (normalized.contains("sql server 8") || normalized.contains("sql server version 8"))
&& (normalized.contains("not support") || normalized.contains("不支持"));
// Other driver wordings name the supported floor instead.
boolean floor2005Rejection = normalized.contains("sql server 2005 or later");
if (version8Rejection || floor2005Rejection) {
// Other driver wordings name the supported floor instead. The
// localized mssql-jdbc resource keeps "SQL Server 2005" in
// English while translating the "or later" suffix.
boolean floor2005Rejection = normalized.contains("sql server 2005 or later")
|| (normalized.contains("sql server 2005") && normalized.contains("更高版本"));
// Some SQL Server 2000 installations close the TDS 7.4
// prelogin socket before mssql-jdbc can report the server
// version. In legacy mode, retry that handshake once with
// jTDS, which speaks the older protocol.
boolean preloginRejection = normalized.contains("connection reset")
|| normalized.contains("connection was reset")
|| normalized.contains("forcibly closed")
|| normalized.contains("意外的登录前响应")
|| (normalized.contains("unexpected")
&& (normalized.contains("prelogin") || normalized.contains("pre-login")));
if (version8Rejection || floor2005Rejection || preloginRejection) {
return true;
}
}
if (current instanceof SQLException) {
SQLException next = ((SQLException) current).getNextException();
if (next != null && next != current.getCause()) {
if (isSqlServer2000Unsupported(next)) {
if (shouldFallbackToJtds(next)) {
return true;
}
}
@@ -320,12 +342,138 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
return super.getColumns(metadataSchema(schema, table), table);
String resolvedSchema = metadataSchema(schema, table);
List<ColumnInfo> columns = super.getColumns(resolvedSchema, table);
if (!sqlServer2000Mode || columns.isEmpty()) {
return columns;
}
try {
return mergeSqlServer2000ColumnComments(
columns,
readSqlServer2000ColumnComments(resolvedSchema, table)
);
} catch (SQLException | RuntimeException error) {
// Comments are optional metadata. Keep the table usable when the
// legacy catalog is unavailable or the account cannot read it.
// Legacy drivers can also throw runtime errors from their catalog
// code, mirroring the RuntimeException guards in getTableDdl.
System.err.println(
"[sqlserver-legacy] SQL Server 2000 column comments unavailable: "
+ error.getClass().getName()
+ ": "
+ error.getMessage()
);
return columns;
}
}
private Map<String, String> readSqlServer2000ColumnComments(String schema, String table) throws SQLException {
try {
return readColumnCommentsFromQuery(sqlServer2000ColumnCommentsSql(), schema, table);
} catch (SQLException error) {
System.err.println(
"[sqlserver-legacy] SQL Server 2000 direct column comments query failed; trying compatibility function: "
+ error.getMessage()
);
return readColumnCommentsFromQuery(sqlServer2000ColumnCommentsFunctionSql(), schema, table);
}
}
private Map<String, String> readColumnCommentsFromQuery(String sql, String schema, String table) throws SQLException {
Map<String, String> comments = new LinkedHashMap<>();
try (PreparedStatement statement = requireConnection().prepareStatement(sql)) {
statement.setString(1, schema);
statement.setString(2, table);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
String column = resultSet.getString("column_name");
String comment = resultSet.getString("column_comment");
if (column != null && comment != null && !comment.trim().isEmpty()) {
String key = column.trim().toLowerCase(Locale.ROOT);
String property = resultSet.getString("property_name");
if (!comments.containsKey(key) || "MS_Description".equalsIgnoreCase(property)) {
comments.put(key, comment);
}
}
}
}
}
return comments;
}
static List<ColumnInfo> mergeSqlServer2000ColumnComments(
List<ColumnInfo> columns,
Map<String, String> comments
) {
if (comments.isEmpty()) {
return columns;
}
Map<String, String> normalizedComments = new LinkedHashMap<>();
for (Map.Entry<String, String> entry : comments.entrySet()) {
if (entry.getKey() != null) {
normalizedComments.put(entry.getKey().trim().toLowerCase(Locale.ROOT), entry.getValue());
}
}
for (ColumnInfo column : columns) {
String comment = normalizedComments.get(column.getName().trim().toLowerCase(Locale.ROOT));
if (comment != null) {
column.setComment(comment);
}
}
return columns;
}
static String sqlServer2000ColumnCommentsSql() {
return "SELECT c.name AS column_name, p.value AS column_comment, p.name AS property_name "
+ "FROM sysobjects o JOIN sysusers u ON o.uid = u.uid "
+ "JOIN syscolumns c ON c.id = o.id "
+ "LEFT OUTER JOIN sysproperties p ON p.id = o.id AND p.smallid = c.colid "
+ "WHERE u.name = ? AND o.name = ? AND o.xtype IN ('U', 'V') "
+ "AND p.value IS NOT NULL "
+ "ORDER BY c.colid, CASE WHEN p.name = 'MS_Description' THEN 0 ELSE 1 END";
}
static String sqlServer2000ColumnCommentsFunctionSql() {
return "SELECT objname AS column_name, CONVERT(nvarchar(4000), value) AS column_comment, "
+ "'MS_Description' AS property_name "
+ "FROM ::fn_listextendedproperty('MS_Description', 'user', ?, 'table', ?, 'column', default)";
}
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
return super.listIndexes(metadataSchema(schema, table), table);
String resolvedSchema = metadataSchema(schema, table);
List<IndexInfo> indexes = super.listIndexes(resolvedSchema, table);
return markPrimaryKeyIndex(indexes, resolvedSchema, table);
}
// SQL Server names a primary-key index after its constraint (PK__<table>__<hex>
// or a user-chosen name), so the shared JDBC metadata layer's "PRIMARY"
// index-name convention never matches and is_primary stayed false. Resolve
// the flag from DatabaseMetaData.getPrimaryKeys() PK_NAME instead, so table
// cloning, the index tree badge, and DDL output see the real primary key.
private List<IndexInfo> markPrimaryKeyIndex(List<IndexInfo> indexes, String schema, String table) {
if (indexes.isEmpty()) {
return indexes;
}
try {
String primaryKeyName = null;
try (java.sql.ResultSet rs = requireConnection().getMetaData().getPrimaryKeys(null, schema, table)) {
if (rs.next()) {
primaryKeyName = rs.getString("PK_NAME");
}
}
if (primaryKeyName == null || primaryKeyName.trim().isEmpty()) {
return indexes;
}
for (IndexInfo index : indexes) {
if (primaryKeyName.equals(index.getName())) {
index.setIs_primary(true);
}
}
} catch (Exception ignored) {
// Fail soft: keep the metadata-layer flags when primary-key lookup is unavailable.
}
return indexes;
}
@Override
@@ -1,36 +1,61 @@
package com.dbx.agent.sqlserverlegacy;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.test.TestSupport;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.net.SocketException;
import java.security.Security;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
class SqlServerLegacyAgentTest {
@Test
void onlySqlServer8UnsupportedErrorsTriggerTheOldDriverFallback() {
// Real mssql-jdbc prelogin rejection for SQL Server 2000
// (R_unsupportedServerVersion, English-only resources).
Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported(
void sqlServer2000PreloginFailuresTriggerTheOldDriverFallback() {
// Real mssql-jdbc prelogin rejection for SQL Server 2000.
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
new SQLException("SQL Server version 8 is not supported by this driver.")
));
// Older driver wordings name the supported floor instead
// (mssql-jdbc R_notSQLServer family).
Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported(
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
new SQLException("This version of the driver can be used only with SQL Server 2005 or later.")
));
Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported(
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
new SQLException("该驱动程序只能与 SQL Server 2005 或更高版本一起使用。")
));
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
new SQLException("该驱动程序不支持 SQL Server 8 版")
));
Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported(
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
new SQLException("The driver does not support SQL Server 8")
));
Assertions.assertFalse(SqlServerLegacyAgent.isSqlServer2000Unsupported(
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
new SQLException("Connection failed", new SocketException("Connection reset"))
));
SQLException chained = new SQLException("Connection failed");
chained.setNextException(new SQLException("驱动程序收到意外的登录前响应。"));
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(chained));
Assertions.assertFalse(SqlServerLegacyAgent.shouldFallbackToJtds(
new SQLException("TLS handshake failed")
));
Assertions.assertFalse(SqlServerLegacyAgent.isSqlServer2000Unsupported(
Assertions.assertFalse(SqlServerLegacyAgent.shouldFallbackToJtds(
new SQLException("Login failed for user 'sa'")
));
}
@@ -101,6 +126,101 @@ class SqlServerLegacyAgentTest {
);
}
@Test
void sqlServer2000ColumnCommentsEnrichJdbcColumnRemarks() {
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
TestSupport.setPrivateConnection(agent, sqlServer2000CommentConnection(
Arrays.asList(
Arrays.asList("ID", "Primary identifier", "MS_Description"),
Arrays.asList("NAME", "Custom label", "CUSTOM_PROP"),
Arrays.asList("NAME", "Display name", "MS_Description")
),
null,
null
));
setSqlServer2000Mode(agent, true);
List<ColumnInfo> columns = agent.getColumns("dbo", "USERS");
Assertions.assertEquals(List.of("ID", "NAME", "CREATED_AT"), columns.stream().map(ColumnInfo::getName).toList());
Assertions.assertEquals("Primary identifier", columns.get(0).getComment());
// MS_Description outranks other extended properties on the same column.
Assertions.assertEquals("Display name", columns.get(1).getComment());
// Columns without an extended property keep their JDBC remark untouched.
Assertions.assertEquals("JDBC remark", columns.get(2).getComment());
Assertions.assertTrue(columns.get(0).getIs_nullable());
Assertions.assertFalse(columns.get(2).getIs_nullable());
Assertions.assertEquals("getdate()", columns.get(2).getColumn_default());
}
@Test
void sqlServer2000ColumnCommentsFallBackToCompatibilityFunction() {
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
TestSupport.setPrivateConnection(agent, sqlServer2000CommentConnection(
null,
Arrays.asList(
Arrays.asList("NAME", "Display name from extended property", "MS_Description")
),
null
));
setSqlServer2000Mode(agent, true);
List<ColumnInfo> columns = agent.getColumns("dbo", "USERS");
// The sysproperties query is unavailable on this legacy catalog, so the
// fn_listextendedproperty compatibility function supplies the comment.
Assertions.assertEquals("Display name from extended property", columns.get(1).getComment());
Assertions.assertNull(columns.get(0).getComment());
Assertions.assertEquals("JDBC remark", columns.get(2).getComment());
}
@Test
void sqlServer2000ColumnCommentsFailSoftWhenCatalogThrowsRuntimeError() {
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
TestSupport.setPrivateConnection(agent, sqlServer2000CommentConnection(
null,
null,
new IllegalStateException("legacy catalog proxy failed")
));
setSqlServer2000Mode(agent, true);
// Comments are optional enrichment: a runtime failure inside the legacy
// catalog must degrade to comment-less columns instead of failing the
// whole column listing.
List<ColumnInfo> columns = agent.getColumns("dbo", "USERS");
Assertions.assertEquals(List.of("ID", "NAME", "CREATED_AT"), columns.stream().map(ColumnInfo::getName).toList());
Assertions.assertNull(columns.get(0).getComment());
Assertions.assertNull(columns.get(1).getComment());
Assertions.assertEquals("JDBC remark", columns.get(2).getComment());
}
@Test
void sqlServer2000ColumnCommentsMergeWithoutDiscardingJdbcMetadata() {
ColumnInfo id = new ColumnInfo("ID", "int", false, null, true);
ColumnInfo name = new ColumnInfo("NAME", "varchar", true, null, false);
ColumnInfo untouched = new ColumnInfo("CREATED_AT", "datetime", false, "getdate()", false);
untouched.setComment("JDBC remark");
List<ColumnInfo> columns = List.of(id, name, untouched);
Map<String, String> comments = new LinkedHashMap<>();
comments.put("ID", "Primary identifier");
comments.put("NAME", "Display name");
List<ColumnInfo> merged = SqlServerLegacyAgent.mergeSqlServer2000ColumnComments(columns, comments);
Assertions.assertSame(columns, merged);
Assertions.assertEquals("Primary identifier", id.getComment());
Assertions.assertEquals("Display name", name.getComment());
Assertions.assertEquals("JDBC remark", untouched.getComment());
Assertions.assertTrue(id.getIs_primary_key());
Assertions.assertEquals("getdate()", untouched.getColumn_default());
comments.clear();
comments.put("name", "Case-insensitive match");
SqlServerLegacyAgent.mergeSqlServer2000ColumnComments(columns, comments);
Assertions.assertEquals("Case-insensitive match", name.getComment());
}
@Test
void constructorRelaxesLegacyTlsPolicyBeforeDriverLoading() {
String key = "jdk.tls.disabledAlgorithms";
@@ -131,6 +251,11 @@ class SqlServerLegacyAgentTest {
Assertions.assertEquals("SELECT 1", new SqlServerLegacyAgent().connectionValidationQuery());
}
@Test
void doesNotShareJdbcConnectionsAcrossLegacyAgentSessions() {
Assertions.assertFalse(new SqlServerLegacyAgent().supportsConnectionPooling());
}
@Test
void sqlServer2000AllNulCharacterPaddingBecomesEmptyString() {
Assertions.assertEquals(
@@ -320,4 +445,202 @@ class SqlServerLegacyAgentTest {
SqlServerLegacyAgent.appendTableCommentDdl(baseDdl, "dbo", "Users", " ")
);
}
/**
* A connection shaped like a jTDS SQL Server 2000 session: the driver's own
* JDBC metadata still reports the table columns, while column comments are
* served by the legacy sysproperties catalog or, when that query fails, by
* the fn_listextendedproperty compatibility function. Comment rows carry
* (column_name, column_comment, property_name).
*/
private static Connection sqlServer2000CommentConnection(
List<List<Object>> primaryCommentRows,
List<List<Object>> fallbackCommentRows,
RuntimeException runtimeError
) {
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
if ("getColumns".equals(method.getName())) {
return metadataResultSet(Arrays.asList(
Arrays.asList("ID", "int", 1, null, null),
Arrays.asList("NAME", "varchar", 1, null, null),
Arrays.asList("CREATED_AT", "datetime", 0, "getdate()", "JDBC remark")
), JDBC_COLUMN_LABELS);
}
if ("getPrimaryKeys".equals(method.getName())) {
return metadataResultSet(List.of(), JDBC_COLUMN_LABELS);
}
return defaultValue(method.getReturnType());
});
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
if ("getMetaData".equals(name)) {
return metadata;
}
if ("prepareStatement".equals(name)) {
String sql = (String) args[0];
if (runtimeError != null) {
return failingStatement(runtimeError);
}
if (sql.toUpperCase(Locale.ROOT).contains("SYSPROPERTIES")) {
return primaryCommentRows == null
? failingStatement(new SQLException("sysproperties catalog unavailable"))
: commentStatement(primaryCommentRows);
}
return commentStatement(fallbackCommentRows == null ? List.of() : fallbackCommentRows);
}
if ("close".equals(name) || "isClosed".equals(name)) {
return "isClosed".equals(name) ? Boolean.FALSE : null;
}
return defaultValue(method.getReturnType());
});
}
private static PreparedStatement commentStatement(List<List<Object>> rows) {
return proxy(PreparedStatement.class, (method, args) -> {
if ("executeQuery".equals(method.getName())) {
return metadataResultSet(rows, COMMENT_LABELS);
}
if ("close".equals(method.getName()) || "setMaxRows".equals(method.getName())) {
return null;
}
return defaultValue(method.getReturnType());
});
}
private static PreparedStatement failingStatement(Throwable error) {
return proxy(PreparedStatement.class, (method, args) -> {
if ("executeQuery".equals(method.getName())) {
throw error;
}
if ("close".equals(method.getName()) || "setMaxRows".equals(method.getName())) {
return null;
}
return defaultValue(method.getReturnType());
});
}
private static final Map<String, Integer> JDBC_COLUMN_LABELS = Map.of(
"COLUMN_NAME", 0,
"TYPE_NAME", 1,
"NULLABLE", 2,
"COLUMN_DEF", 3,
"REMARKS", 4
);
private static final Map<String, Integer> COMMENT_LABELS = Map.of(
"COLUMN_NAME", 0,
"COLUMN_COMMENT", 1,
"PROPERTY_NAME", 2
);
private static ResultSet metadataResultSet(List<List<Object>> rows, Map<String, Integer> labels) {
int[] index = {-1};
return proxy(ResultSet.class, (method, args) -> {
String name = method.getName();
if ("next".equals(name)) {
index[0] += 1;
return index[0] < rows.size();
}
if ("close".equals(name)) {
return null;
}
if ("getString".equals(name) || "getInt".equals(name) || "getObject".equals(name)) {
Integer position = args[0] instanceof String label ? labels.get(label.toUpperCase(Locale.ROOT)) : null;
Object value = position == null || index[0] < 0 || index[0] >= rows.size()
? null
: rows.get(index[0]).get(position);
if ("getString".equals(name)) {
return value == null ? null : value.toString();
}
if ("getInt".equals(name)) {
return value instanceof Number number ? number.intValue() : 0;
}
return value;
}
return defaultValue(method.getReturnType());
});
}
private static void setSqlServer2000Mode(SqlServerLegacyAgent agent, boolean value) {
try {
Field field = SqlServerLegacyAgent.class.getDeclaredField("sqlServer2000Mode");
field.setAccessible(true);
field.set(agent, value);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Unable to set SQL Server 2000 mode", e);
}
}
@SuppressWarnings("unchecked")
private static <T> T proxy(Class<T> type, MethodHandler handler) {
InvocationHandler invocationHandler = new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
return handler.handle(method, args);
}
};
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, invocationHandler);
}
private static Object defaultValue(Class<?> type) {
if (Boolean.TYPE.equals(type)) {
return false;
}
if (Byte.TYPE.equals(type)) {
return (byte) 0;
}
if (Short.TYPE.equals(type)) {
return (short) 0;
}
if (Integer.TYPE.equals(type)) {
return 0;
}
if (Long.TYPE.equals(type)) {
return 0L;
}
if (Float.TYPE.equals(type)) {
return 0f;
}
if (Double.TYPE.equals(type)) {
return 0.0d;
}
if (Character.TYPE.equals(type)) {
return '\0';
}
return null;
}
private interface MethodHandler {
Object handle(Method method, Object[] args) throws Throwable;
}
@Test
void listIndexesMarksPrimaryKeyByConstraintName() {
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
if ("getIndexInfo".equals(method.getName())) {
return metadataResultSet(Arrays.asList(
Arrays.asList("PK__orders__3213E83F5A9632C8", "id"),
Arrays.asList("PK__orders__3213E83F5A9632C8", "tenant_id"),
Arrays.asList("IX_orders_name", "name")
), Map.of("INDEX_NAME", 0, "COLUMN_NAME", 1));
}
if ("getPrimaryKeys".equals(method.getName())) {
return metadataResultSet(Arrays.asList(
Arrays.asList("PK__orders__3213E83F5A9632C8", "id"),
Arrays.asList("PK__orders__3213E83F5A9632C8", "tenant_id")
), Map.of("PK_NAME", 0, "COLUMN_NAME", 1));
}
return defaultValue(method.getReturnType());
});
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) ->
"getMetaData".equals(method.getName()) ? metadata : defaultValue(method.getReturnType())));
List<IndexInfo> indexes = agent.listIndexes("dbo", "orders");
List<IndexInfo> primary = indexes.stream().filter(IndexInfo::getIs_primary).toList();
Assertions.assertEquals(1, primary.size(), indexes.toString());
Assertions.assertEquals("PK__orders__3213E83F5A9632C8", primary.get(0).getName());
Assertions.assertEquals(List.of("id", "tenant_id"), primary.get(0).getColumns());
}
}

Some files were not shown because too many files have changed in this diff Show More