Compare commits

...
Author SHA1 Message Date
github-actions[bot] 8f54385b97 chore(packages): release 0.4.75 [skip node-packages-release] 2026-08-28 10:27:48 +00:00
github-actions[bot] cb9f87031f chore(jdbc): bump plugin version [skip ci] 2026-08-28 09:35:31 +00:00
t8y2 5c738f5bf2 chore(release): prepare v0.5.98 2026-08-28 17:34:50 +08:00
二丫讲梵 038ff4e773 fix(editor): align wrapped line numbers 2026-08-28 17:19:40 +08:00
t8y2 9c56de2e9f chore(query): gate test-only execution helper 2026-08-28 09:17:46 +00:00
zipg ef87e45cbc fix(windows): recover hidden startup window 2026-08-28 17:16:55 +08:00
t8y2 f83f707fbc fix(kingbase): honor driver identifier quote for schema context
Closes #7505
2026-08-28 09:13:09 +00:00
zipg 7a4edb6c19 fix(postgres): preserve configured search path 2026-08-28 16:55:04 +08:00
二丫讲梵 b4787a63af feat(editor): configure multi-statement result view 2026-08-28 16:52:02 +08:00
Guoyu Su fd17037a2a fix(linux): bundle AppImage input method modules 2026-08-28 16:51:33 +08:00
二丫讲梵 41db375ee6 style(editor): align line number gutter items 2026-08-28 16:51:03 +08:00
t8y2 1498b27314 fix(query): avoid IRIS derived count aliases
Closes #7486
2026-08-28 08:25:31 +00:00
jearhe 6d8e667bed feat(structure): support search in DDL view 2026-08-28 15:30:08 +08:00
StaravenX df88d7baa7 fix(jdbc): fall back to DatabaseMetaData when oracle dictionary views are missing 2026-08-28 15:29:06 +08:00
t8y2 659fb7f330 feat(mcp): expose message queue send tool
Closes #7236
2026-08-28 07:27:51 +00:00
二丫讲梵 c237ecbfca fix(nacos): allow manual scope when namespace directory is missing 2026-08-28 15:10:25 +08:00
t8y2 bcc64274e8 feat(ci): sign arm64 Windows builds and NSIS uninstallers 2026-08-28 15:05:45 +08:00
Mo-Moore 802019daa3 feat(editor): execute custom sql template by shortcut on selection 2026-08-28 14:27:55 +08:00
jearhe 60bfb41f13 feat(editor): add clear-all action for sql parameter values 2026-08-28 14:25:42 +08:00
t8y2 17e2573fbe fix(kafka): fall back to topic names when metadata times out 2026-08-28 06:11:26 +00:00
t8y2 2467d212d5 fix(dameng): normalize legacy DBX connection URLs
Closes #7482
2026-08-28 14:07:10 +08:00
t8y2 80224ab1d9 fix(oracle): support 11g data compare pagination
Closes #7470
2026-08-28 05:42:17 +00:00
t8y2 89086cc22b fix(schema): list HighGo PostgreSQL extensions
Closes #7448
2026-08-28 05:02:43 +00:00
Abeautifulsnow fdfad88590 feat(editor): add global default transaction commit mode 2026-08-28 12:37:11 +08:00
李子凡 d3a97164aa fix(sql): preserve empty raw placeholders 2026-08-28 12:36:37 +08:00
t8y2 d4245f0d58 fix(dameng): open java.net module for multi-NIC hosts 2026-08-28 12:36:00 +08:00
李子凡 2c5f926bd8 fix(grid): pad short timestamp fractions 2026-08-28 12:35:47 +08:00
amwps290 20a2994fa1 feat(structure): show table constraints for postgres-compatible dbs 2026-08-28 12:14:04 +08:00
t8y2 7ad05b178f fix(mysql): support DRDS index expression fallback
Closes #7363
2026-08-28 03:42:29 +00:00
t8y2 be307ba2d7 fix(query): collapse duplicated external-driver query branch 2026-08-28 11:41:24 +08:00
zipg 10bce698ae fix(mcp): install rustls crypto provider for TLS connections 2026-08-28 11:13:14 +08:00
Diego Fabricio c0abc26fe8 fix(grid): allow rerunning exact total row count from status 2026-08-28 11:12:41 +08:00
zipg 425ca055ea fix(mysql): keep pool when health probe times out during export 2026-08-28 11:12:06 +08:00
dadong 99a9961712 fix(export): harden config export passphrase handling 2026-08-28 11:11:35 +08:00
zipg d8ec9bb8d0 feat(connection): choose group when creating a connection 2026-08-28 11:11:00 +08:00
t8y2 4636788f49 fix(export): fall back when Doris rejects read-only snapshot
Closes #7446
2026-08-28 02:36:06 +00:00
t8y2 22305b7d0f fix(jdbc): retry invalid UTF-8 table previews without left wrappers
Closes #7268
2026-08-28 02:05:30 +00:00
zipg 71d07348ad perf(oracle): stop slow index metadata from blocking first query 2026-08-27 22:25:39 +08:00
Melody Ruch b198dd1327 fix(postgres): affected rows for WITH-wrapped DML and CTE completion 2026-08-27 22:25:09 +08:00
Melody Ruch 9290391985 fix(structure): emit NULL keyword for nullable MySQL timestamp columns 2026-08-27 22:24:40 +08:00
Melody Ruch 1016ccc352 fix(backup): match db.schema.table excludes when database equals schema 2026-08-27 22:24:09 +08:00
Abeautifulsnow ec0b6768be feat(grid): add row/column crosshair highlight setting 2026-08-27 22:23:13 +08:00
t8y2 b09c6f4f21 fix(export): stabilize Kingbase large result pagination
Closes #7377
2026-08-27 19:08:05 +08:00
t8y2 74ffab29ca fix(transfer): enable identity insert for SQL Server
Closes #7335
2026-08-27 18:52:15 +08:00
t8y2 e996f8aefb fix(transfer): use rownum pagination for OceanBase Oracle
Closes #7209
2026-08-27 18:47:57 +08:00
zipg 4fed29fba2 fix(structure): improve mysql auto-increment editing and field shortcuts 2026-08-27 18:21:52 +08:00
二丫讲梵 214bf86a6f feat(desktop): add zen mode toggle for data tabs 2026-08-27 17:59:10 +08:00
Farahani 955558c81f feat(safety): temporary write-unlock window for read-only connections 2026-08-27 17:37:54 +08:00
Mo-Moore 7933b7656f feat(jdbc,desktop): browse Hive/Inceptor routines from system views 2026-08-27 17:32:56 +08:00
t8y2 e5572ead7a fix(grid): parse pg timestamps deterministically for local date sort
Closes #7281
2026-08-27 17:32:09 +08:00
t8y2 bd6c780aba fix(core): uppercase dameng column types in structure ddl
Closes #7343
2026-08-27 17:23:40 +08:00
dadong dfd4d3b7ff feat(import): add SQL script import with dialect-aware parsing 2026-08-27 17:17:41 +08:00
Yang Fei ab4e747bf9 feat(editor): drag result grid columns into the SQL editor 2026-08-27 17:16:57 +08:00
Mo-Moore 9e4fe8ce56 fix(jdbc): connect Hive2 JDBC through registered driver 2026-08-27 17:16:32 +08:00
t8y2 6a6e5fbad9 fix(core): use row_number pagination for sqlserver table transfers
Closes #7356
2026-08-27 17:10:10 +08:00
t8y2 e8d6370b1e fix(grid): reset scroll to top on external result replacement
Results replaced without an internal grid action (SQL re-executed from
the editor or a grid-embedded run button) kept the previous viewport, so
DOM-mode grids stayed stranded at the old scroll offset after running an
ORDER BY query. The result watch now resets to the first row unless the
replacement is an in-place refresh (prepareFullReload markers) or an
infinite-scroll append completion. A remounted grid also no longer
adopts a scroll-only pending-changes snapshot; cached scroll is only
replayed alongside unsaved edit state, while the KeepAlive reactivate
path still restores pure scroll.

Closes #7341
2026-08-27 16:33:23 +08:00
t8y2 4eeeafa858 fix(sidebar): expand collapsed groups when locating tab
Closes #7387
2026-08-27 16:33:23 +08:00
t8y2 e4dc5bace8 fix(core): use drop user cascade for oceanbase oracle schema drop
Closes #7351
2026-08-27 16:33:23 +08:00
zipg 920ab62cf1 feat(grid): support deleting rows from JOIN query results 2026-08-27 16:28:14 +08:00
t8y2 36a7b7bd3a feat(ai): localize custom provider label 2026-08-27 16:23:51 +08:00
t8y2 565feb8745 feat(ci): cache native c deps through sccache 2026-08-27 16:14:42 +08:00
Abeautifulsnow 40549c9573 fix(dialog): clamp grid track and wrap footer so dialogs stop clipping 2026-08-27 16:06:25 +08:00
zipg eb0994e980 feat(grid): support composite unique reference keys 2026-08-27 16:00:00 +08:00
t8y2 cf03de122a fix(ci): keep sccache server alive for post-build stats 2026-08-27 15:56:48 +08:00
t8y2 d9beaaaa1c fix(agents): raise gradle daemon heap for parallel builds 2026-08-27 14:38:53 +08:00
github-actions[bot] 5cca5e0b86 chore(packages): release 0.4.74 [skip node-packages-release] 2026-08-27 06:25:47 +00:00
github-actions[bot] 6e644c0eea chore: bump module versions [skip ci] 2026-08-27 06:07:01 +00:00
t8y2 f3effbc76b fix(release): bundle sqlite in static browser builds 2026-08-27 13:25:41 +08:00
github-actions[bot] 81172688b8 chore(jdbc): bump plugin version [skip ci] 2026-08-27 04:44:08 +00:00
t8y2 b5bac9cafc chore(release): prepare v0.5.97 2026-08-27 12:43:26 +08:00
hanxuanyu 704f817b4d feat(grid): add snapshots for selected data 2026-08-27 12:21:28 +08:00
t8y2 e6d25b016e fix(jdbc): pair oracle ojdbc maven installs with orai18n
Closes #7261
2026-08-27 12:19:16 +08:00
二丫讲梵 d70a1231b0 feat(xlsx): add optional auto filter to xlsx export 2026-08-27 12:18:42 +08:00
t8y2 185db5e8c6 docs(sponsors): add TrustAsia sponsor to READMEs and site 2026-08-27 12:09:57 +08:00
Abeautifulsnow bd05c0350d feat(oracle): hide commit/rollback for clean manual-transaction state 2026-08-27 11:30:27 +08:00
t8y2 d2326acb81 fix(grid): keep DOM results at the first row 2026-08-27 11:08:42 +08:00
onenewcode 8bc31d3319 fix(sql-library): use delete confirm label in delete dialogs 2026-08-27 10:55:20 +08:00
知食分子 ba8df70fe6 fix(desktop): add legacy webview fallbacks for snapshots and dialogs 2026-08-27 10:55:05 +08:00
t8y2 afcfa59d0b fix(oracle): reload large values through rowid-wrapped select
Refs: #7082
2026-08-27 10:43:54 +08:00
Melody Ruch 4a3d63b09a fix(completion): mask strings and comments in clause detection 2026-08-27 09:21:54 +08:00
t8y2 caaa8932a1 fix(tests): add missing Keyboard icon to structure editor mocks 2026-08-27 09:11:37 +08:00
jearhe f41b5dbdf6 fix(transfer): preserve PostgreSQL indexes in structure-only 2026-08-27 08:40:27 +08:00
zipg e7191b1c4b fix(completion): add datepart args for sqlserver date functions 2026-08-27 08:40:12 +08:00
zipg 2138f59da4 fix(compare): keep oracle prefill selections during async loads 2026-08-27 08:39:45 +08:00
zipg 2711646d48 fix(datagen): stop column defaults overriding generation rules 2026-08-27 08:39:30 +08:00
二丫讲梵 3d77966f52 feat(structure-editor): add quick field add and duplicate shortcuts 2026-08-27 08:39:15 +08:00
二丫讲梵 62f2185f44 feat(sql-formatter): add setting to preserve blank lines 2026-08-27 08:38:50 +08:00
Melody Ruch 699043ff07 fix(import): add default length to bare varchar on mysql targets 2026-08-27 08:38:35 +08:00
jearhe 6875ca4000 fix(schema-diff): refresh table lists before comparing 2026-08-27 08:38:20 +08:00
t8y2 168e7bcb54 fix(docs): sync lightweight dev command with sqlite-bundled feature 2026-08-26 22:09:26 +08:00
Melody Ruch ff88516235 fix(elasticsearch): clamp page size to max_result_window 2026-08-26 22:07:34 +08:00
二丫讲梵 aac09d3cb7 fix(grid): keep canvas hover state across drawing surface flips 2026-08-26 22:04:42 +08:00
二丫讲梵 174f786483 build(sqlite): enable bundled sqlite in fast dev and check targets 2026-08-26 22:03:52 +08:00
Zzz 4b454b72b9 fix(http-tunnel): reduce round-trip latency under active traffic 2026-08-26 22:00:04 +08:00
t8y2 ac2af84e7b fix(desktop): reformat redis key expansion spec for CI 2026-08-26 21:56:55 +08:00
t8y2 8333c78320 fix(docs): keep pnpm lockfile patchedDependencies format CI expects 2026-08-26 21:44:10 +08:00
t8y2 3a0e86956c feat(docs): redesign changelog page and add route progress bar 2026-08-26 21:38:27 +08:00
GIWTO 96d2f0dd1c feat(transfer): swap source and target connections 2026-08-26 21:38:10 +08:00
StaravenX bf09181ba5 fix(jdbc): fall back to jdbc schemas without all_users 2026-08-26 21:37:29 +08:00
zipg e93b41a4ff feat(editor): preselect the first completion candidate 2026-08-26 21:31:54 +08:00
zipg 589fbd9de8 feat(ui): streamline sidebar and settings layout 2026-08-26 21:31:26 +08:00
zipg cb4cc43685 fix(build): resolve windows desktop sqlite link failure 2026-08-26 21:07:45 +08:00
t8y2 8382d85e19 fix(schema-diff): keep both-side tables out of delete group
Closes #7225
2026-08-26 20:52:38 +08:00
t8y2 10df1b07d1 fix(redis): keep key tree groups expanded across refresh
Closes #7173
2026-08-26 20:52:37 +08:00
t8y2 7231c41618 fix(settings): make live preview read-only and hint intentional typo
Closes #7233
2026-08-26 20:52:37 +08:00
t8y2 2e739d5902 fix: initialize visible_database_patterns in remaining initializers 2026-08-26 20:41:31 +08:00
t8y2 c00003e4d7 fix(mq): initialize visible_database_patterns in test configs 2026-08-26 20:10:10 +08:00
Melody Ruch a33f7d3c56 fix(diff): keep self-referencing foreign keys inside the target database 2026-08-26 19:26:39 +08:00
t8y2 0739048425 fix(readme): avoid duplicate sponsor logos 2026-08-26 18:52:46 +08:00
t8y2 d06f62f9f4 feat(sidebar): wildcard patterns for visible databases
Closes #7164
2026-08-26 18:43:31 +08:00
t8y2 71344c637c fix(grid): hint truncated values in grid search
Closes #7279
2026-08-26 18:43:21 +08:00
t8y2 138e48d347 fix(export): clamp diagram png scale to canvas limits
Closes #7248
2026-08-26 18:43:09 +08:00
t8y2 0588a67449 feat(readme): reorganize sponsor section 2026-08-26 18:33:35 +08:00
二丫讲梵 25ea7b21eb feat(connection): show server versions for more datasources 2026-08-26 18:29:13 +08:00
zipg fb907a5866 fix(oracle): apply default orcl database 2026-08-26 17:47:40 +08:00
jearhe affcb7fa7e perf(transfer): virtualize large object lists in the picker 2026-08-26 17:47:14 +08:00
jearhe d15605cee9 fix(editor): match replace shortcut modifiers strictly 2026-08-26 17:46:50 +08:00
Melody Ruch 95537957cc fix(diff): qualify deploy ddl with the target schema 2026-08-26 17:46:24 +08:00
t8y2 c153ca8f57 feat(grid): add refresh to data grid context menu
Closes #7273
2026-08-26 17:28:39 +08:00
zipg b5124baca9 feat(sqlite): restore rc4 legacy support via optional feature 2026-08-26 17:21:53 +08:00
t8y2 4b4b4ac2dd fix(redis): fill expanded group subtree via targeted scan
Closes #7162
2026-08-26 17:14:12 +08:00
t8y2 83a6ad005b feat(docs): add faq page for data storage and data directory 2026-08-26 17:00:08 +08:00
Melody Ruch b77328f03e fix(jdbc): render large oracle numbers without scientific notation 2026-08-26 16:40:52 +08:00
t8y2 d85816c3ed feat(grid): add begins-with and ends-with filter modes
Closes #7282
2026-08-26 16:40:17 +08:00
jearhe f12ac21453 fix(diff): sync horizontal scroll in schema comparison 2026-08-26 15:50:14 +08:00
Guoyu Su bc18bbfe5c fix(redis): scan cluster masters fairly for matching keys 2026-08-26 15:49:49 +08:00
Melody Ruch 8c735206e0 fix(redis): pair withscores members and scores in the console 2026-08-26 15:31:42 +08:00
t8y2 8475c84d48 fix(duckdb): checkpoint wal on worker shutdown 2026-08-26 15:23:29 +08:00
zipg 6686d1628e fix(completion): resolve ob-oracle schema for alias column completion 2026-08-26 15:07:37 +08:00
Melody Ruch 1e9babca45 fix(editor): skip statement frame for oversized oracle package bodies 2026-08-26 15:07:07 +08:00
onenewcode 0f9d32e1f5 feat(redis): edit hash fields 2026-08-26 14:44:19 +08:00
李达千 75c2dc5e17 fix(mongo): do not force directConnection when loadBalanced 2026-08-26 14:43:52 +08:00
Abeautifulsnow eeeb4e5011 feat(ai): run agent sessions as background tasks 2026-08-26 14:09:08 +08:00
Allen b37a1f8538 feat(grid): color quick condition bar keywords, fields and values 2026-08-26 13:52:41 +08:00
Melody Ruch 4c28492f2c fix(sidebar): refresh tree after dropping the last visible database 2026-08-26 13:52:16 +08:00
Melody Ruch ca0d4356c0 fix(editor): highlight pg and mysql builtin functions 2026-08-26 13:51:51 +08:00
jearhe ef22b84fbe perf(kafka): batch consumer group subscriptions into one agent round trip 2026-08-26 13:51:26 +08:00
KuriyamaMirai a4bf8442d7 fix(sqlserver): complete schema diff DDL support 2026-08-26 13:21:50 +08:00
t8y2 2b7d353313 feat(docs): refine landing page presentation 2026-08-26 13:14:38 +08:00
zipg 5b61f17c49 feat(dameng): support dm6 connections via custom jdbc drivers 2026-08-26 12:43:31 +08:00
zipg c8c9870dba fix(schema): converge mysql structure sync and refresh metadata 2026-08-26 12:30:10 +08:00
zipg 43289e2ce1 fix(oracle): 加速大存储过程源码读取 2026-08-26 11:05:36 +08:00
zipg 60e3d61934 feat(mysql): 支持查看和修改表存储引擎 2026-08-26 11:05:08 +08:00
zipg 2e8673a7ea fix(kafka): 修复跳过证书校验未生效 2026-08-26 11:04:40 +08:00
zipg a6c386a067 fix(editor): 避免惯性滚动误触发字号缩放 2026-08-26 11:04:12 +08:00
zipg 68eae4ad4d feat(gaussdb): 支持带 psql 控制命令的匿名块执行 2026-08-26 11:03:44 +08:00
t8y2 8db66db8ca fix(release): ignore desktop-only Cargo.lock changes 2026-08-26 10:54:31 +08:00
t8y2 f1578a6e9e chore(release): prepare v0.5.96 2026-08-26 10:36:27 +08:00
zipg dc10323b7c fix(grid): restore first double-click cell editing 2026-08-26 10:04:44 +08:00
github-actions[bot] 8bcbfd7ae7 fix(ci): disable sccache for Windows 7 release build 2026-08-26 03:54:19 +08:00
github-actions[bot] 43c896053b chore(packages): release 0.4.73 [skip node-packages-release] 2026-08-25 19:19:09 +00:00
github-actions[bot] 084f872a80 chore: bump module versions [skip ci] 2026-08-25 19:01:19 +00:00
github-actions[bot] 8e47296e02 fix(release): restore Windows signing build 2026-08-26 02:46:11 +08:00
github-actions[bot] 706334434a revert(sqlite): remove RC4 Legacy database support 2026-08-26 02:46:04 +08:00
github-actions[bot] d362c8efb8 chore(release): prepare v0.5.95 2026-08-26 00:42:11 +08:00
zipg 771a7f03a0 fix(editor): restore SQL selection case shortcuts 2026-08-26 00:27:27 +08:00
zipg 2a2357e2f1 feat(sidebar): refine active connection filter indicator 2026-08-26 00:25:10 +08:00
skyler 1c2e5bb07c feat(ci): sign x64 Windows releases with TrustAsia VSign 2026-08-26 00:07:30 +08:00
Guoyu Su a9c6c7b5e4 fix(core): support named-user tilde paths 2026-08-25 23:57:02 +08:00
zipg 1ed20e424e feat(sqlite): support RC4 Legacy databases 2026-08-25 23:37:52 +08:00
jearhe 0dc1f0c14c fix(editor): guard SQL shortcuts during IME composition 2026-08-25 22:51:26 +08:00
Guoyu Su 0658d18922 fix(mysql): prevent stale export pool cleanup 2026-08-25 22:50:40 +08:00
Melody Ruch aa91a0aaa9 fix(duckdb): preserve CURRENT_DATE arithmetic literals 2026-08-25 22:36:40 +08:00
Klinge cb581baf20 fix(grid): avoid canvas resize observer loops 2026-08-25 22:35:39 +08:00
zipg 19f8736b67 fix(editor): improve MySQL routine syntax diagnostics 2026-08-25 22:34:57 +08:00
whoops 5e307f56cc feat(redis): add configurable key search templates 2026-08-25 22:34:04 +08:00
高瑞强 bfd7c7def2 feat(mongo): open object browser from database tree nodes 2026-08-25 22:15:40 +08:00
二丫讲梵 46111850db feat(export): support XLSX comment headers 2026-08-25 22:00:26 +08:00
zipg 86f36e51ad fix(sidebar): improve active connection indicator 2026-08-25 21:54:53 +08:00
zipg c6e9e94c58 fix(editor): restore configurable completion acceptance 2026-08-25 21:52:01 +08:00
jearhe f9c4b19a60 fix(grid): use native context menu in filter inputs 2026-08-25 21:28:55 +08:00
Zzz a66590868e fix(kingbase): resolve schema for unqualified grid edits 2026-08-25 21:26:00 +08:00
github-actions[bot] 7494cf8011 feat(ci): bootstrap VSign operator credentials 2026-08-25 20:29:34 +08:00
github-actions[bot] 18ee40bb5d feat(ci): add VSign operator bootstrap probe 2026-08-25 20:29:34 +08:00
t8y2 ef9d0b8042 fix(export): preserve PostgreSQL bytea values
Closes #7184
2026-08-25 12:24:11 +00:00
t8y2 988fa1aabb fix(postgres): support PostgreSQL 9.2 batched DDL metadata
Closes #7142
2026-08-25 11:49:52 +00:00
github-actions[bot] 9b4f94cbb5 fix(completion): trigger quoted table suggestions 2026-08-25 19:22:29 +08:00
t8y2 151172f81a fix(postgres): qualify view source relation references
Closes #7137
2026-08-25 10:44:25 +00:00
github-actions[bot] 51f7b1d8b5 fix(docs): read generated connection options in content check 2026-08-25 18:17:39 +08:00
github-actions[bot] c27246ca01 fix(ci): restore JDBC files before branch switch 2026-08-25 17:43:33 +08:00
github-actions[bot] 8d2e627efe chore(packages): release 0.4.72 [skip node-packages-release] 2026-08-25 17:31:10 +08:00
github-actions[bot] 5c29af80ec fix(ci): authenticate bot pushes through checkout 2026-08-25 17:28:14 +08:00
github-actions[bot] 013fb011e5 chore: bump module versions [skip ci] 2026-08-25 17:28:13 +08:00
github-actions[bot] 79af004b6e fix(ci): push agent version bump with release token 2026-08-25 17:22:49 +08:00
github-actions[bot] eae1fa0c75 fix(ci): push JDBC bump with release token 2026-08-25 17:14:50 +08:00
github-actions[bot] 33c0cf2050 chore(jdbc): bump plugin version [skip ci] 2026-08-25 17:14:49 +08:00
t8y2 dfbdcfc04f chore(release): prepare v0.5.94 2026-08-25 16:15:51 +08:00
zipg 81c3cc15d2 fix(memory): release closed query and connection resources 2026-08-25 15:47:23 +08:00
t8y2 88e659833d fix(sql): format Oracle queries with PL/SQL dialect
Closes #7138
2026-08-25 07:17:11 +00:00
Elias e9fea304ba fix(xugu): preserve spatial SRIDs in exports 2026-08-25 15:15:41 +08:00
t8y2 343d006300 feat(editor): require explicit SQL completion selection
Closes #1191
2026-08-25 06:49:45 +00:00
zipg 9b8721e0e8 fix(sql): preserve quoted parameter null semantics 2026-08-25 14:46:46 +08:00
lizzzzz777 876f850e2d feat(editor): format SQL files on save 2026-08-25 14:46:25 +08:00
二丫讲梵 249896dddf fix(data-grid): preserve scoped context selections 2026-08-25 14:45:15 +08:00
t8y2 7dfd0fdd5a fix(tests): cover explicit app close prompt mode 2026-08-25 06:31:35 +00:00
Zzz 316a1f2a7c fix(mcp): support pnpm global shim with separate node runtime 2026-08-25 14:26:13 +08:00
zipg b488942d82 feat(connection): preserve failed one-time connections 2026-08-25 14:02:51 +08:00
t8y2 d1d8dc4aec feat(tabs): keep SQL drafts on app exit by default
Closes #7134
2026-08-25 05:45:31 +00:00
t8y2 cf652f212e feat(completion): support OceanBase Oracle alias columns
Closes #7126
2026-08-25 05:33:12 +00:00
t8y2 e977e886c3 fix(query): skip metadata for constant projections
Closes #7111
2026-08-25 05:05:55 +00:00
t8y2 eccbc5a315 fix(editor): support Spark datasource DDL for Kyuubi
Closes #7120
2026-08-25 04:21:42 +00:00
t8y2 4eabeb6a15 fix(settings): allow option card text to wrap
Closes #7121
2026-08-25 04:07:31 +00:00
t8y2 9c90edabe3 fix(completion): replace partially quoted identifiers 2026-08-25 11:57:52 +08:00
t8y2 e7ef9df85c fix(transfer): support default tree-schema databases
Closes #7104
2026-08-25 03:19:26 +00:00
t8y2 7c8e0a88cf fix(grid): refresh saved rows in place 2026-08-25 11:05:38 +08:00
t8y2 08365d73eb fix(ci): sync frontend tests with grid changes 2026-08-25 11:02:27 +08:00
t8y2 4be1928d15 chore(github): stop automatic review requests 2026-08-25 10:55:06 +08:00
t8y2 2cf79b3c9f fix(editor): persist transaction mode changes 2026-08-25 10:49:58 +08:00
t8y2 0aa18257e7 fix(sidebar): restore database-first sticky headers 2026-08-25 10:33:13 +08:00
t8y2 418951c1e2 fix(settings): sync live preview line numbers 2026-08-25 10:31:46 +08:00
t8y2 7686d7dc13 fix(tabs): replace tab icon during query execution 2026-08-25 10:20:47 +08:00
t8y2 a2f8824e6a feat(tabs): show query execution status across tab surfaces
Closes #4986
2026-08-25 01:34:00 +00:00
t8y2 277386ec81 feat(mysql): edit table auto-increment counter
Closes #7065
2026-08-25 01:24:09 +00:00
t8y2 44dc2a88f8 feat(grid): add edit structure shortcut
Closes #4303
2026-08-25 00:41:11 +00:00
t8y2 2f464e0d33 feat(settings): move editor live preview near appearance controls
Closes #4346
2026-08-24 23:59:42 +00:00
t8y2 a6edbd7fcb feat(transfer): make data transfer dialog resizable
Closes #1149
2026-08-24 23:53:13 +00:00
t8y2 efba56c8c0 feat(grid): compare original and edited JSON values
Closes #1638
2026-08-24 23:25:24 +00:00
t8y2 b3e8709272 feat(grid): show average for numeric selections
Closes #4929
2026-08-24 22:53:11 +00:00
t8y2 e2fa810f2e feat(grid): distinguish blank and null filters
Closes #5006
2026-08-24 22:33:21 +00:00
t8y2 f621e40655 feat(grid): add pagination shortcut mappings
Closes #4743
2026-08-24 20:59:29 +00:00
t8y2 cd18b7a3db feat(grid): add go-to-column shortcut mapping
Closes #4108
2026-08-24 20:41:29 +00:00
t8y2 5c75c2391c feat(editor): select SQL string content on double click
Closes #4261
2026-08-24 20:22:38 +00:00
t8y2 68b0ab0476 feat(grid): configure cell detail button visibility
Closes #5593
2026-08-24 19:54:35 +00:00
t8y2 3232195e1f fix(editor): preserve visible cursor after query execution
Closes #7037
2026-08-24 19:32:48 +00:00
t8y2 9af98c2db2 feat(shortcuts): toggle query results pane
Closes #6193
2026-08-24 19:24:54 +00:00
t8y2 adb7578998 feat(grid): paste rows from blank new cells
Closes #6144
2026-08-24 18:56:44 +00:00
t8y2 332daba302 fix(query): protect unsaved tabs on connection close
Closes #4641
2026-08-24 18:44:23 +00:00
t8y2 492c9727ae feat(editor): set all SQL parameters to raw
Closes #5823
2026-08-24 18:32:50 +00:00
t8y2 6b5b33540f feat(grid): show column defaults in new row placeholders
Closes #3096
2026-08-24 18:22:10 +00:00
t8y2 9e135f7609 fix(export): bound stalled PostgreSQL backup streams
Closes #7087
2026-08-24 16:55:00 +00:00
t8y2 2305f2de22 fix(tdsql): preserve directives in generated query SQL
Closes #7097
2026-08-24 16:38:12 +00:00
1d3d6c8bb1 fix(jdbc): expose Oracle indexes and objects (#7096)
* fix(jdbc): expose Oracle indexes and objects

* fix(jdbc): degrade unsupported index metadata to empty list

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
Co-authored-by: t8y2 <t8y2@users.noreply.github.com>
2026-08-25 00:10:22 +08:00
t8y2 d9674029f3 fix(mysql): enable cleartext authentication for Doris profiles
Closes #5355
2026-08-24 14:31:06 +00:00
zipg 7904231566 fix(kingbase): display char column lengths from bpchar metadata 2026-08-24 21:42:46 +08:00
zipg 992ac00aff fix(backup): hide unsupported database backup entries 2026-08-24 21:29:21 +08:00
t8y2 6baf17f609 fix(ssh): trim Pageant username buffer padding
Closes #7091
2026-08-24 13:19:25 +00:00
t8y2 ba25a871fd fix(query): guard unsupported stale manual transaction state
Closes #7045
2026-08-24 12:55:43 +00:00
t8y2 255faf0e4c fix(elasticsearch): support OpenSearch Dashboards proxy XSRF
Closes #7075
2026-08-24 12:37:43 +00:00
二丫讲梵 8d23216d60 feat(completion): show table metadata comments in SQL completions 2026-08-24 20:36:58 +08:00
zipg f45fcdb69d feat(grid): preview text stored in MySQL BLOB cells 2026-08-24 20:34:04 +08:00
onenewcode 566bc87014 fix(grid): chunk Oracle CLOB literals in data grid SQL 2026-08-24 20:32:19 +08:00
zipg d8e3d041cc feat(saphana): open stored procedure source 2026-08-24 20:32:05 +08:00
zipg 0b4159372e fix(formatter): keep ClickHouse date abbreviation alias case 2026-08-24 20:31:51 +08:00
Guoyu Su e0ffbe8992 fix(ssh): expand custom agent socket paths 2026-08-24 20:31:35 +08:00
jearhe ba60ffb954 fix(grid): ignore unchanged cell blur commits 2026-08-24 20:28:24 +08:00
Abeautifulsnow ff31f44b42 feat(ai): surface live AI generation status with smooth timing 2026-08-24 20:28:12 +08:00
Guoyu Su 697a8efe6e feat(kafka): filter loaded messages 2026-08-24 20:27:26 +08:00
jearhe ae3b991bd5 fix(ui): prevent connection select dialog overflow 2026-08-24 20:27:14 +08:00
t8y2 a62a51b922 fix(agent): tolerate post-startup driver stdout noise
Closes #7039
2026-08-24 12:03:12 +00:00
t8y2 d6bb83cad7 fix(oracle): reload deferred values by synthetic rowid
Closes #7082
2026-08-24 11:53:16 +00:00
t8y2 6cf8d8e035 fix(sqlserver): generate valid schema diff column DDL
Closes #7051
2026-08-24 11:46:55 +00:00
t8y2 d29c2d85cb chore(github): add repository code ownership 2026-08-24 19:19:05 +08:00
t8y2 e1a4242729 fix(grid): keep grouped column comments in lookup
Closes #7071
2026-08-24 11:07:55 +00:00
t8y2 7a098a276a fix(tidb): preserve new view create statements
Closes #7050
2026-08-24 11:01:55 +00:00
GIWTO 57b350eb49 fix(sidebar): Dameng tree blank band, admin node duplication and schema auto-expand 2026-08-24 18:58:15 +08:00
jearhe 707296c71d fix(data-grid): preserve resized column widths across query reruns 2026-08-24 18:47:31 +08:00
二丫讲梵 ff4667b93f feat(structure): search and paginate source table picker for copy columns 2026-08-24 18:47:13 +08:00
zipg 5bafb68919 fix(grid): continue Oracle manual transaction cursor for infinite scroll 2026-08-24 18:37:30 +08:00
zipg 4021216155 fix(sidebar): refresh local table search index on tree changes 2026-08-24 18:37:13 +08:00
jearhe 4a478acf07 feat(editor): allow hiding query line numbers 2026-08-24 18:35:34 +08:00
miracle eaad88cc80 fix(objects): refresh event editor on selection and fix docs-export manifest 2026-08-24 18:35:17 +08:00
Melody Ruch c1888a2364 fix(storage): use WAL and immediate transactions for shared SQLite data dir 2026-08-24 18:34:49 +08:00
t8y2 bdba8ace7d fix(milvus): accept REST success response code
Closes #7074
2026-08-24 10:31:56 +00:00
github-actions[bot] 3f960f51ab chore(packages): release 0.4.71 [skip node-packages-release] 2026-08-24 09:02:04 +00:00
github-actions[bot] d7c81195d9 chore: bump module versions [skip ci] 2026-08-24 08:44:31 +00:00
t8y2 0aa1fb9d7e fix(ci): copy connection-types in docker image and align auto-commit tests 2026-08-24 15:55:05 +08:00
t8y2 357fcbbcf7 chore: bump version to 0.5.93 2026-08-24 15:31:26 +08:00
KlingeandWenJiu 332ce7405d fix(postgres): generate table DDL on PostgreSQL 9.x (#7042)
Co-authored-by: WenJiu <jilin.zhou@winkeytech.com>
2026-08-24 15:10:10 +08:00
t8y2 781cf01c78 fix(query): default query tabs to auto-commit 2026-08-24 15:01:55 +08:00
zipgandzipg c021fe009b fix(jdbc): support manual transactions and auto-commit control (#6997)
* fix(jdbc): support manual transactions

* fix(jdbc): preserve paged query transaction state

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-24 14:50:18 +08:00
zipg c8dbeeaf7d fix(ui): keep driver store/settings page switching and tab active state (#7035) 2026-08-24 14:34:59 +08:00
t8y2 0efce9e9a9 fix(tests): update sidebar paste CI assertions 2026-08-24 14:04:19 +08:00
t8y2 63c19a9230 fix(docker): copy scripts and plugins for vite connection types sync 2026-08-24 13:59:25 +08:00
二丫讲梵 05d8e1333e fix(nacos): adapt service workbench layout to narrow panes 2026-08-24 13:34:29 +08:00
jearhe a8369390df feat(sidebar): close connected connections in group 2026-08-24 13:33:37 +08:00
zipg 917bbc1238 fix(oracle): speed up Oracle 11g system object column metadata loading 2026-08-24 13:32:42 +08:00
Elias d7adff54b7 feat(xugu): expose spatial query values as WKT 2026-08-24 13:31:48 +08:00
zipg 8cc924270c fix(editor): preserve user-adjusted SQL editor position during execution 2026-08-24 13:30:50 +08:00
zhangyc 4d7d4ab74e feat(web): support WebDAV config sync on web 2026-08-24 13:29:23 +08:00
Melody Ruch 8187ef9baf fix(sql): avoid O(n^2) re-tokenization in MySQL routine editing 2026-08-24 13:28:41 +08:00
onenewcode 2f47a23c4c fix(postgres): display money query values 2026-08-24 13:27:57 +08:00
zipg bd9f10cb5a fix(agents): build Win7-compatible RabbitMQ agent with Go 1.20 2026-08-24 13:27:12 +08:00
Allen 29b527fd3a feat(transfer): show source/target pickers as group trees 2026-08-24 13:26:08 +08:00
lizzzzz777 80240d0ba6 feat(structure-editor): support ctrl/shift multi-select for columns 2026-08-24 13:08:36 +08:00
zipg 4c938328b7 fix(oracle): avoid Oracle 11g table tree timeout 2026-08-24 12:51:11 +08:00
zipg c0665d69f3 fix(ci): sync sidebar table paste assertions 2026-08-24 12:50:58 +08:00
zipg 2124cccef7 fix(oracle): support editing query results without primary key 2026-08-24 12:50:45 +08:00
二丫讲梵 c462272d8e feat(editor): add copy button for floating DDL hint 2026-08-24 12:50:31 +08:00
t8y2 7e1e9053f8 fix(query): box transaction command errors 2026-08-24 12:38:05 +08:00
t8y2 e03cc2d269 fix(query): recover expired manual transaction sessions 2026-08-24 12:19:27 +08:00
t8y2 0047d79974 fix(grid): preserve MySQL tinyint numeric edits
Closes #7015
2026-08-24 03:12:16 +00:00
t8y2 c1e21c7261 fix(docs): avoid duplicate column type modifiers
Closes #7010
2026-08-24 02:29:34 +00:00
t8y2 55c2b63c70 fix(export): make database export cancellation responsive
Closes #7003
2026-08-24 01:45:32 +00:00
t8y2 497f070254 fix(ssh): support Pageant for Windows local accounts
Closes #6998
2026-08-24 00:45:09 +00:00
t8y2 3e9c383732 fix(oracle): prefer physical row identifiers over ROWID
Closes #6575
2026-08-23 22:53:52 +00:00
t8y2 e0be55ddf3 fix(ci): preserve table paste success contract
Refs: #4832
2026-08-23 22:17:22 +00:00
t8y2 e11a0d467d feat(ai): add generic AI configuration deep links 2026-08-24 06:05:24 +08:00
github-actions[bot] 3ece54d07e chore(docs): refresh contributor activity 2026-08-23 18:05:28 +00:00
t8y2 72bb880ee7 fix(table-paste): surface first batch paste error
Closes #4832
2026-08-23 16:24:19 +00:00
t8y2 e1eb44f626 fix(ci): format export selection helper 2026-08-23 22:25:15 +08:00
t8y2 e1f48bdd60 fix(ci): restore Rust lint and docs bundle checks 2026-08-23 14:03:53 +00:00
t8y2 b4b04d30b8 fix(export): allow routine-only database exports
Closes #6888
2026-08-23 22:00:42 +08:00
t8y2 e15ade130d fix(linux): disable DMABuf for Strix Halo on Wayland
Closes #6709
2026-08-23 11:09:14 +00:00
Xinbeok 2927e01591 feat(ai): add resizable maximized chat panel 2026-08-23 18:29:35 +08:00
t8y2 add44cf39e fix(duckdb): preserve prefix aliases during SQL formatting
Closes #6977
2026-08-23 10:11:25 +00:00
jearhe 351dc81a30 fix(schema-diff): align table selection layout 2026-08-23 18:08:17 +08:00
t8y2 6a06a12455 feat(nacos): support DataId mapping in config sync
Closes #6439
2026-08-23 10:00:41 +00:00
t8y2 d616e6b1a8 fix(iotdb): expose Tree Time in data grid filters
Closes #6434
2026-08-23 09:56:15 +00:00
t8y2 493e18755d fix(rocketmq): decode consumer client assignments
Closes #6967
2026-08-23 09:50:46 +00:00
t8y2 6e189723bd fix(sql): recognize @set declarations after comments
Closes #6974
2026-08-23 09:44:33 +00:00
t8y2 4dd107484f fix(ci): merge supplemental database issue aliases 2026-08-23 16:41:46 +08:00
t8y2 0827478078 feat(connection): centralize connection type registration 2026-08-23 16:38:06 +08:00
Melody Ruch 38664e6aea fix(codeSnapshot): preserve line structure for line numbers 2026-08-23 12:25:45 +08:00
zhangyc 726f86e24c feat(schema-diff): select fields and indexes for synchronization 2026-08-23 12:24:47 +08:00
miracle d8bc5153d2 feat(mysql): manage and edit MySQL events 2026-08-23 12:23:46 +08:00
t8y2 701f188e45 fix(ui): restore query tab activation
Closes #6968
2026-08-23 03:54:17 +00:00
t8y2 4e09b007c0 fix(ci): correct Windows Perl setup syntax 2026-08-23 11:39:43 +08:00
t8y2 bc19d25ccd fix(transfer): strip Dameng storage clauses from reused DDL
Closes #6833
2026-08-23 11:34:22 +08:00
t8y2 3b66e88356 fix(ci): provide full Perl for Windows package builds 2026-08-23 11:12:38 +08:00
t8y2 1db4148a93 fix(ci): make package release preparation idempotent 2026-08-23 10:24:06 +08:00
二丫讲梵 e55ee6054d feat(meilisearch): add workspace, key, and task management 2026-08-23 02:53:52 +08:00
二丫讲梵 93dc455c72 feat(result-tabs): support pinned tabs and batch closing 2026-08-23 02:53:07 +08:00
github-actions[bot] 9815e5ac81 chore(docs): refresh contributor activity 2026-08-22 18:05:20 +00:00
Allen 096e0ac409 feat(ai): show connection picker as a group tree 2026-08-23 01:40:51 +08:00
二丫讲梵 c4630540dd feat(export): support name and comment XLSX headers 2026-08-23 01:40:22 +08:00
jearhe 0908a944e8 fix(sidebar): keep table search results on Enter 2026-08-23 01:39:53 +08:00
zipg acc2137cde feat(postgres): support editing table owners 2026-08-23 01:39:23 +08:00
zipg 4a1f3e7141 feat(data-grid): support manual reference display values 2026-08-23 01:38:50 +08:00
jearhe b74d49e982 feat(sidebar): extend filters to views and routines 2026-08-23 01:38:19 +08:00
t8y2 3dbcc2d0dc fix(ci): install OpenSSL Perl module on Windows 2026-08-22 17:10:06 +00:00
github-actions[bot] c9f184ac5d chore(packages): release 0.4.70 [skip node-packages-release] 2026-08-22 15:34:17 +00:00
github-actions[bot] a1a4415425 chore: bump module versions [skip ci] 2026-08-22 15:17:50 +00:00
github-actions[bot] 9f638f9c79 chore(jdbc): bump plugin version [skip ci] 2026-08-22 13:25:04 +00:00
t8y2 3bbcab3bab chore(release): prepare v0.5.92 2026-08-22 21:22:54 +08:00
t8y2 a1e94d8228 feat(oracle): support adding primary keys to existing tables
Closes #6922
2026-08-22 12:24:02 +00:00
t8y2 946677a579 feat(dameng): support toggling identity on existing columns
Closes #6906
2026-08-22 11:40:51 +00:00
Tara bf029cd342 feat(postgres): support safe database renaming 2026-08-22 19:30:38 +08:00
jearhe 3118e0d617 feat(config): support optional plaintext connection exports 2026-08-22 19:19:10 +08:00
二丫讲梵 904efff6b3 feat(sidebar): support hierarchical batch connection moves 2026-08-22 19:18:11 +08:00
zipg 36c9500a4e fix(tdengine): display timezone timestamps and tags correctly 2026-08-22 19:17:05 +08:00
t8y2 2fee435b33 feat(mcp): add ZCode configuration guidance
Closes #6763
2026-08-22 11:14:06 +00:00
t8y2 906ea8ba26 feat(tabs): locate tab objects in sidebar
Closes #6681
2026-08-22 10:31:17 +00:00
t8y2 53b7be3c6f fix(grid): handle horizontal wheel input
Closes #6793
2026-08-22 10:17:35 +00:00
t8y2 de99931c04 feat(sidebar): support quick connection rename
Closes #6792
2026-08-22 10:06:21 +00:00
t8y2 3f1544d45e fix(ai): align send button with prompt submission
Closes #6949
2026-08-22 09:31:12 +00:00
t8y2 756d4aedc9 fix(test): settle vector imports before teardown 2026-08-22 09:16:13 +00:00
t8y2 389faff8ee fix(sqlserver): propagate USE context through query batches
Closes #6947
2026-08-22 09:00:22 +00:00
zipg 0559f9f76e feat(query): resume failed SQL batches safely 2026-08-22 16:37:24 +08:00
jearhe 9774a194ec fix(connection): parse OceanBase JDBC URLs 2026-08-22 16:37:20 +08:00
t8y2 6304335fcc fix(test): await pending frontend module teardown 2026-08-22 08:33:19 +00:00
Guoyu Su 82e5756325 feat(agent): export managed drivers for offline use 2026-08-22 15:41:21 +08:00
Klinge 6eb8b43701 fix(macos): prevent Escape from exiting native fullscreen 2026-08-22 15:41:17 +08:00
miracle 3442acd52c feat(completion): optimize qualified SQL completion queries 2026-08-22 15:41:13 +08:00
t8y2 fe269a28db feat(zookeeper): add UTF-8 value preview
Closes #6897
2026-08-22 07:21:26 +00:00
t8y2 b2cf487734 feat(mcp): add CodeBuddy configuration guide
Closes #6729
2026-08-22 07:17:06 +00:00
t8y2 33dfaf957f test(vector): make data grid mock synchronous 2026-08-22 06:50:10 +00:00
t8y2 a0c9a1c878 feat(redis): insert command argument examples
Closes #6925
2026-08-22 06:04:07 +00:00
jearhe f274a599ed fix(sql): respect PostgreSQL ARRAY brackets 2026-08-22 14:01:46 +08:00
t8y2 11179676ed fix(sidebar): locate grouped connections after restart
Closes #6840
2026-08-22 05:46:37 +00:00
二丫讲梵 a6f4cebd18 feat(editor): include database name in generated SQL 2026-08-22 13:40:42 +08:00
t8y2 fcb6ea92db fix(editor): cache semantic highlight windows
Closes #6936
2026-08-22 05:07:09 +00:00
onenewcode 6aaac7133f fix(grid): unify long-cell double-click selection 2026-08-22 12:58:28 +08:00
zipg 3858cf1e59 fix(gbase): sync type label after version switch 2026-08-22 12:57:55 +08:00
zipg edffa6706d fix(editor): preserve active cell border on row highlight 2026-08-22 12:57:23 +08:00
jearhe e8c759e314 fix(phoenix): handle system catalog special types 2026-08-22 12:54:28 +08:00
Melody Ruch a2c9317c8b fix(sql): resolve MyBatis where tags before execution 2026-08-22 12:52:54 +08:00
t8y2 355f34f3c7 fix(sql): split declaration-only Oracle package bodies
Closes #6943
2026-08-22 04:37:01 +00:00
t8y2 f3d2b6cb01 fix(grid): clear stale header context menu targets
Closes #6869
2026-08-22 03:50:15 +00:00
t8y2 62657446f3 feat(sql): add group-by multi-column completion
Closes #6939
2026-08-22 03:02:42 +00:00
t8y2 d33161bb71 fix(databricks): use backticks for generated identifiers
Closes #6915
2026-08-22 02:18:10 +00:00
t8y2 5ca99c632b fix(iris): return column descriptions
Closes #6912
2026-08-22 02:00:21 +00:00
t8y2 621074beca fix(redis): allow db0 commands without SELECT support
Closes #6876
2026-08-22 01:50:50 +00:00
t8y2 509d28c69a fix(rocketmq): restore consumer client assignments
Closes #6898
2026-08-22 01:38:58 +00:00
t8y2 b015cfeef5 fix(tdengine): preserve FLOAT decimal precision
Closes #6937
2026-08-22 01:07:45 +00:00
t8y2 da49e03048 fix(mcp): resolve mise-managed server shims
Closes #6930
2026-08-22 01:03:17 +00:00
github-actions[bot] bb90c5febc chore(docs): refresh contributor activity 2026-08-21 18:08:57 +00:00
t8y2 3bec5091df fix(grid): use table-open page size for documents
Closes #6903
2026-08-21 17:32:32 +00:00
t8y2 f57cd3960b fix(oracle): expose sequences in schema tree
Closes #6893
2026-08-21 17:22:32 +00:00
t8y2 37ca4c31ad fix(oceanbase): expose Oracle-mode sequences in schema tree
Closes #6895
2026-08-21 16:38:16 +00:00
zipg 7d5cae6486 fix(mysql): preserve JDBC TLS parameter semantics 2026-08-21 19:57:22 +08:00
zipg 8671a2fb2a feat(grid): support deleting custom column formatters 2026-08-21 19:44:52 +08:00
zipg 47e02b8da7 fix(oracle): defer XMLTYPE in manual transactions 2026-08-21 19:44:13 +08:00
zipg e50bd26141 feat(grid): load large-value previews by viewport 2026-08-21 19:05:44 +08:00
Justin Gao 31a50fb460 feat(editor): preserve unsaved SQL drafts on exit 2026-08-21 18:38:53 +08:00
onenewcode 52c892676c feat(vector): support renaming Milvus collections 2026-08-21 18:19:47 +08:00
zipg 4b75334164 fix(editor): avoid quadratic SQL folding scans 2026-08-21 18:13:55 +08:00
tatuke 68acf9f132 fix(import): preserve explicit DBeaver database names 2026-08-21 18:12:39 +08:00
zipg 2bb85dcdc9 fix(agent): accept legacy H2 offline packages 2026-08-21 17:51:50 +08:00
t8y2 00b641aaec fix(table-editor): coalesce MySQL auto-increment key changes
Closes #6873
2026-08-21 09:09:39 +00:00
t8y2 102f24798c fix(snapshot): wire code snapshot option switches
Closes #6870
2026-08-21 08:27:23 +00:00
t8y2 49296c8930 fix(data-grid): serialize JSON documents in copied SQL assignments
Closes #6871
2026-08-21 07:59:26 +00:00
t8y2 0daa23ed8f fix(desktop): validate app-open links on secondary launch 2026-08-21 15:17:33 +08:00
t8y2 7d6a7c2321 feat(desktop): support dbx://open deep links 2026-08-21 14:55:46 +08:00
github-actions[bot] 81e174e332 chore(packages): release 0.4.69 [skip node-packages-release] 2026-08-21 06:34:29 +00:00
github-actions[bot] 655cd12d9d chore: bump module versions [skip ci] 2026-08-21 06:19:21 +00:00
t8y2 dbe653d3d2 chore(release): prepare v0.5.91 2026-08-21 13:24:41 +08:00
t8y2 2d321b3537 fix(sql): preserve consecutive Oracle PL/SQL blocks
Closes #6843
2026-08-21 05:04:02 +00:00
zipg 0ba2021181 fix(sqlserver): suppress diagnostics for routine batches 2026-08-21 13:03:48 +08:00
Allen 09b326558f feat(ai): drag scoped table references into chat 2026-08-21 13:03:14 +08:00
t8y2 cc78d4f528 fix(ai): include Node runtime in Codex CLI path
Closes #6849
2026-08-21 04:34:41 +00:00
zipg 4a22889b17 fix(editor): prevent stale completion metadata after refresh 2026-08-21 12:22:14 +08:00
Zhuxing 4bf7551b82 fix(redis): support legacy pickle and valid XML tags 2026-08-21 12:21:37 +08:00
二丫讲梵 b09fd35cd8 feat(redis): delete leaf keys from the key list 2026-08-21 12:20:57 +08:00
jearhe 25d5b885ba fix(kafka): support scoped IPv6 bootstrap addresses 2026-08-21 12:20:19 +08:00
t8y2 c210efbcc5 fix(mysql): complete built-in function suggestions
Closes #6846
2026-08-21 04:09:56 +00:00
t8y2 ec50662793 fix(oracle): avoid slow 11g synonym metadata lookup
Closes #6855
2026-08-21 03:47:35 +00:00
t8y2 02402de53f fix(spanner): handle array values and intrinsic numeric DDL
Closes #6841
2026-08-21 02:57:23 +00:00
zipg ab0ff28fe4 fix(postgres): 支持旧版 TLS 兼容模式 2026-08-21 10:52:27 +08:00
Abeautifulsnow 12da615daa fix(ai): require exact SQL confirmation for agent writes 2026-08-21 10:36:17 +08:00
jearhe f2e13922a2 fix(phoenix): preserve schema in all table references 2026-08-21 10:18:56 +08:00
t8y2 e139f63953 fix(postgres): preserve primary key constraint names
Closes #6821
2026-08-21 02:04:03 +00:00
jearhe 5792ce1244 fix(doris): preserve single external catalog visibility 2026-08-21 09:27:24 +08:00
github-actions[bot] 68179d16f5 chore(docs): refresh contributor activity 2026-08-20 18:34:34 +00:00
t8y2 4c13e21cca fix(transfer): avoid cloned csv regression fixture 2026-08-21 01:28:02 +08:00
二丫讲梵 7fa94152a8 feat(nacos): add access control and namespace administration 2026-08-21 01:15:46 +08:00
t8y2 30f75d8388 fix(transfer): reduce import and export allocations 2026-08-21 01:10:17 +08:00
t8y2 b5d3343f9a feat(oracle): support manual query transactions 2026-08-21 01:05:59 +08:00
二丫讲梵 a7edb8f8db feat(grid): add conditional bulk updates for MySQL 2026-08-21 01:04:16 +08:00
t8y2 10d1509d92 fix(sidebar): isolate virtual tree row identities
Closes #6773
2026-08-20 15:59:10 +00:00
t8y2 90a18a7b23 fix(editor): refresh context actions on first right-click
Closes #6811
2026-08-20 14:38:00 +00:00
t8y2 5767a0fd59 fix(snapshot): preserve previews when syntax highlighting fails
Closes #6777
2026-08-20 14:33:10 +00:00
t8y2 69e5e81071 fix(grid): preserve exact totals beyond the result row limit
Closes #6786
2026-08-20 14:26:52 +00:00
Melody Ruch b4015a4393 fix(sidebar): make destructive table operation cancellation reliable 2026-08-20 22:23:37 +08:00
Allen 7ce992b8f8 feat(tabs): add Ctrl+Tab MRU switcher 2026-08-20 22:12:52 +08:00
dadong 81872165da fix(snapshot): preserve code layout and export full images 2026-08-20 22:11:49 +08:00
t8y2 68d2919219 fix(sqlserver): skip rowversion columns when copying table data
Closes #6796
2026-08-20 14:05:47 +00:00
t8y2 b0448da0cc fix(tabs): distinguish structure editors by schema
Closes #6799
2026-08-20 13:51:45 +00:00
Melody Ruch c030835acf fix(jdbc): keep local plugin installation fully offline 2026-08-20 21:39:00 +08:00
zipg be9f8e72e4 fix(postgres): resolve result metadata through search_path 2026-08-20 21:38:20 +08:00
dadong 8a9b02fa07 fix(settings): keep color picker panels within the viewport 2026-08-20 21:37:34 +08:00
t8y2 751894d224 fix(editor): cover soft-wrapped statements with the current frame
Closes #6764
2026-08-20 13:25:41 +00:00
t8y2 6aee39e884 fix(sqlserver): tolerate malformed UTF-16 row values
Closes #6758
2026-08-20 13:12:18 +00:00
SCSTC e8e980c25c feat(connection): unify Ignite version selection 2026-08-20 21:01:23 +08:00
zipg 8031a9e651 fix(mysql): report batch DML progress without phantom failures 2026-08-20 21:00:41 +08:00
t8y2 1a3f20f6c3 fix(kingbase): return materialized view source definitions
Closes #6766
2026-08-20 12:52:01 +00:00
Melody Ruch e2a4b26379 fix(grid): show deleted row details in confirmation 2026-08-20 20:49:47 +08:00
jearhe b3acf1e558 fix(wry): recover from WebView2 process failures 2026-08-20 20:42:45 +08:00
大熊 d2e2270d84 fix(grid): edit multi-cell selections inline 2026-08-20 20:41:51 +08:00
Andrew 5741bc1256 feat(gaussdb): support M-mode index metadata and DDL 2026-08-20 18:50:22 +08:00
t8y2 ba0f864234 fix(kingbase): retry plaintext after V7 TLS failure
Closes #6803
2026-08-20 10:45:05 +00:00
onenewcode 439f2c2617 feat(milvus): add database and collection deletion 2026-08-20 18:25:12 +08:00
zipg db8f262c14 fix(grid): reset pagination when table filters change 2026-08-20 18:15:51 +08:00
Melody Ruch 3132b17323 feat(editor): add semantic diagnostics toolbar toggle 2026-08-20 17:59:35 +08:00
t8y2 345cd3f8b3 feat(h2): bundle multi-version JDBC drivers 2026-08-20 17:41:22 +08:00
github-actions[bot] a6a76640e0 chore(packages): release 0.4.68 [skip node-packages-release] 2026-08-20 09:16:36 +00:00
github-actions[bot] 321a8d03af chore: bump module versions [skip ci] 2026-08-20 09:02:12 +00:00
t8y2 501990b29b chore(release): prepare v0.5.90 2026-08-20 16:53:29 +08:00
Frost f494f90c30 feat(spanner): add Google Cloud Spanner support 2026-08-20 16:33:09 +08:00
zipg 2cc8d78e64 fix(grid): restore data grid rendering 2026-08-20 16:17:58 +08:00
amwps290 11407b537f feat(postgres): add table vacuum action 2026-08-20 16:04:22 +08:00
二丫讲梵 0e0edcd206 feat(ai): add connection tree context 2026-08-20 15:15:19 +08:00
jearhe e4577abeed feat(config): support selective connection import and export 2026-08-20 15:15:05 +08:00
Melody Ruch 89e0f1734b fix(sidebar): keep destructive table operations cancellable 2026-08-20 15:14:45 +08:00
Frost dd77817c7e docs(icons): narrow brand attribution claims 2026-08-20 15:14:32 +08:00
Elias 90af026193 fix(xugu): preserve type members during refresh 2026-08-20 15:14:19 +08:00
Elias 50607826d0 fix(xugu): reload package member groups from owner 2026-08-20 15:14:06 +08:00
Elias dcbd034afa feat(xugu): preserve index partition metadata 2026-08-20 15:13:07 +08:00
t8y2 d53c855b96 feat(docs): update database coverage to 90+ 2026-08-20 15:02:26 +08:00
t8y2 be1785d4fa fix(cassandra): list table triggers from system schema
Closes #6751
2026-08-20 06:34:13 +00:00
t8y2 5a921b3b76 fix(grid): show comments for keyless query results
Closes #6748
2026-08-20 06:28:04 +00:00
t8y2 12a2dd07f7 fix(editor): format PostgreSQL SQL with backticks
Closes #6741
2026-08-20 06:18:51 +00:00
github-actions[bot] dc74d2a77f chore(packages): release 0.4.67 [skip node-packages-release] 2026-08-20 06:08:23 +00:00
github-actions[bot] 8b0fd8d7d8 chore: bump module versions [skip ci] 2026-08-20 05:53:15 +00:00
t8y2 3f939e0458 fix(redshift): use compatible object metadata catalogs
Closes #6734
2026-08-20 05:45:44 +00:00
t8y2 fc67a323c4 fix(rabbitmq): parse connection addresses without URL
Closes #5280
2026-08-20 13:37:34 +08:00
t8y2 f3cc8b7a01 chore(release): prepare v0.5.89 2026-08-20 12:43:54 +08:00
wu-zhao-min e90dff7be8 fix(agent): clean up operation locks after use 2026-08-20 12:24:03 +08:00
t8y2 5014100554 revert(gaussdb): allow M-mode query result updates 2026-08-20 11:45:29 +08:00
zipg a6d03f3577 fix(connection): correct MongoDB OIDC form layout 2026-08-20 11:36:09 +08:00
t8y2 6f723a3b1c fix(postgres): defer exported triggers until after routines
Closes #6739
2026-08-20 03:35:40 +00:00
t8y2 8d82dbed08 Revert "chore(release): prepare v0.5.89"
This reverts commit 6a008a96fb.
2026-08-20 10:55:56 +08:00
zipg 06be463baa fix(gaussdb): allow M-mode query result updates 2026-08-20 10:43:56 +08:00
github-actions[bot] 021966bb62 chore(jdbc): bump plugin version [skip ci] 2026-08-20 02:42:28 +00:00
t8y2 6a008a96fb chore(release): prepare v0.5.89 2026-08-20 10:39:55 +08:00
Freedom bd36b5f1d0 fix(sidebar): expand connection node on double-click activation 2026-08-20 10:23:07 +08:00
dadong 1faf5715f1 feat(mysql): return table maintenance result sets 2026-08-20 09:47:53 +08:00
funnytime 8213da233b feat(tabs): add tab navigation history shortcuts 2026-08-20 09:28:39 +08:00
dadong c7797b4be1 feat(table): support openGauss object renaming 2026-08-20 09:28:29 +08:00
zipg 2c75a6535e feat(mongodb): support OIDC browser authentication 2026-08-20 09:27:04 +08:00
Melody Ruch f36d854e68 fix(transfer): defer MySQL foreign keys until after data transfer 2026-08-20 09:26:55 +08:00
jearhe 78aab1e5ed fix(connection): invalidate data-tab metadata on disconnect/reconnect 2026-08-20 09:25:14 +08:00
OctoBored b537c4b891 docs: fix broken star history chart 2026-08-20 08:48:49 +08:00
Guoyu Su f88167c3d0 feat(kafka): add consumer group backlog view 2026-08-20 08:47:05 +08:00
Melody Ruch dc8b083c63 fix(mysql): return CHECKSUM TABLE result set 2026-08-20 08:35:14 +08:00
github-actions[bot] c35f791681 chore(docs): refresh contributor activity 2026-08-19 18:30:44 +00:00
Lan-zk c286eec7d2 feat(mcp): add DeepSeek Harness configuration 2026-08-20 02:26:56 +08:00
SCSTC 634bb9a7d0 feat(ignite3): add Apache Ignite 3 support 2026-08-20 02:21:55 +08:00
t8y2 34c8afeb58 fix(mysql): refresh tables after proxy detection
Closes #6721
2026-08-19 18:19:04 +00:00
二丫讲梵 c2b7e9f269 fix(grid): preserve async context filter targets 2026-08-20 02:16:25 +08:00
Melody Ruch 15d73f3020 fix(sql-analysis): handle PostgreSQL procedure diagnostics 2026-08-20 01:59:26 +08:00
Elias 82da9cb6dc feat(xugu): expand table editor data types 2026-08-20 01:52:43 +08:00
Freedom 884744df4c feat(meilisearch): add document and settings views 2026-08-20 01:51:57 +08:00
dadong 39ce50b681 feat(redis): add TTL metadata support 2026-08-20 01:51:11 +08:00
t8y2 680358aff8 feat(grid): retain 20 condition history entries
Closes #6720
2026-08-19 17:29:08 +00:00
t8y2 c163adc8e5 revert(ai): remove OrcaRouter built-in provider 2026-08-19 17:05:10 +00:00
t8y2 bbb57b32c8 fix(sql): expand @set variables in selected statements
Closes #6719
2026-08-19 16:43:20 +00:00
二丫讲梵 58d9b48808 feat(ai): support image and text file attachments 2026-08-19 21:17:07 +08:00
Marc-oss-hub 5e9ac2876b feat(ai): add OrcaRouter as a built-in AI provider 2026-08-19 21:16:12 +08:00
t8y2 74be035352 feat(editor): show target columns for INSERT SELECT
Closes #6696
2026-08-19 13:10:22 +00:00
Melody Ruch 2b11af94ba fix(mysql): show scheduled events in the sidebar 2026-08-19 20:49:32 +08:00
t8y2 5011468a9a fix(settings): open settings with reactive color schemes 2026-08-19 20:44:56 +08:00
t8y2 4e74f77cc8 fix(editor): complete MySQL table names after DESC
Closes #6668
2026-08-19 12:41:29 +00:00
Melody Ruch 7a707e77d8 fix(sidebar): preserve order on same-folder sorted drops 2026-08-19 20:25:05 +08:00
GIWTO 34a158765e fix(sidebar): prevent blank tree after Dameng expansion 2026-08-19 20:23:17 +08:00
Melody Ruch 11903c6dfd fix(ai): constrain explain plan canvas overflow 2026-08-19 20:22:15 +08:00
t8y2 4c38b389ce fix(kingbase): list schema triggers with parent tables
Closes #6707
2026-08-19 11:24:56 +00:00
t8y2 e61bd75c72 fix(transfer): enable GoldenDB table transfers
Closes #6705
2026-08-19 10:47:31 +00:00
Melody Ruch e65c3ab914 chore(test): fix frontend format drift 2026-08-19 18:32:48 +08:00
t8y2 80a808d53c fix(jdbc): pass Phoenix URL properties to driver
Closes #6699
2026-08-19 10:24:58 +00:00
t8y2 9a1ead8ca4 fix(jdbc): preserve TDengine database context
Closes #6682
2026-08-19 09:43:12 +00:00
t8y2 bfbbc2bff2 fix(mysql): preserve generated columns in table editor 2026-08-19 17:25:21 +08:00
t8y2 1315b25c3e fix(dameng): preserve numeric bit result values
Closes #6674
2026-08-19 08:31:40 +00:00
t8y2 dee9d52761 fix(mcp): hide Windows console window 2026-08-19 16:08:09 +08:00
t8y2 bf6c8d4d2b fix(tdsql): list only logical partitioned tables
Closes #6660
2026-08-19 07:55:10 +00:00
jearhe 68d93c9427 fix(rabbitmq): preserve queue metrics and features 2026-08-19 15:49:26 +08:00
Abeautifulsnow 30f5c34729 feat(editor): add code snapshot export 2026-08-19 15:49:15 +08:00
dadong a40dc02825 fix(sidebar): align group selection with visible tree 2026-08-19 15:42:44 +08:00
dadong c09dfbc813 fix(sqlserver): avoid duplicate pagination AST parse 2026-08-19 15:36:54 +08:00
jearhe 4bba84bbdd fix(doris): show column comments in query results 2026-08-19 15:32:19 +08:00
Allen c10b4e92ce feat(editor): show connection group tree in toolbar 2026-08-19 15:31:24 +08:00
Melody Ruch 45819df9e3 fix(editor): handle mixed multi-range Tab indentation 2026-08-19 15:15:57 +08:00
Melody Ruch f4f894d979 fix(editor): ignore SQL Server transaction BEGIN in folding 2026-08-19 15:15:20 +08:00
二丫讲梵 f937ab9c6d feat(tabbar): add overflow tab search 2026-08-19 15:09:07 +08:00
Melody Ruch d085c5b4f4 fix(postgres): correct partition DDL generation 2026-08-19 15:08:54 +08:00
jearhe e9ca697c3b fix(schema-diff): keep compare action reachable 2026-08-19 15:08:42 +08:00
zipg ca8f6cd967 fix(connection): show global timeout help on Windows 2026-08-19 15:08:31 +08:00
zipg be171e9976 fix(sqlserver): preserve commented CTE queries 2026-08-19 15:08:19 +08:00
t8y2 ab2444c757 fix(postgres): retry plaintext for preferred TLS failures
Closes #6662
2026-08-19 07:04:44 +00:00
t8y2 f651e3a61f fix(iris): avoid quoting ordinary data grid identifiers
Closes #6663
2026-08-19 06:29:03 +00:00
t8y2 2196b743b0 fix(sidebar): preserve one-time links across connection reloads
Closes #6647
2026-08-19 05:51:01 +00:00
t8y2 81d22b32c7 fix(transfer): rewrite Dameng DDL target schema
Closes #6632
2026-08-19 05:14:28 +00:00
zipg 68ad9b2c5d fix(postgres): recover invalidated prepared statements 2026-08-19 11:44:40 +08:00
miracle 8c2b232243 fix(metadata): bound cache maintenance and pool recovery 2026-08-19 11:10:12 +08:00
zipg 21af5cf600 feat(mysql): support safe grouped result editing 2026-08-19 11:03:17 +08:00
zipg 533c7a9633 test(rust): prevent export test stack overflow 2026-08-19 10:00:25 +08:00
github-actions[bot] d0bc4751c3 chore(docs): refresh contributor activity 2026-08-18 18:33:32 +00:00
SCSTC 703c6bc81f feat(ignite): add Apache Ignite 2.x support 2026-08-19 02:04:13 +08:00
Guoyu Su e5ccb5cb01 fix(kingbase): use KingbaseES display name 2026-08-19 02:04:00 +08:00
Melody Ruch 09b2a1d552 fix(ssh): exclude TOFU prompt time from connect timeout 2026-08-19 01:51:54 +08:00
jearhe 6f5ec6c819 fix(postgres): prevent duplicate columns in generated DDL 2026-08-19 01:02:41 +08:00
t8y2 ca8f566674 fix(dameng): initialize DBMS output lazily
Closes #6400
2026-08-18 16:55:30 +00:00
Melody Ruch 43c62d85ad fix(grid): include all selected cells in SQL copy 2026-08-19 00:53:48 +08:00
t8y2 f3ffdcc27d perf(docs): optimize landing page rendering 2026-08-19 00:41:07 +08:00
zipg 5b1d6efc71 fix(kingbase): support V7 metadata catalogs 2026-08-19 00:34:06 +08:00
hanxuanyu fd41268432 fix(grid): stabilize row drag selection gestures 2026-08-19 00:30:40 +08:00
zipg b7338518d1 fix(iotdb): correct timestamp display and table editing 2026-08-18 23:24:57 +08:00
Võ Văn Thành (Jay) fa4238c5d3 fix(mongodb): use line comments in editor 2026-08-18 23:17:24 +08:00
zipg 6cbb10439e fix(data-grid): restore column quick filtering 2026-08-18 23:17:12 +08:00
dadong c4105d1e65 fix(editor): handle trailing comments in SQL ranges 2026-08-18 23:16:59 +08:00
dadong 1ecac25d52 docs(setup): update Windows development instructions 2026-08-18 23:16:45 +08:00
Andrew 58b7d8856a fix(gaussdb): persist count query DOP setting 2026-08-18 23:16:33 +08:00
kirito e0448ac87e feat(data-grid): add customizable data type colors 2026-08-18 23:05:17 +08:00
t8y2 95760e320a fix(oracle): list materialized views without table duplicates
Closes #6611
2026-08-18 14:33:54 +00:00
Guoyu Su 27fd668758 feat(ai): keep provider headers visible 2026-08-18 22:04:53 +08:00
Abeautifulsnow aeba7d81ad feat(objects): add search clear buttons 2026-08-18 22:04:38 +08:00
zipg bba6df6242 fix(mysql): ignore routine labels in compatible dialects 2026-08-18 22:04:24 +08:00
Melody Ruch e0bf2ca717 fix(schema-diff): highlight deploy script selections 2026-08-18 22:04:09 +08:00
Guoyu Su 9dd00b9e85 chore(gitignore): ignore Python cache artifacts 2026-08-18 22:03:55 +08:00
dadong 4b2365b961 fix(tabs): keep close-confirmation actions visible 2026-08-18 22:03:40 +08:00
zipg 3b75c8861d fix(impala): keep long-running queries active 2026-08-18 22:03:26 +08:00
Melody Ruch ea4a258d24 fix(ai): keep ordered list markers inside message bubbles 2026-08-18 22:03:11 +08:00
Melody Ruch 7be64f45a7 fix(redis): mark key-tree counts as partial while scanning 2026-08-18 22:01:55 +08:00
t8y2 3c1813123d fix(editor): avoid bundling unused Shiki languages 2026-08-18 21:33:49 +08:00
t8y2 871bd9d21f fix(yashandb): read structured values as JDBC objects
Closes #6594
2026-08-18 12:51:34 +00:00
t8y2 86f2a2da57 docs(readme): show complete release download total 2026-08-18 19:39:54 +08:00
github-actions[bot] 81f780ed01 chore(packages): release 0.4.66 [skip node-packages-release] 2026-08-18 10:04:03 +00:00
github-actions[bot] c27f21dd14 chore: bump module versions [skip ci] 2026-08-18 09:48:29 +00:00
t8y2 1e58f9898d chore(release): prepare v0.5.88 2026-08-18 16:21:17 +08:00
t8y2 0b49c5a761 fix(sidebar): avoid synchronous selection pruning 2026-08-18 15:47:44 +08:00
Melody Ruch 94949eb1c7 fix(mongodb): preserve infinite-scroll append state 2026-08-18 15:45:35 +08:00
t8y2 0aafdb0238 fix(postgres): include partitions in table structure exports
Closes #6505
2026-08-18 07:40:44 +00:00
Abeautifulsnow c712be3e02 feat(agent): support cancelling installs and upgrades 2026-08-18 15:36:52 +08:00
知食分子 5196e92b24 fix(desktop): support legacy macOS WebViews 2026-08-18 15:36:37 +08:00
Melody Ruch 5acdbfa61e fix(grid): refresh database-computed values after edits 2026-08-18 15:31:15 +08:00
jearhe b3b6d29099 feat(schema-diff): support selecting tables to compare 2026-08-18 15:31:00 +08:00
jearhe 0facea2740 feat(backup): add one-shot database backups 2026-08-18 15:30:44 +08:00
Melody Ruch f0cc5aefcf fix(editor): let global shortcuts override CodeMirror bindings 2026-08-18 15:24:16 +08:00
zipg 9051ad35b8 fix(connection): clear parsed state when switching profiles 2026-08-18 15:24:01 +08:00
二丫讲梵 168f2a472b feat(redis): support renaming keys 2026-08-18 15:23:08 +08:00
zipg 61103523de feat(mysql): support table-scoped grants for new users 2026-08-18 14:50:48 +08:00
t8y2 2eee29c0bf fix(grid): deduplicate copied SQL IN tuples
Closes #6542
2026-08-18 06:32:44 +00:00
Melody Ruch 1345e3f57b fix(postgres): include partition children when generating table DDL 2026-08-18 13:18:18 +08:00
t8y2 f997a8a39b fix(ai): clarify model selector hierarchy 2026-08-18 13:17:53 +08:00
wyeye 3bb57b9c90 fix(jdbc): route UI connections through transport endpoint 2026-08-18 12:47:52 +08:00
jearhe 4d32bc4981 fix(transfer): preserve Oracle character length semantics 2026-08-18 12:47:33 +08:00
dienaso 9fc7721590 fix(kingbase): honor connection identifier quotes in exports 2026-08-18 12:43:31 +08:00
t8y2 cb09a5c75b fix(mysql): handle duplicate columns in count queries
Closes #6522
2026-08-18 04:08:54 +00:00
skyler 30cbdbce8d fix(ai): replay complete MiniMax incremental reasoning 2026-08-18 12:05:42 +08:00
Melody Ruch 41ffc969a1 fix(editor): bound SQL completion to active statement 2026-08-18 11:51:07 +08:00
jearhe 8271ba2a71 fix(grid): show column comments for grouped query results 2026-08-18 11:50:49 +08:00
dadong 4c260b1a2f feat(grid): add compact JSON editor action 2026-08-18 11:49:57 +08:00
t8y2 d93abea495 fix(jdbc): qualify TDengine table preview queries
Closes #6509
2026-08-18 03:47:05 +00:00
t8y2 28747e8d29 fix(oceanbase): restore Oracle-mode system schema visibility
Closes #6508
2026-08-18 03:45:51 +00:00
GIWTO 99066319cd fix(dameng): fall back when DBMS_METADATA is unavailable 2026-08-18 11:39:55 +08:00
hanxuanyu cf1476a6dc feat(dolt): add database-aware version control workspace 2026-08-18 11:30:20 +08:00
Melody Ruch 3e6e43c57c fix(ai): cancel active streams before conversation changes 2026-08-18 11:18:40 +08:00
Abeautifulsnow e6fa850a31 feat(editor): add configurable completion shortcut 2026-08-18 10:52:37 +08:00
Andrew e2b8ce3408 fix(gaussdb): align multi-host delete buttons 2026-08-18 10:52:25 +08:00
dadong a6945a19f2 fix(grid): preserve viewport during gutter execution 2026-08-18 10:52:13 +08:00
Abeautifulsnow 99ded5c8bb fix(ai-agent): align table scrollbar themes 2026-08-18 10:52:01 +08:00
lizzzzz777 d39d3ec134 fix(script): support Windows port checks 2026-08-18 10:51:50 +08:00
zipg 50c706cc95 fix(vastbase): preserve schema for result inserts 2026-08-18 10:51:38 +08:00
jearhe fab5c75fd2 fix(data-compare): avoid unsafe key inference 2026-08-18 10:51:26 +08:00
jearhe 665e5920c9 fix(objects): exclude type labels from browser search 2026-08-18 10:51:13 +08:00
jearhe b6df771760 fix(import): parse numeric thousands separators 2026-08-18 10:51:00 +08:00
jearhe cddc6a2c40 fix(settings): report apply failures without stalling 2026-08-18 10:50:47 +08:00
Bagus Wahyu Aprianto 840082d73b feat(sidebar): add table New Query action 2026-08-18 10:50:35 +08:00
Melody Ruch f92497b3d3 fix(schema-diff): keep deploy confirmation actions visible 2026-08-18 10:50:23 +08:00
t8y2 37efd29187 fix(docs): add DynamoDB support metadata 2026-08-18 10:45:55 +08:00
t8y2 dd8c645a06 feat(docs): show sponsor cards in two columns 2026-08-18 10:22:28 +08:00
github-actions[bot] 3e46c6027e chore(docs): refresh contributor activity 2026-08-17 18:34:27 +00:00
github-actions[bot] 9162d312de chore(packages): release 0.4.65 [skip node-packages-release] 2026-08-17 16:54:52 +00:00
github-actions[bot] e1b3514084 chore: bump module versions [skip ci] 2026-08-17 16:40:42 +00:00
t8y2 bf1bb80d05 chore(release): prepare v0.5.87 2026-08-17 20:54:17 +08:00
t8y2 9846a4f77e fix(ai): restore MiniMax incremental replay expectation 2026-08-17 20:38:10 +08:00
zipg 4794a66aba fix(hive): support JDBC URLs and HTTP noSasl connections 2026-08-17 19:40:40 +08:00
t8y2 c724fd46b0 fix(grid): preserve page size when final fetch is narrowed
Closes #6478
2026-08-17 10:24:08 +00:00
jearhe aa6d60533c feat(postgres): support concurrent index creation 2026-08-17 16:31:44 +08:00
azens ebf412b144 fix(mcp): support custom TLS trust for web backends 2026-08-17 15:54:13 +08:00
t8y2 e872586b70 fix(saphana): restore schema-scoped table actions
Closes #6457
2026-08-17 07:47:51 +00:00
t8y2 088cca545c fix(sync): terminate WebDAV collection paths
Closes #6448
2026-08-17 07:31:21 +00:00
luo jiyin dd82e8f9c8 docs(go-gssapi): correct README spelling 2026-08-17 15:10:30 +08:00
二丫讲梵 ce5b96189f feat(sidebar): support bulk connection group deletion 2026-08-17 14:58:48 +08:00
起岚 23102934ad fix(typos): correct test fixture spelling 2026-08-17 14:57:26 +08:00
zipg 0856e40455 fix(mysql): support cloud column-store large-value preview 2026-08-17 14:54:47 +08:00
zipg 50874601c1 fix(mysql): support ShardingSphere binary previews 2026-08-17 14:54:08 +08:00
Võ Văn Thành (Jay) a290948ad9 fix(editor): suggest MongoDB collection methods 2026-08-17 14:53:34 +08:00
Melody Ruch 6a25bd1909 fix(settings): confirm before discarding unsaved settings 2026-08-17 14:52:00 +08:00
zipg 9dbb2ef72f fix(starrocks): handle empty external databases 2026-08-17 14:47:47 +08:00
zipg 7d2458a74b fix(data-grid): restore desktop filter rule dragging 2026-08-17 14:47:00 +08:00
zipg db7463eb70 fix(oracle): export portable table DDL 2026-08-17 14:45:25 +08:00
zipg 1cdee024c2 fix(installer): add Chinese languages and permission hint 2026-08-17 14:44:27 +08:00
t8y2 736ebbdd69 fix(extractors): normalize uppercase JSON field names
Closes #6385
2026-08-17 06:10:10 +00:00
t8y2 84e834aab9 fix(release): preserve provenance for launcher packages
Closes #6300
2026-08-17 06:04:15 +00:00
t8y2 710b264bc4 fix(sqlserver): enable arithabort for activity trace reads
Closes #6440
2026-08-17 05:38:32 +00:00
Andrew f6b91e93dc feat(gaussdb): make count query parallelism configurable 2026-08-17 13:11:57 +08:00
Melody Ruch 4ce27f8ae5 fix(backup): verify scheduled export destinations before writing 2026-08-17 12:41:26 +08:00
t8y2 a8f966dcfd fix(postgres): preserve serial columns in copied table DDL
Closes #6405
2026-08-17 04:25:18 +00:00
t8y2 2ade896d0a fix(mysql): return XA RECOVER result sets
Closes #6380
2026-08-17 04:08:54 +00:00
t8y2 520de302dd fix(kafka): support message sends to legacy brokers
Closes #6408
2026-08-17 03:52:27 +00:00
bigFish0124 e9c35fe619 feat(sidebar): put close database connection first in context menu 2026-08-17 11:20:28 +08:00
zipg e97c9ab5d7 feat(dynamodb): support querying, editing, and exporting 2026-08-17 03:27:44 +08:00
zipg 92ab9bb0dc fix(editor): normalize LIKE case and completed SQL indentation 2026-08-17 02:09:50 +08:00
github-actions[bot] dbd93dae5a chore(docs): refresh contributor activity 2026-08-16 18:05:15 +00:00
zipg f4dbfe0cc3 fix(query): deduplicate timeout setting actions 2026-08-17 01:52:15 +08:00
二丫讲梵 19e547fe52 fix(data-grid): scope SQL extractors to active context 2026-08-17 01:51:19 +08:00
二丫讲梵 50e6476cb0 fix(meilisearch): preserve proxy paths and secure REST requests 2026-08-17 01:50:11 +08:00
t8y2 47337ac048 feat(docs): update database coverage to 80+ 2026-08-17 00:44:01 +08:00
t8y2 f30d0cb680 fix(docs): keep installer links valid across releases
Closes #6403
2026-08-17 00:14:43 +08:00
github-actions[bot] e474138696 chore(packages): release 0.4.64 [skip node-packages-release] 2026-08-16 10:58:45 +00:00
github-actions[bot] 8931f65b89 chore: bump module versions [skip ci] 2026-08-16 10:43:52 +00:00
github-actions[bot] 3b16a0213e chore(jdbc): bump plugin version [skip ci] 2026-08-16 10:08:44 +00:00
t8y2 265d282550 chore(release): prepare v0.5.86 2026-08-16 18:07:15 +08:00
t8y2 21f2f58640 fix(connection): move password hint to tooltip 2026-08-16 17:45:44 +08:00
zipg f15ecd6d59 fix(sql): ignore MySQL routine labels during parameter parsing 2026-08-16 17:02:15 +08:00
jearhe 64e5e88c27 fix(grid): show comments for joined query results 2026-08-16 15:34:03 +08:00
Domye d5f18034f9 feat(mcp): add Pi client configuration 2026-08-16 15:33:49 +08:00
二丫讲梵 310eaa38a0 feat(docs): centralize supported database metadata 2026-08-16 15:33:34 +08:00
zipg c2cf8d3960 fix(completion): avoid unnecessary MySQL identifier quotes 2026-08-16 15:27:08 +08:00
zipg d2e77a45dd fix(sql-files): prevent sticky folder header bleed-through 2026-08-16 15:26:30 +08:00
Melody Ruch 4660a11f25 fix(schema-diff): require explicit expression index provenance 2026-08-16 14:39:43 +08:00
zipg 01ea889569 fix(completion): apply database-specific identifier quoting 2026-08-16 14:16:04 +08:00
Melody Ruch 2f3a32955c fix(import): apply layouts when connections already exist 2026-08-16 14:10:30 +08:00
jearhe 71136377e5 fix(sidebar): cancel stale table search work across modes 2026-08-16 14:09:50 +08:00
Melody Ruch e671b3da7d fix(redis): keep automatic scan loading within shared budget 2026-08-16 14:09:02 +08:00
Domye 8996da7d9a feat(theme): add custom UI color palettes 2026-08-16 11:59:28 +08:00
t8y2 c81466d0db fix(editor): format keymap configuration 2026-08-16 03:44:48 +00:00
二丫讲梵 bdee3a843c feat(structure): copy columns between tables 2026-08-16 11:39:44 +08:00
Melody Ruch 1d8771a846 fix(oracle): bind explain plan placeholders 2026-08-16 11:34:33 +08:00
Melody Ruch e18222628d feat(editor): add SQL block comment action 2026-08-16 11:29:03 +08:00
Melody Ruch 34a4f5d78a fix(rabbitmq): route queues to messages 2026-08-16 11:24:42 +08:00
二丫讲梵 dd83601c3c fix(grid): left-align transposed record headers 2026-08-16 11:24:30 +08:00
Melody Ruch 044379da71 fix(mcp): type optional schema fields 2026-08-16 11:24:18 +08:00
jearhe edb136d960 fix(editor): respect custom Ctrl+D shortcuts 2026-08-16 11:24:06 +08:00
二丫讲梵 c63c517b4f feat(nacos): add access control support 2026-08-16 11:23:53 +08:00
zipg 1838a4726e fix(structure): keep horizontal scrollbar visible 2026-08-16 11:23:42 +08:00
二丫讲梵 175c22d927 fix(docs): detect macOS download architecture 2026-08-16 11:22:55 +08:00
minion 56c4c08836 fix(webview): extend startup watchdog timeout 2026-08-16 11:21:22 +08:00
github-actions[bot] 4c2eb39937 chore(docs): refresh contributor activity 2026-08-15 18:04:42 +00:00
t8y2 2346dbbdd7 fix(website): detect Intel Macs for downloads 2026-08-16 00:05:17 +08:00
zipg 36ee06bbf8 feat(grid): display referenced values for foreign key columns 2026-08-15 23:53:32 +08:00
Melody Ruch e97a5a5b8f fix(transfer): validate existing target before overwrite 2026-08-15 23:43:10 +08:00
Melody Ruch 7f1a983498 fix(mqtt): add pause and resume control for message stream 2026-08-15 23:42:56 +08:00
Melody Ruch ed87267777 fix(redis): keep JSON tree labels horizontal 2026-08-15 23:29:54 +08:00
Melody Ruch 030627cc10 fix(editor): resync WebKit caret after paste 2026-08-15 23:29:38 +08:00
Melody Ruch 2f2303e1d6 fix(mysql): keep view edit draft formatted 2026-08-15 23:29:24 +08:00
Melody Ruch 6988f9878f fix(ai): handle CodeBuddy authentication state correctly 2026-08-15 23:29:08 +08:00
Melody Ruch f9c4f290f5 feat(oracle): show table constraints in structure editor 2026-08-15 23:28:53 +08:00
Melody Ruch 76b8f2f54b fix(transfer): rank prefix matches first in object search 2026-08-15 23:28:39 +08:00
Melody Ruch bd4cfd6b99 feat(metadata): display object comments across databases 2026-08-15 21:57:37 +08:00
Melody Ruch ad7a66c64f fix(mongodb): reconcile command execution summary status 2026-08-15 21:56:11 +08:00
Elias 5bfb07eef0 fix(xugu): place public synonyms after schemas 2026-08-15 21:55:54 +08:00
zipg 868502326c feat(kyuubi): add native connection support 2026-08-15 21:55:37 +08:00
Melody Ruch 60e685d6e6 fix(redis): display console command before response 2026-08-15 21:55:21 +08:00
Melody Ruch 6ec3ff0ccc fix(sqlserver): include lock wait in reported query duration 2026-08-15 21:55:03 +08:00
Melody Ruch b6c83838f3 fix(tabs): stop touch input from arming tab drag reorder 2026-08-15 21:54:46 +08:00
zipg 1d7fa00fe3 fix(query): add timeout settings entry to error page 2026-08-15 21:53:17 +08:00
Melody Ruch 8f3351f720 fix(mcp): enforce read-only SQL classification consistently 2026-08-15 18:57:22 +08:00
Melody Ruch c573c249ed fix(sidebar): show loading while global search is in flight 2026-08-15 18:56:58 +08:00
Melody Ruch bdf5f6a87c fix(nacos): allow overriding target group when copying configs 2026-08-15 18:56:34 +08:00
Melody Ruch d782667b22 fix(nacos): build syntax tree before config editor paint 2026-08-15 18:40:24 +08:00
github-actions[bot] 405bdb295c chore(packages): release 0.4.63 [skip node-packages-release] 2026-08-15 09:44:06 +00:00
github-actions[bot] 49d669c805 chore: bump module versions [skip ci] 2026-08-15 09:29:27 +00:00
t8y2 9bbe8e0b6e chore(release): prepare v0.5.85 2026-08-15 17:02:28 +08:00
t8y2 9dcbf2a356 fix(grid): initialize filter state before preview watcher 2026-08-15 15:39:46 +08:00
azens 5fe8b347a6 feat(mcp): support web proxy and custom headers 2026-08-15 14:59:04 +08:00
Melody Ruch 9fbbf70d4a fix(kafka): enable partition adjustment for single-partition topics 2026-08-15 14:52:30 +08:00
zipg 9e7d80f467 feat(grid): add persistent SQL filter views 2026-08-15 14:45:52 +08:00
Melody Ruch 8bf4485a75 fix(grid): remeasure headers after configured font loads 2026-08-15 14:45:41 +08:00
大熊 2ea5e8e9b9 feat(settings): add update check and MCP uninstall 2026-08-15 14:44:46 +08:00
Domye 745483cd6c feat(sidebar): add indentation and font size settings 2026-08-15 14:44:35 +08:00
Melody Ruch 98df975614 fix(sqlserver): focus data results in multi-target execution 2026-08-15 14:42:06 +08:00
Diego Fabricio 6a2000deff fix(ui): stabilize live progress counters 2026-08-15 14:41:54 +08:00
Lan-zk 356127f0e3 fix(kafka): restore message tab scrolling 2026-08-15 14:41:42 +08:00
Melody Ruch cd6f99fbc5 fix(grid): enable copy as SQL for selected cells 2026-08-15 14:41:29 +08:00
Xinyu Kuo 84f1498a4b fix(duckdb): restore parquet preview and reclaim connections 2026-08-15 14:41:16 +08:00
Melody Ruch a407c77a16 fix(transfer): separate object and dialog scrollbars 2026-08-15 14:41:04 +08:00
Melody Ruch 057b24b5e8 fix(editor): honor configured accept-completion shortcut 2026-08-15 14:35:52 +08:00
zipg 1aec3ae000 fix(mysql): improve large-value table previews 2026-08-15 12:58:28 +08:00
skyler 2d4b6f85fc fix(jdbc): use non-empty Java test fixtures 2026-08-15 12:08:41 +08:00
t8y2 4768dbb99a feat(tabs): add adjacent data tab opening option
Closes #3723
2026-08-15 00:52:24 +00:00
t8y2 fbaf556f3a feat(editor): add SQL substitution master switch
Closes #5759
2026-08-14 23:54:54 +00:00
t8y2 dc2d3ea1dc feat(copy): add camel-case JSON field names
Closes #4784
2026-08-14 23:07:21 +00:00
t8y2 a0de029a23 fix(saved-sql): avoid full directory rewrites
Closes #4300
2026-08-14 21:15:06 +00:00
github-actions[bot] f23a9e15d5 chore(docs): refresh contributor activity 2026-08-14 18:19:53 +00:00
t8y2 e5298f2bf3 fix(editor): apply custom background to gutters
Closes #850
2026-08-14 17:55:15 +00:00
t8y2 b521d0377c fix(mcp): publish string connection selector schema
Closes #6285
2026-08-14 17:29:56 +00:00
Guoyu Su 38a7815b72 feat(ai): add message copy actions 2026-08-15 00:28:12 +08:00
zipg 8737114d28 fix(jdbc): include managed drivers in offline bundles 2026-08-15 00:27:34 +08:00
t8y2 86bb88ab66 fix(mongodb): install legacy agent before automatic fallback
Closes #6167
2026-08-14 16:02:42 +00:00
t8y2 3a637de44c fix(mysql): honor connection timeout when listing databases
Closes #4244
2026-08-14 15:46:46 +00:00
t8y2 2d526db91b feat(opentenbase): add PostgreSQL-compatible database support 2026-08-14 23:40:50 +08:00
Andrew 722b6e7d19 feat(gaussdb): add target server type selection 2026-08-14 22:56:28 +08:00
李子凡 5695ef6e77 fix(sql): preserve same-line TDSQL directives 2026-08-14 22:46:32 +08:00
hanxuanyu a574459f8b feat(dolt): improve system tables and SQL completion 2026-08-14 22:18:28 +08:00
t8y2 c5eea21821 fix(sql): preserve database quoting in star expansion
Closes #6247
2026-08-14 14:14:28 +00:00
Melody Ruch b9e63dc57e fix(sidebar): preserve group state after search 2026-08-14 22:12:44 +08:00
李子凡 26084d12e1 feat(editor): add configurable join-lines shortcut 2026-08-14 22:11:09 +08:00
t8y2 2b8a5c8897 fix(import): use SQL Server float for decimal inference
Refs: #4072
2026-08-14 13:53:03 +00:00
t8y2 a99b75a8c5 fix(postgres): preserve array values in table previews
Closes #6275
2026-08-14 12:44:30 +00:00
zipg a6d9e6818e feat(impala): add full database support 2026-08-14 20:41:57 +08:00
Melody Ruch 185ff68d78 fix(elasticsearch): format Kibana-style REST requests 2026-08-14 20:41:46 +08:00
zipg f5a3652f6a fix(nix): update pnpm dependency hash 2026-08-14 20:41:34 +08:00
t8y2 9f2af0983a fix(query): support Caché total-row counts
Closes #6266
2026-08-14 11:41:19 +00:00
t8y2 6dcfdc6ae0 fix(mysql): isolate compatible database-specific logic 2026-08-14 19:09:05 +08:00
Abeautifulsnow 1ff24aaeb4 feat(auth): keep unsaved passwords in session memory 2026-08-14 18:37:53 +08:00
t8y2 bcec60ebef fix(docs): hide issue entry and extend image timeout 2026-08-14 18:35:35 +08:00
lizzzzz777 05361eb40d feat(transfer): persist reusable transfer tasks 2026-08-14 18:25:05 +08:00
Melody Ruch 1a10db4c18 fix(sql): quote MySQL completion identifiers 2026-08-14 18:13:31 +08:00
kirito cd51e247cb fix(grid): format Mongo shell date values 2026-08-14 18:05:35 +08:00
t8y2 b855923a21 feat(docs): add anonymous GitHub issue submission 2026-08-14 17:55:25 +08:00
Melody Ruch ebad74f81e fix(nacos): stabilize config list scrollbar 2026-08-14 17:48:15 +08:00
amwps290 0e1022f04c feat(grid): add geometry layer preview 2026-08-14 17:47:54 +08:00
zipg 60e95b1634 fix(docs): list all native offline drivers 2026-08-14 17:47:35 +08:00
Melody Ruch a578916fa1 fix(editor): preserve scroll after closing DDL dialog 2026-08-14 17:47:14 +08:00
zipg 02eed8c683 fix(sidebar): highlight destructive more menus 2026-08-14 17:46:54 +08:00
zipg 518a8b593c fix(oracle): load CLOB values lazily and safely 2026-08-14 17:46:34 +08:00
zipg 1cd962d9c7 fix(ci): make ZooKeeper JAAS fixture readable 2026-08-14 17:46:14 +08:00
github-actions[bot] 6013e21cb4 chore(packages): release 0.4.62 [skip node-packages-release] 2026-08-14 09:15:48 +00:00
github-actions[bot] 41b68fd624 chore: bump module versions [skip ci] 2026-08-14 09:03:17 +00:00
t8y2 f645d7d50d fix(iotdb): normalize table export projections
Closes #6241
2026-08-14 08:47:37 +00:00
t8y2 65792128e4 fix(mongodb): support show databases shell commands
Closes #6226
2026-08-14 07:49:07 +00:00
t8y2 462f8e1fb2 chore(release): bump version to 0.5.84 2026-08-14 15:42:22 +08:00
t8y2 c7a1b6df0f fix(mcp): publish Gemini-compatible optional array schema
Closes #6233
2026-08-14 07:34:50 +00:00
lizzzzz777 041dc8b856 feat(sidebar): browse objects on database single click 2026-08-14 15:30:31 +08:00
t8y2 f29590b1c6 fix(zookeeper): handle large children responses safely
Closes #6200
2026-08-14 15:08:02 +08:00
t8y2 9e730fac56 fix(kafka): support legacy topic metadata and config gaps
Closes #6217
2026-08-14 07:01:07 +00:00
Melody Ruch df95288aab fix(editor): display configured execute shortcut 2026-08-14 14:47:08 +08:00
Melody Ruch 30d13bc7f0 fix(grid): ignore pointer jitter during drag selection 2026-08-14 14:45:56 +08:00
zipg 325127419f fix(mongodb): install legacy driver during connection test 2026-08-14 14:45:35 +08:00
zipg b6c4a0be0f fix(dameng): bound connection initialization timeout 2026-08-14 14:40:30 +08:00
t8y2 073139cde9 fix(frontend): restore formatting checks 2026-08-14 05:45:54 +00:00
t8y2 3db119ca4a fix(user-admin): pass connection context in provider test 2026-08-14 05:35:13 +00:00
t8y2 56fde5b20b feat(zookeeper): replace Java agent with native Go implementation 2026-08-14 13:34:20 +08:00
t8y2 2d47c94992 fix(docs): preserve database column comments
Closes #6212
2026-08-14 05:26:21 +00:00
kirito 799bdd1a62 feat(mongodb): support safe index editing 2026-08-14 12:58:03 +08:00
Melody Ruch 880d635961 fix(sql): handle MySQL dash comments correctly 2026-08-14 12:57:35 +08:00
hanxuanyu d63a407931 fix(grid): preserve selection across column reordering 2026-08-14 12:57:05 +08:00
gggaiitx b406c0a52c feat(editor): add SQL context menu actions 2026-08-14 12:56:39 +08:00
Melody Ruch 5b40487753 fix(structure): fill resizable charset and collation columns 2026-08-14 12:56:11 +08:00
t8y2 f9fd2d4fa8 fix(firebird): return stored procedure source
Closes #6141
2026-08-14 04:14:59 +00:00
t8y2 e3c154ab4c fix(kingbase): preserve user search path across statements
Closes #6134
2026-08-14 03:38:27 +00:00
zipg 348e3a0c70 fix(grid): hydrate large values across result changes 2026-08-14 11:28:10 +08:00
t8y2 56cacd0441 fix(docs): render connection import step headings 2026-08-14 10:42:28 +08:00
t8y2 4c6c3142c0 feat(mysql): add auto-increment counter action
Closes #3019
2026-08-14 01:59:04 +00:00
t8y2 278429ab07 feat(grid): copy selected data as SELECT
Closes #5591
2026-08-14 01:47:05 +00:00
t8y2 c892c8d1b0 feat(mysql): support changing user account host
Closes #4677
2026-08-14 00:41:17 +00:00
t8y2 2ecbac4a6f fix(connection): preserve credentials for credential-free URLs
Closes #5377
2026-08-14 00:00:19 +00:00
t8y2 3d114c28e2 fix(sqlserver): split KILL statement execution targets
Closes #5566
2026-08-13 22:39:32 +00:00
t8y2 9b983441d7 fix(chromadb): honor cloud tenant and database
Closes #4486
2026-08-13 18:26:48 +00:00
Lan-zk 98f5c52f8e feat(sidebar): add regular expression search 2026-08-14 02:25:24 +08:00
github-actions[bot] 5df8990802 chore(docs): refresh contributor activity 2026-08-13 18:19:45 +00:00
Melody Ruch 10b1ba4578 fix(objects): support modifier-click table selection 2026-08-14 02:06:05 +08:00
Diego Fabricio f9f21cbd88 fix(grid): recognize MySQL bit columns as boolean 2026-08-14 02:05:50 +08:00
Melody Ruch 3ecfb99f9a fix(editor): center executed statement after execution 2026-08-14 02:05:05 +08:00
t8y2 42d9ad9fc2 fix(gbase8s): use Informix SQL for table exports
Closes #3584
2026-08-13 17:53:53 +00:00
GIWTO b119ca36ae fix(sqlserver): preserve TOP query ordering across pages 2026-08-14 00:22:43 +08:00
miracle e77b547cea fix(kafka): improve message browsing and topic navigation 2026-08-14 00:19:09 +08:00
Dhevenddra K G f6518ff3a0 fix(schema): quote MySQL bare column defaults safely 2026-08-14 00:16:05 +08:00
zipg 35eab8a8b1 perf(query): optimize large-value result diagnostics 2026-08-14 00:11:28 +08:00
Andrew 9c50cc789b fix(gaussdb): use schema for M-mode view DDL 2026-08-14 00:10:11 +08:00
Andrew b22cd9079a fix(jdbc): preserve multi-bit values when reading booleans 2026-08-13 23:57:30 +08:00
zipg 4efd815329 fix(ai): use current form config for connection tests 2026-08-13 23:55:41 +08:00
t8y2 38bf4ec521 feat(settings): remember DDL wrapping preference
Closes #4903
2026-08-13 15:18:59 +00:00
t8y2 0b530145ba feat(sidebar): copy MySQL object display paths
Closes #5903
2026-08-13 14:35:09 +00:00
t8y2 34f6389503 fix(startup): isolate optional AI config failures
Closes #3779
2026-08-13 14:32:37 +00:00
zipg 58633bf682 fix(frontend): restore CI compatibility checks 2026-08-13 22:31:22 +08:00
echocde 64dcd8ab18 fix(jdbc): read CLOB values from character streams 2026-08-13 22:31:13 +08:00
t8y2 55d611141b fix(query): allow inserts into bound distinct results
Closes #6169
2026-08-13 13:10:48 +00:00
t8y2 fc6c5d4aa6 fix(jdbc): support Caché data grid pagination
Closes #3423
2026-08-13 20:50:07 +08:00
zipg 65cd4cfe28 fix(mysql): preserve nested routine blocks 2026-08-13 20:35:16 +08:00
大熊 d64908c596 fix(connection): improve synced password recovery 2026-08-13 20:34:54 +08:00
t8y2 de72675b67 feat(welcome): show recently opened connections
Closes #6015
2026-08-13 12:19:21 +00:00
t8y2 e0a7bc996d fix(sidebar): preserve objects for ancestor search matches
Closes #5613
2026-08-13 12:15:46 +00:00
JcEvoX cce36ae058 feat(elasticsearch): allow disabling connectivity checks 2026-08-13 20:11:52 +08:00
t8y2 be3336c1ec fix(sqlserver): keep data results interactive with messages
Closes #5967
2026-08-13 12:00:40 +00:00
zipg 75df9266cb feat(grid): support selecting readonly cell text 2026-08-13 19:45:09 +08:00
Abeautifulsnow a3eaac3413 feat(redis): add decoded Unicode JSON view 2026-08-13 19:44:48 +08:00
t8y2 8b02ba003b fix(generate): recognize NUMBER columns as numeric
Closes #5283
2026-08-13 11:33:55 +00:00
onenewcode b38e30cd7f fix(nacos): load r-nacos config detail safely 2026-08-13 19:31:17 +08:00
Melody Ruch d26321056d fix(editor): seed search with multiline selection 2026-08-13 19:28:31 +08:00
zipg 847539397c fix(editor): suppress completion after newline 2026-08-13 19:28:13 +08:00
Melody Ruch fbf240efbc fix(desktop): keep update download active on dismiss 2026-08-13 19:27:55 +08:00
Melody Ruch 68c22e878b fix(editor): wrap generated SQL templates 2026-08-13 19:27:37 +08:00
onenewcode 84beec5136 fix(jre): allow uninstall with native drivers 2026-08-13 19:25:27 +08:00
jinmo 58a2d78629 feat(mqtt): persist publish panel visibility 2026-08-13 19:25:07 +08:00
wbean 1e0687a7e2 fix(jdbc): stop forcing cursor fetching 2026-08-13 19:24:49 +08:00
zipg a640809c61 fix(mysql): repair encoded DDL comments 2026-08-13 19:24:30 +08:00
t8y2 44cb278257 fix(transfer): show tables for supported database types
Refs: #6115
2026-08-13 11:01:50 +00:00
t8y2 f8b165fe20 fix(grid): resolve column comments through source metadata
Closes #6054
2026-08-13 10:47:02 +00:00
t8y2 5a05c79a45 fix(tidb): display ADMIN SHOW query results
Closes #6151
2026-08-13 10:13:06 +00:00
t8y2 ad2b02d0e4 fix(repo): remove tracked node_modules symlink
Closes #6149
2026-08-13 09:50:07 +00:00
t8y2 b23c31bfc6 fix(postgres): accept X.509 v1 client certificates
Closes #6103
2026-08-13 09:37:08 +00:00
t8y2 37f64dbdf4 fix(grid): keep computed query columns individually read-only
Closes #6132
2026-08-13 09:28:31 +00:00
onenewcode 7a21e7e5f9 fix(postgres): display reg types as text 2026-08-13 16:49:59 +08:00
t8y2 8548ecacc0 fix(transfer): complete PostgreSQL 9.x table transfer
Closes #6070
2026-08-13 16:13:07 +08:00
Melody Ruch a0354726b1 fix(grid): detect exact LIMIT row bounds safely 2026-08-13 16:04:23 +08:00
Lan-zk 7a01a440b8 feat(ai): add conversation history search 2026-08-13 16:04:11 +08:00
Melody Ruch 1c89373035 fix(ssh): report reconnect failures accurately 2026-08-13 16:03:57 +08:00
t8y2 2faa755bfe fix(sidebar): restore complete object list after search
Closes #6006
2026-08-13 07:56:32 +00:00
t8y2 c228078e25 fix(mcp): respect configured package registry
Closes #5912
2026-08-13 07:49:54 +00:00
zipg 47fcb9311f fix(oracle): configure default row prefetch 2026-08-13 15:26:53 +08:00
zhangyc 5f60fbe196 feat(sidebar): manage saved SQL by database 2026-08-13 15:26:45 +08:00
zhangyc 1efb3de113 fix(web): preserve export download filenames 2026-08-13 15:26:37 +08:00
Frost e1e334c7cd fix(agents): remove stale Hive metadata coverage row 2026-08-13 15:26:30 +08:00
大熊 b5a8864220 fix(sidebar): search connections by host and username 2026-08-13 15:24:30 +08:00
t8y2 18d581137c feat(editor): configure SQL completion column ordering
Closes #5955
2026-08-13 06:54:51 +00:00
t8y2 31a7609604 fix(grid): make transpose metadata display optional 2026-08-13 14:52:18 +08:00
t8y2 a82ccdecd1 fix(oracle): export explicit timestamp literals
Closes #6078
2026-08-13 13:39:28 +08:00
t8y2 7f9a2d06dd fix(mysql): show routine creation and update times
Closes #6079
2026-08-13 13:32:43 +08:00
Melody Ruch 33a3e0de58 fix(editor): wrap long SQL preview text 2026-08-13 13:25:48 +08:00
zipg e85d3dcc59 fix(grid): bound large table previews by byte budget 2026-08-13 13:25:04 +08:00
github-actions[bot] 020654c33c chore(packages): release 0.4.61 [skip node-packages-release] 2026-08-13 05:13:07 +00:00
github-actions[bot] 38b5440662 chore: bump module versions [skip ci] 2026-08-13 05:01:25 +00:00
zipg 0f86614679 fix(query): show timeout settings for structured errors 2026-08-13 12:57:29 +08:00
zhangyc c51abd2178 fix(schema-diff): guard and review destructive DDL 2026-08-13 12:42:51 +08:00
Melody Ruch e8156ab411 fix(editor): dismiss hover tooltip on table navigation 2026-08-13 12:42:38 +08:00
hanxuanyu e4b9e55883 fix(grid): improve column drag scrolling and selection 2026-08-13 12:41:57 +08:00
dienaso 7eac5dd610 fix(kingbase): honor MySQL-compatible identifier quoting 2026-08-13 12:40:50 +08:00
t8y2 6f4f6f373e fix(transfer): support legacy PostgreSQL routine catalogs
Refs: #6070
2026-08-13 04:19:51 +00:00
t8y2 c7e0c1c899 chore: bump version to 0.5.83 2026-08-13 12:00:25 +08:00
t8y2 3acaf6f16a fix(grid): support MySQL string-quoted editable aliases
Closes #6059
2026-08-13 03:51:17 +00:00
zipg 982778128e feat(sqlserver): add activity trace 2026-08-13 11:49:54 +08:00
zipg e5a3917f7d fix(sql): preserve file associations and list selection 2026-08-13 11:44:55 +08:00
onenewcode 2e27f7a66a feat(redis): add key list select all 2026-08-13 11:42:35 +08:00
t8y2 cf57df35da fix(backup): drop missing databases from edited plans
Closes #6057
2026-08-13 03:41:03 +00:00
ptma ee08f356e9 feat(grid): flatten multiline cell text 2026-08-13 11:40:22 +08:00
t8y2 01e67ee9a4 fix(ci): repair baseline checks 2026-08-13 03:31:36 +00:00
t8y2 68733fbf51 docs(readme): remove FAQ and relocate community 2026-08-13 11:13:19 +08:00
t8y2 91e7463ee5 feat(postgres): complete sequence names in regclass literals
Closes #4005
2026-08-13 02:54:18 +00:00
Elias a67bcb09db feat(xugu): report structured agent errors 2026-08-13 10:51:40 +08:00
t8y2 d10cdaa0fb fix(oracle): support explain plans with custom JDBC drivers
Closes #6050
2026-08-13 02:24:46 +00:00
t8y2 93e9be46a9 fix(schema-diff): compare common view definitions
Closes #3921
2026-08-13 02:01:27 +00:00
t8y2 23c9028b57 feat(mongodb): enable SOCKS5 proxy connections
Closes #6047
2026-08-13 00:05:35 +00:00
t8y2 16e3bb7d7a fix(ssh): restore agent authentication for new tunnels
Closes #3796
2026-08-12 23:13:51 +00:00
t8y2 3acf3a66b9 feat(kafka): reset one partition to an absolute offset
Closes #5960
2026-08-12 22:42:15 +00:00
t8y2 4106a8ce22 fix(process-list): cancel queries without closing sessions
Closes #3967
2026-08-12 21:57:19 +00:00
t8y2 396ebf8393 feat(mcp): duplicate complete connection configs
Closes #4192
2026-08-12 21:52:54 +00:00
t8y2 5cfb457903 feat(dameng): preserve indexes when cloning tables
Closes #3958
2026-08-12 21:39:44 +00:00
t8y2 5375bd1473 fix(editor): run hinted SQL from statement gutter
Closes #5986
2026-08-12 20:35:26 +00:00
t8y2 3dd034bf06 feat(oracle): preserve character length units
Closes #4739
2026-08-12 19:44:32 +00:00
t8y2 bd83ee189b fix(generate): honor explicit unique value generation
Refs: #5958
2026-08-12 19:05:41 +00:00
t8y2 a740e2e9ce feat(meilisearch): add native document database support 2026-08-13 02:59:56 +08:00
github-actions[bot] 292d0c8b54 chore(docs): refresh contributor activity 2026-08-12 18:19:41 +00:00
Melody Ruch de5c2cb26b fix(editor): preserve semantic whitespace in hover DDL copies 2026-08-13 02:12:09 +08:00
zipg d4d6501af6 feat(sql): support MyBatis foreach collection parameters 2026-08-13 02:10:51 +08:00
二丫讲梵 433626f5c9 feat(data-grid): color cells by database-aware types 2026-08-13 02:10:37 +08:00
Melody Ruch 0b5c0bdcc0 fix(editor): resolve quoted identifiers in table previews 2026-08-13 01:59:58 +08:00
二丫讲梵 1d2a47fee0 feat(editor): apply word wrap to config editors 2026-08-13 01:59:04 +08:00
Melody Ruch f88e39563d fix(ssh): raise host-key prompt above connection dialog 2026-08-13 01:58:35 +08:00
t8y2 6f3895edd8 fix(sql): preserve TDSQL proxy directive
Closes #6043
2026-08-12 17:02:19 +00:00
t8y2 e18d1ddedf fix(sqlserver): respect TOP row bound in pagination
Closes #6033
2026-08-12 16:37:14 +00:00
t8y2 d0e027bb17 fix(mongodb): support legacy agent insertMany writes
Refs: #6032
2026-08-12 16:20:25 +00:00
t8y2 7dabb9e4fb fix(jdbc): preserve Oracle DATE time values
Closes #5949
2026-08-12 15:54:42 +00:00
t8y2 847afca6fe fix(iotdb): omit tree database from session handshake
Closes #5826
2026-08-12 15:45:24 +00:00
t8y2 9fb70bcfc9 feat(rocketmq): replace Java agent with native Go implementation 2026-08-12 23:15:20 +08:00
t8y2 baec487a21 fix(data-grid): show first nonblank cell preview line
Closes #5977
2026-08-12 14:53:39 +00:00
t8y2 5bda37b05d fix(sql-completion): add missing MySQL function suggestions
Closes #6024
2026-08-12 14:27:26 +00:00
一颗红心 11694292da feat(grid): preserve metadata in transpose view 2026-08-12 22:22:33 +08:00
知食分子 513c3011fc fix(desktop): improve legacy WebView compatibility 2026-08-12 21:59:52 +08:00
t8y2 6a88df5500 fix(dameng): preserve indexes when rebuild fails
Closes #6002
2026-08-12 13:48:29 +00:00
t8y2 2517307801 fix(kingbase): fall back when catalog OIDs are unavailable
Closes #5995
2026-08-12 13:16:35 +00:00
t8y2 86dcc75fa0 feat(sql): support dotted braced parameter names
Closes #4263
2026-08-12 13:12:29 +00:00
Melody Ruch a9194ba3b5 fix(sidebar): preserve dirty procedure source tabs 2026-08-12 21:06:13 +08:00
Guoyu Su 9b24d962bb feat(mongodb): support generic runCommand execution 2026-08-12 21:05:13 +08:00
t8y2 2d00620fac fix(saved-sql): protect external sync edits
Refs: #4393
2026-08-12 12:55:34 +00:00
Andrew 58f05d91c3 fix(gaussdb): retrieve M-mode view DDL 2026-08-12 20:42:45 +08:00
t8y2 c570517fe0 fix(transfer): recover unsupported MySQL collations
Closes #6023
2026-08-12 12:20:45 +00:00
Andrew 6429334549 fix(gaussdb): support M-mode user administration 2026-08-12 20:18:18 +08:00
zipg 105920047a fix(sidebar): improve connection multi-select actions 2026-08-12 20:17:57 +08:00
jinmo 668c5ddadb fix(mqtt): make payload resizing accessible 2026-08-12 20:15:58 +08:00
zipg 9a30b95a1a fix(editor): preserve trailing Dameng dot whitespace 2026-08-12 20:14:28 +08:00
t8y2 023e082ce5 fix(hive): preserve leaf column labels in query results
Closes #3790
2026-08-12 11:53:26 +00:00
Abeautifulsnow a029888725 feat(connection): allow opting out of password storage 2026-08-12 19:34:00 +08:00
Melody Ruch b8d37774fd fix(editor): center search matches in the viewport 2026-08-12 19:26:24 +08:00
Melody Ruch 2a26ea5429 fix(editor): preserve SQL on full-width formatter errors 2026-08-12 19:26:16 +08:00
Melody Ruch 463c3cd7f0 fix(sidebar): avoid expanding unloaded located tables 2026-08-12 19:26:09 +08:00
Melody Ruch 2d217229c6 fix(editor): restore SQL formatter config export 2026-08-12 19:26:00 +08:00
t8y2 42afd3cad2 fix(transfer): preserve MySQL spatial values
Closes #6004
2026-08-12 11:00:07 +00:00
t8y2 a9299e947f fix(mysql): preserve results from single procedure calls
Closes #5560
2026-08-12 10:53:43 +00:00
t8y2 5f8cc0b08b fix(hive): use legacy-compatible session protocol 2026-08-12 18:50:45 +08:00
t8y2 1ad609f0df fix(structure): debounce SQL preview updates 2026-08-12 18:33:50 +08:00
Elias ea9a27ce11 feat(xugu): expose public synonyms in an isolated scope 2026-08-12 18:24:01 +08:00
t8y2 ab46f3fc72 fix(structure): flatten editable grid controls 2026-08-12 18:12:30 +08:00
t8y2 915c0f60a4 fix(import): batch SQLite append transactions
Closes #4676
2026-08-12 10:07:58 +00:00
t8y2 8c2f7c02ad fix(ci): stream QQ notification JSON over SSH stdin
Closes #6005
2026-08-12 09:51:49 +00:00
t8y2 55e3616938 feat(sql): decode MyBatis XML comparison entities
Closes #4312
2026-08-12 09:37:10 +00:00
zipg fc2db2a17c fix(ui): unify list hover and selection highlighting 2026-08-12 17:33:01 +08:00
t8y2 d0b67ae795 feat(mcp): include connection group paths in listings
Closes #4214
2026-08-12 09:05:27 +00:00
t8y2 056e46504e fix(dameng): complete table column data type options
Closes #5275
2026-08-12 08:40:07 +00:00
t8y2 747e9900df fix(schema-diff): preserve MySQL added column positions
Closes #4291
2026-08-12 08:14:37 +00:00
t8y2 307c382d40 fix(docs): clarify Windows offline installer 2026-08-12 16:03:25 +08:00
t8y2 26dd569afc feat(docs): add Windows offline download options 2026-08-12 15:42:06 +08:00
t8y2 5737bb497f fix(sqlserver): handle hierarchyid query results
Closes #5987
2026-08-12 07:32:01 +00:00
t8y2 b29e52b0a2 feat(mysql): expose triggers in sidebar object tree
Closes #5199
2026-08-12 06:21:52 +00:00
t8y2 143143a88d fix(grid): reset scroll for replacement query results
Closes #5950
2026-08-12 06:17:42 +00:00
zipg c27a4dd83d perf(mysql): limit large table result memory 2026-08-12 14:06:33 +08:00
t8y2 1259c34db8 feat(rabbitmq): show sortable queue message counts
Closes #4286
2026-08-12 14:00:36 +08:00
t8y2 4e3a8e4110 feat(oracle): expose synonyms in schema tree
Closes #4655
2026-08-12 05:52:08 +00:00
GIWTO ec4f8582e6 feat(dameng): add user and role administration 2026-08-12 13:35:03 +08:00
t8y2 9bacb752e4 fix(tests): format document field search spec 2026-08-12 05:22:36 +00:00
t8y2 a23f516395 fix(jdbc): tolerate missing routine metadata in object browsing
Closes #1532
2026-08-12 05:13:29 +00:00
t8y2 eaa8b59a10 fix(mcp): keep confirmed SQL within central policy
Closes #5346
2026-08-12 05:10:18 +00:00
t8y2 b86ca22206 fix(transfer): split reused Dameng table DDL
Closes #1650
2026-08-12 05:04:29 +00:00
t8y2 891e60c2f9 fix(mongodb): export all documents from collection view
Closes #4197
2026-08-12 05:00:59 +00:00
Guoyu Su e865e4102e fix(agents): ignore nested Java bin outputs 2026-08-12 12:56:44 +08:00
t8y2 3b189c23e9 refactor(hive): remove retired Java agent 2026-08-12 12:46:34 +08:00
zipg 2df0530c77 fix(settings): correct connection and query timeout limits 2026-08-12 12:41:15 +08:00
zhangyc 5173bbafd1 fix(sidebar): correct connection search activation 2026-08-12 12:37:01 +08:00
onenewcode e9538ee1dd fix(duckdb): support table rename 2026-08-12 12:36:14 +08:00
知食分子 e9e0d04a2c fix(ai): keep replies visible on legacy WebView 2026-08-12 12:35:10 +08:00
t8y2 0e42de5f7f fix(mysql): pin dashboard polling to one client session
Closes #5969
2026-08-12 04:28:00 +00:00
t8y2 5433b61d3e fix(grid): preserve page size at query result limit
Closes #5965
2026-08-12 04:24:56 +00:00
t8y2 3a5de1f9d8 fix(postgres): restore table DDL on PostgreSQL 9.x
Closes #5964
2026-08-12 04:22:33 +00:00
t8y2 980c5de3dc fix(web): honor upload limit for table imports
Closes #5957
2026-08-12 03:54:18 +00:00
zhouwenqing 9cfac53a97 perf(web): cache table metadata safely 2026-08-12 11:51:31 +08:00
zipg 81f45ba048 fix(mysql): handle binary hex display and empty values 2026-08-12 10:59:56 +08:00
Elias 275b4d5831 feat(xugu): organize package members and preserve body access 2026-08-12 10:59:37 +08:00
Guoyu Su e8fae013bd fix(ai): harden MiniMax reasoning and tool replay 2026-08-12 10:27:25 +08:00
t8y2 e4bd07c535 fix(transfer): preserve PostgreSQL always identity values
Closes #5942
2026-08-12 02:11:19 +00:00
t8y2 02a56e4f64 fix(grid): show structured copy errors
Closes #5401
2026-08-12 01:04:13 +00:00
github-actions[bot] 8788bed921 chore(packages): release 0.4.60 [skip node-packages-release] 2026-08-12 00:58:46 +00:00
github-actions[bot] 9ae5f495c1 chore: bump module versions [skip ci] 2026-08-12 00:44:44 +00:00
2052 changed files with 281790 additions and 41578 deletions
+44
View File
@@ -0,0 +1,44 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$Path,
[string]$ExpectedCertificateSha1 = $env:VSIGN_CERT_HASH
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
if ([string]::IsNullOrWhiteSpace($ExpectedCertificateSha1)) {
throw "The expected Authenticode certificate SHA1 is not configured"
}
$expectedSha1 = $ExpectedCertificateSha1.Replace(" ", "").ToUpperInvariant()
if ($expectedSha1 -notmatch '^[0-9A-F]{40}$') {
throw "The expected Authenticode certificate SHA1 is invalid"
}
foreach ($candidate in $Path) {
$resolvedPath = (Resolve-Path -LiteralPath $candidate -ErrorAction Stop).Path
if (!(Test-Path -LiteralPath $resolvedPath -PathType Leaf)) {
throw "Authenticode target is not a file: $resolvedPath"
}
$signature = Get-AuthenticodeSignature -LiteralPath $resolvedPath
if ($null -eq $signature.SignerCertificate) {
throw "No Authenticode signature was found on $resolvedPath"
}
$actualSha1 = $signature.SignerCertificate.Thumbprint.Replace(" ", "").ToUpperInvariant()
if ($actualSha1 -ne $expectedSha1) {
throw "Signing certificate mismatch on $resolvedPath. Expected $expectedSha1, got $actualSha1"
}
if ($signature.Status -ne "Valid") {
throw "Authenticode signature status for $resolvedPath is $($signature.Status): $($signature.StatusMessage)"
}
if ($null -eq $signature.TimeStamperCertificate) {
throw "The Authenticode signature on $resolvedPath does not contain a timestamp certificate"
}
Write-Host "Verified Authenticode signature: $resolvedPath"
}
+7 -1
View File
@@ -58,15 +58,21 @@ const nativeDriverDirectories = {
neo4j: "neo4j-go",
vastbase: "vastbase-go",
rabbitmq: "rabbitmq",
rocketmq: "rocketmq",
zookeeper: "zookeeper",
tdengine: "tdengine",
};
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine"]);
const nativeDriverSharedPaths = {
hive: [
"agents/go-common/go-gssapi",
"agents/go-common/gohive",
"agents/go-common/gosasl",
],
zookeeper: [
"agents/go-common/go-gssapi",
"agents/go-common/gosasl",
],
};
function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) {
@@ -59,6 +59,37 @@ test("bumps the native RabbitMQ agent from its Go directory", () => {
assert.equal(result.versions.rabbitmq, "0.1.1");
});
test("bumps the native RocketMQ agent from its Go directory", () => {
const result = evaluateAgentVersionBump({
versions: { rocketmq: "0.1.0" },
changedFiles: ["agents/drivers/rocketmq/main.go"],
moduleExists: (path) => path === "agents/drivers/rocketmq",
readModuleFile: () => "",
});
assert.equal(result.versions.rocketmq, "0.1.1");
assert.deepEqual(result.nativeModules, ["rocketmq"]);
});
test("bumps ZooKeeper from native and shared SASL source directories", () => {
for (const changedFile of [
"agents/drivers/zookeeper/main.go",
"agents/go-common/gosasl/sasl.go",
"agents/go-common/go-gssapi/krb5/krb5.go",
]) {
const result = evaluateAgentVersionBump({
versions: { zookeeper: "0.1.0" },
changedFiles: [changedFile],
moduleExists: (path) => path === "agents/drivers/zookeeper",
readModuleFile: () => "",
});
assert.equal(result.versions.zookeeper, "0.1.1");
assert.deepEqual(result.javaModules, []);
assert.deepEqual(result.nativeModules, ["zookeeper"]);
}
});
test("bumps the native Vastbase agent from its independent Go directory", () => {
const result = evaluateAgentVersionBump({
versions: { vastbase: "0.1.37" },
+15 -2
View File
@@ -41,7 +41,7 @@ const extraAliases = {
doris: ["selectdb"],
manticoresearch: ["manticore"],
dameng: ["dm8", "dameng", "达梦"],
kingbase: ["kingbasees", "kingbase", "人大金仓", "金仓"],
kingbase: ["kingbasees", "kingbase", "人大金仓", "金仓", "电科金仓"],
highgo: ["瀚高"],
yashandb: ["yashan", "崖山"],
saphana: ["hana", "sap hana"],
@@ -55,6 +55,7 @@ const extraAliases = {
db2: ["ibm db2"],
informix: ["ibm informix"],
bigquery: ["google bigquery"],
spanner: ["cloud spanner", "google spanner", "google cloud spanner"],
kylin: ["apache kylin"],
oscar: ["shentong", "oscar", "神通"],
xugu: ["xugudb", "xugu", "虚谷"],
@@ -90,7 +91,19 @@ const supplementalDrivers = [
const manifestUrl = new URL("../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url);
const manifest = JSON.parse(fs.readFileSync(manifestUrl, "utf8"));
const catalogEntries = [...manifest.drivers, ...supplementalDrivers];
const manifestDbTypes = new Set(manifest.drivers.map((driver) => driver.dbType));
const supplementalByDbType = new Map(supplementalDrivers.map((driver) => [driver.dbType, driver]));
const catalogEntries = [
...manifest.drivers.map((driver) => {
const supplemental = supplementalByDbType.get(driver.dbType);
if (!supplemental) return driver;
return {
...driver,
aliases: [...new Set([...(driver.aliases || []), ...(supplemental.aliases || [])])],
};
}),
...supplementalDrivers.filter((driver) => !manifestDbTypes.has(driver.dbType)),
];
const duplicateDbTypes = catalogEntries
.map((driver) => driver.dbType)
.filter((dbType, index, values) => values.indexOf(dbType) !== index);
+6 -2
View File
@@ -6,12 +6,16 @@ import { dirname, join, resolve } from "node:path";
const LOCALES_DIR = "apps/desktop/src/i18n/locales";
const SOURCE_LOCALE = "zh-CN";
const TARGET_LOCALES = ["en", "es", "it", "ja", "pt-BR", "zh-TW"];
const TARGET_LOCALES = ["en", "es", "it", "ja", "ko", "pt-BR", "zh-TW"];
// `git show <ref>:<path>` needs forward slashes even on Windows, where
// `path.join()` yields backslashes. Allow an override (CI keeps the default).
const refPath = (path) => path.replaceAll("\\", "/");
const TARGET_LABELS = {
en: "English",
es: "Spanish",
it: "Italian",
ja: "Japanese",
ko: "Korean",
"pt-BR": "Brazilian Portuguese",
"zh-TW": "Traditional Chinese used in Taiwan",
};
@@ -106,7 +110,7 @@ function localePath(locale) {
function readBaseFile(path) {
try {
return execFileSync("git", ["show", `${baseRef}:${path}`], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
return execFileSync("git", ["show", `${baseRef}:${refPath(path)}`], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
} catch (error) {
throw new Error(`Unable to read ${path} from ${baseRef}: ${error.message}`);
}
@@ -41,6 +41,23 @@ test("database label synchronization includes every catalog entry once", () => {
);
});
test("Kingbase aliases retain historical and current company names", () => {
const kingbase = databaseIssueDrivers.find((driver) => driver.dbType === "kingbase");
assert.ok(kingbase);
assert.equal(kingbase.aliases.includes("人大金仓"), true);
assert.equal(kingbase.aliases.includes("金仓"), true);
assert.equal(kingbase.aliases.includes("电科金仓"), true);
assert.equal(kingbase.aliases.includes("中电科金仓"), false);
});
test("supplemental aliases merge into manifest entries without duplicating labels", () => {
const mqttEntries = databaseIssueDrivers.filter((driver) => driver.dbType === "mqtt");
assert.equal(mqttEntries.length, 1);
assert.equal(mqttEntries[0].aliases.includes("emqx"), true);
assert.equal(mqttEntries[0].aliases.includes("hivemq"), true);
assert.equal(mqttEntries[0].aliases.includes("mosquitto"), true);
});
test("labels native and compatibility database products with their families", () => {
const cases = [
["Turso", ["db/turso"]],
@@ -60,6 +77,8 @@ test("labels native and compatibility database products with their families", ()
["RabbitMQ", ["db/mq", "db/rabbitmq"]],
["MQTT 5.0", ["db/mqtt"]],
["EMQX 5.8", ["db/mqtt"]],
["电科金仓 KingbaseES V9", ["db/kingbase"]],
["中电科金仓 KingbaseES V9", ["db/kingbase"]],
];
for (const [database, expected] of cases) {
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const BUILDER = join(REPO_ROOT, "agents/scripts/build_offline_jdbc_payload.mjs");
const ZIP_BUILDER = join(REPO_ROOT, "agents/scripts/build_offline_zip.sh");
const RELEASE_VERIFIER = join(REPO_ROOT, "agents/scripts/verify_offline_jdbc_release.mjs");
const ASSET_MANIFEST = join(REPO_ROOT, "apps/desktop/src/lib/database/managedJdbcAssets.json");
const PLATFORMS = ["macos-aarch64", "macos-x64", "linux-x64", "linux-aarch64", "windows-x64", "windows-aarch64"];
function flattenBundles(manifest) {
return Object.values(manifest.drivers).flatMap((driver) => Object.values(driver.bundles || {}));
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, { encoding: "utf8", ...options });
assert.equal(result.status, 0, result.stderr || result.stdout);
return result;
}
test("builds and verifies all platform ZIPs from the managed JDBC asset manifest", () => {
const root = mkdtempSync(join(tmpdir(), "dbx-offline-jdbc-builder-test-"));
try {
const releaseDir = join(root, "release");
const pluginZip = join(root, "plugin.zip");
const jarRoot = join(root, "jar");
const artifact = join(root, "driver.jar");
const resolver = join(root, "resolver.mjs");
const testManifestPath = join(root, "managed-jdbc-assets.json");
mkdirSync(releaseDir, { recursive: true });
mkdirSync(join(jarRoot, "META-INF"), { recursive: true });
writeFileSync(pluginZip, "plugin");
writeFileSync(join(jarRoot, "META-INF/LICENSE"), "Apache License 2.0\n");
writeFileSync(join(jarRoot, "META-INF/NOTICE"), "Test notice\n");
run("zip", ["-qr", artifact, "."], { cwd: jarRoot });
const digest = createHash("sha256").update(readFileSync(artifact)).digest("hex");
const productionManifest = JSON.parse(readFileSync(ASSET_MANIFEST, "utf8"));
const testManifest = structuredClone(productionManifest);
const resolverBundles = {};
for (const bundle of flattenBundles(testManifest)) {
bundle.artifact.file_name = "driver.jar";
bundle.artifact.size = statSync(artifact).size;
bundle.artifact.sha256 = digest;
bundle.redistribution.license_entries = ["META-INF/LICENSE"];
bundle.redistribution.notice_entries = ["META-INF/NOTICE"];
resolverBundles[bundle.coordinate] = {
coordinate: bundle.coordinate,
scope: "runtime",
repositories: [testManifest.repository],
artifacts: [
{
groupId: bundle.artifact.group_id,
artifactId: bundle.artifact.artifact_id,
version: bundle.artifact.version,
classifier: bundle.artifact.classifier,
extension: bundle.artifact.extension,
file: artifact,
size: bundle.artifact.size,
sha256: digest,
},
],
};
}
writeFileSync(testManifestPath, `${JSON.stringify(testManifest, null, 2)}\n`);
writeFileSync(
resolver,
`#!/usr/bin/env node
const bundles = ${JSON.stringify(resolverBundles)};
const coordinate = process.argv[process.argv.indexOf("--coordinate") + 1];
if (!bundles[coordinate]) process.exit(2);
process.stdout.write(JSON.stringify(bundles[coordinate]));
`,
);
chmodSync(resolver, 0o755);
run(process.execPath, [BUILDER, releaseDir, pluginZip, resolver, testManifestPath], {
env: { ...process.env, DBX_OFFLINE_JDBC_MAVEN_CACHE: join(root, "cache") },
});
const payloadDir = join(releaseDir, "offline-jdbc/jdbc");
const offlineManifest = JSON.parse(readFileSync(join(payloadDir, "offline-manifest.json"), "utf8"));
assert.equal(offlineManifest.format_version, 1);
assert.equal(offlineManifest.plugin_entry, "jdbc/plugin.zip");
assert.equal(offlineManifest.third_party_entry, "jdbc/third-party-notices.json");
assert.deepEqual(
offlineManifest.bundles.map((bundle) => bundle.coordinate),
flattenBundles(productionManifest).map((bundle) => bundle.coordinate),
);
for (const bundle of offlineManifest.bundles) {
const bundleManifest = JSON.parse(readFileSync(join(payloadDir, "maven", bundle.id, "manifest.json"), "utf8"));
assert.equal(bundleManifest.coordinate, bundle.coordinate);
assert.equal(bundleManifest.artifacts.length, 1);
assert.equal(bundleManifest.artifacts[0].sha256, digest);
}
writeFileSync(join(releaseDir, "agent-registry.json"), "{}\n");
for (const platform of PLATFORMS) writeFileSync(join(releaseDir, `dbx-jre-21-${platform}.tar.zst`), platform);
run("bash", [ZIP_BUILDER, releaseDir]);
run(process.execPath, [RELEASE_VERIFIER, releaseDir, testManifestPath]);
for (const platform of PLATFORMS) assert.equal(existsSync(join(releaseDir, `dbx-agents-offline-${platform}.zip`)), true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
@@ -15,7 +15,7 @@ import { basename, join } from "node:path";
import { tmpdir } from "node:os";
const REGISTRY_ASSET = "agent-registry.json";
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine"]);
const PLATFORMS = [
"macos-aarch64",
"macos-x64",
@@ -114,6 +114,24 @@ test("requires all IoTDB native platforms when reusing a release", () => {
);
});
test("requires all RocketMQ native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-rocketmq-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const registry = { drivers: { rocketmq: { version: "0.1.40", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { rocketmq: "0.1.40" },
modules: ["rocketmq"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
test("ignores zero-size legacy JAR placeholders for native-only modules", () => {
const native = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]),
+106
View File
@@ -0,0 +1,106 @@
[CmdletBinding()]
param()
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$requiredVariables = @(
"GITHUB_ENV",
"RUNNER_TEMP",
"VSIGN_CERT_HASH",
"VSIGN_OPERATOR_PFX_B64",
"VSIGN_OPERATOR_PWD",
"VSIGN_OPERATOR_SHA1",
"VSIGN_SERVER",
"VSIGN_TOOL_SHA256"
)
foreach ($variableName in $requiredVariables) {
$value = [Environment]::GetEnvironmentVariable($variableName)
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Required VSign configuration is missing: $variableName"
}
}
$serverUri = $null
if (![Uri]::TryCreate($env:VSIGN_SERVER, [UriKind]::Absolute, [ref]$serverUri) -or $serverUri.Scheme -ne "https") {
throw "VSIGN_SERVER must be an absolute HTTPS URL"
}
$expectedToolSha256 = $env:VSIGN_TOOL_SHA256.Replace(" ", "").ToUpperInvariant()
$expectedOperatorSha1 = $env:VSIGN_OPERATOR_SHA1.Replace(" ", "").ToUpperInvariant()
$expectedCertificateSha1 = $env:VSIGN_CERT_HASH.Replace(" ", "").ToUpperInvariant()
if ($expectedToolSha256 -notmatch '^[0-9A-F]{64}$') {
throw "VSIGN_TOOL_SHA256 is invalid"
}
if ($expectedOperatorSha1 -notmatch '^[0-9A-F]{40}$') {
throw "VSIGN_OPERATOR_SHA1 is invalid"
}
if ($expectedCertificateSha1 -notmatch '^[0-9A-F]{40}$') {
throw "VSIGN_CERT_HASH is invalid"
}
$runId = if ([string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID)) { "local" } else { $env:GITHUB_RUN_ID }
$runAttempt = if ([string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ATTEMPT)) { "1" } else { $env:GITHUB_RUN_ATTEMPT }
$workDir = Join-Path $env:RUNNER_TEMP "vsign-$runId-$runAttempt"
$operatorDir = Join-Path $workDir "operator"
$operatorFile = Join-Path $operatorDir "dbx-github-ci@ssigncode.pfx"
$toolPath = Join-Path $workDir "ssigncode.exe"
$toolUrl = "https://github.com/g5wsg/vsign-github-test1/releases/download/vsign-cli-v1/ssigncode.exe"
New-Item -ItemType Directory -Force -Path $operatorDir | Out-Null
Invoke-WebRequest -Uri $toolUrl -OutFile $toolPath
$actualToolSha256 = (Get-FileHash -LiteralPath $toolPath -Algorithm SHA256).Hash
if ($actualToolSha256 -ne $expectedToolSha256) {
throw "VSign CLI SHA256 mismatch. Expected $expectedToolSha256, got $actualToolSha256"
}
& $toolPath version
if ($LASTEXITCODE -ne 0) {
throw "VSign CLI version check failed with exit code $LASTEXITCODE"
}
try {
$pfxBytes = [Convert]::FromBase64String($env:VSIGN_OPERATOR_PFX_B64.Trim())
[IO.File]::WriteAllBytes($operatorFile, $pfxBytes)
$flags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet
$operatorCertificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new(
$operatorFile,
$env:VSIGN_OPERATOR_PWD,
$flags
)
}
catch {
throw "Cannot restore or decrypt the VSign operator PFX: $($_.Exception.Message)"
}
try {
$actualOperatorSha1 = $operatorCertificate.Thumbprint.Replace(" ", "").ToUpperInvariant()
if ($actualOperatorSha1 -ne $expectedOperatorSha1) {
throw "Operator certificate mismatch. Expected $expectedOperatorSha1, got $actualOperatorSha1"
}
if (!$operatorCertificate.HasPrivateKey) {
throw "The operator PFX does not contain a private key"
}
if ($operatorCertificate.NotAfter.ToUniversalTime() -le [DateTime]::UtcNow) {
throw "The operator certificate has expired"
}
}
finally {
$operatorCertificate.Dispose()
}
$githubEnvironment = [ordered]@{
VSIGN_WORK_DIR = $workDir
VSIGN_CLI = $toolPath
SSIGNCODE_OPERATOR_STORE = "file"
SSIGNCODE_OPERATOR_DIR = $operatorDir
SSIGNCODE_OPERATOR_FILE = $operatorFile
}
foreach ($entry in $githubEnvironment.GetEnumerator()) {
"$($entry.Key)=$($entry.Value)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
}
Write-Host "VSign CLI and operator certificate are ready."
+34 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { writeFileSync } from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -10,6 +10,10 @@ const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || "";
const OUT_CN = "releases-cn.json";
const OUT_EN = "releases-en.json";
const OUT_INDEX_CN = "index-cn.json";
const OUT_INDEX_EN = "index-en.json";
const RELEASES_CN_DIR = "releases-cn";
const RELEASES_EN_DIR = "releases-en";
const LATEST_EN_OUT = "latest-en.json";
const LATEST_NOTES_OUT = "latest-notes.json";
const EN_CACHE_URL = process.env.CHANGELOG_EN_CACHE_URL || "https://dl.dbxio.com/changelog/releases-en.json";
@@ -68,6 +72,7 @@ function stripIssueRefs(text) {
// 可能是旧版脚本生成的(desc 仍含 closes #),在写文件前统一清理一次。
function stripIssueRefsInReleases(json) {
for (const release of json.releases || []) {
if (release.markdown) release.markdown = stripIssueRefs(release.markdown);
for (const section of release.sections || []) {
for (const item of section.items || []) {
if (item.desc) item.desc = stripIssueRefs(item.desc);
@@ -138,12 +143,30 @@ export function buildReleasesJson(releases, now = new Date()) {
name: r.name || r.tag_name,
date: r.published_at.slice(0, 10),
_sourceHash: buildReleaseSourceHash(r),
markdown: stripIssueRefs(stripDownloadSection(r.body || "")),
sections: parseBody(r.body || ""),
})),
};
}
export function buildChangelogIndex(json) {
return {
updatedAt: json.updatedAt,
releases: json.releases.map(({ tag, name, date }) => ({ tag, name, date })),
};
}
function writeReleaseFiles(json, directory) {
rmSync(directory, { recursive: true, force: true });
mkdirSync(directory, { recursive: true });
for (const release of json.releases) {
writeFileSync(resolve(directory, `${release.tag}.json`), JSON.stringify(release, null, 2));
}
}
function releaseToMarkdown(release) {
if (release.markdown !== undefined) return release.markdown;
return release.sections
.map((s) => {
const items = s.items.map((i) => (i.desc ? `- **${i.title}** — ${i.desc}` : `- ${i.title}`)).join("\n");
@@ -199,7 +222,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
for (const release of cnJson.releases) {
const cachedRelease = cachedByTag.get(release.tag);
if (cachedRelease?._sourceHash === release._sourceHash) {
enReleases.push({ ...cachedRelease, name: release.name, date: release.date, _sourceHash: release._sourceHash });
enReleases.push({ ...cachedRelease, name: release.name, date: release.date, _sourceHash: release._sourceHash, markdown: cachedRelease.markdown || releaseToMarkdown(cachedRelease) });
reusedCount++;
} else {
enReleases.push(release);
@@ -218,6 +241,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
name: release.name,
date: release.date,
_sourceHash: release._sourceHash,
markdown: cachedRelease.markdown || releaseToMarkdown(cachedRelease),
});
reusedCount++;
continue;
@@ -226,7 +250,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
const sectionsText = releaseToMarkdown(release);
if (!sectionsText.trim()) {
enReleases.push({ ...release, sections: [] });
enReleases.push({ ...release, sections: [], markdown: "" });
continue;
}
@@ -256,7 +280,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
const data = await res.json();
const translated = data.choices?.[0]?.message?.content || "";
const enSections = parseBody(translated);
enReleases.push({ ...release, sections: enSections.length > 0 ? enSections : release.sections });
enReleases.push({ ...release, sections: enSections.length > 0 ? enSections : release.sections, markdown: enSections.length > 0 ? translated.trim() : release.markdown });
translatedCount++;
await sleep(200);
@@ -276,6 +300,9 @@ async function main() {
writeFileSync(OUT_CN, JSON.stringify(cnJson, null, 2));
console.log(`Wrote ${OUT_CN}`);
writeFileSync(OUT_INDEX_CN, JSON.stringify(buildChangelogIndex(cnJson), null, 2));
writeReleaseFiles(cnJson, RELEASES_CN_DIR);
console.log(`Wrote ${OUT_INDEX_CN} and ${RELEASES_CN_DIR}/`);
const latestNotes = buildLatestReleaseNotes(releases);
if (latestNotes) {
@@ -293,6 +320,9 @@ async function main() {
stripIssueRefsInReleases(enJson);
writeFileSync(OUT_EN, JSON.stringify(enJson, null, 2));
console.log(`Wrote ${OUT_EN}`);
writeFileSync(OUT_INDEX_EN, JSON.stringify(buildChangelogIndex(enJson), null, 2));
writeReleaseFiles(enJson, RELEASES_EN_DIR);
console.log(`Wrote ${OUT_INDEX_EN} and ${RELEASES_EN_DIR}/`);
// 应用内更新提示用的英文 notes(单条最新版本)
const latestEn = buildLatestEnNotes(enJson);
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
die() {
echo "AppImage input-method verification failed: $*" >&2
exit 1
}
if [[ $# -ne 1 ]]; then
die "usage: $0 <AppImage file or bundle directory>"
fi
input_path=$1
if [[ -f "$input_path" ]]; then
appimage=$input_path
elif [[ -d "$input_path" ]]; then
appimages=()
while IFS= read -r -d '' candidate; do
appimages+=("$candidate")
done < <(find "$input_path" -maxdepth 1 -type f -name '*.AppImage' -print0)
[[ ${#appimages[@]} -eq 1 ]] \
|| die "expected exactly one AppImage in $input_path, found ${#appimages[@]}"
appimage=${appimages[0]}
else
die "path does not exist: $input_path"
fi
appimage=$(realpath "$appimage")
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/dbx-appimage-input-methods.XXXXXX")
trap 'rm -rf "$work_dir"' EXIT
(
cd "$work_dir"
"$appimage" --appimage-extract >/dev/null
)
appdir="$work_dir/squashfs-root"
[[ -d "$appdir" ]] || die "AppImage extraction did not create squashfs-root"
appdir=$(realpath "$appdir")
require_artifact() {
local basename=$1
local match
local resolved
match=$(find "$appdir" \( -type f -o -type l \) -name "$basename" -print -quit)
[[ -n "$match" ]] || die "missing bundled artifact: $basename"
[[ -e "$match" ]] || die "bundled artifact is a broken symbolic link: ${match#"$appdir"/}"
resolved=$(realpath "$match")
[[ "$resolved" == "$appdir"/* ]] \
|| die "bundled artifact resolves outside the AppImage: ${match#"$appdir"/}"
echo "Found $basename at ${match#"$appdir"/}"
}
require_artifact im-fcitx5.so
require_artifact im-ibus.so
require_artifact libFcitx5GClient.so.2
require_artifact libibus-1.0.so.5
cache=''
while IFS= read -r -d '' candidate; do
if grep -Fq 'im-fcitx5.so' "$candidate" && grep -Fq 'im-ibus.so' "$candidate"; then
cache=$candidate
break
fi
done < <(find "$appdir" -type f -path '*/gtk-3.0/*/immodules.cache' -print0)
[[ -n "$cache" ]] || die "no private GTK 3 immodules.cache registers both Fcitx 5 and IBus"
echo "Verified GTK input-method cache at ${cache#"$appdir"/}"
+72
View File
@@ -0,0 +1,72 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$FilePath
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$resolvedPath = (Resolve-Path -LiteralPath $FilePath -ErrorAction Stop).Path
if (!(Test-Path -LiteralPath $resolvedPath -PathType Leaf)) {
throw "VSign target is not a file: $resolvedPath"
}
$extension = [IO.Path]::GetExtension($resolvedPath).ToLowerInvariant()
if ($extension -eq ".dll") {
Write-Host "Skipping Authenticode signing for DLL: $resolvedPath"
return
}
if ($extension -notin @(".exe", ".msi")) {
throw "Unsupported VSign target type: $extension"
}
$requiredVariables = @(
"SSIGNCODE_OPERATOR_DIR",
"SSIGNCODE_OPERATOR_FILE",
"SSIGNCODE_OPERATOR_PWD",
"SSIGNCODE_OPERATOR_STORE",
"VSIGN_CERT_HASH",
"VSIGN_CLI",
"VSIGN_KEY_PIN",
"VSIGN_SERVER"
)
foreach ($variableName in $requiredVariables) {
$value = [Environment]::GetEnvironmentVariable($variableName)
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Required VSign runtime configuration is missing: $variableName"
}
}
$directory = [IO.Path]::GetDirectoryName($resolvedPath)
$fileName = [IO.Path]::GetFileNameWithoutExtension($resolvedPath)
$signedPath = Join-Path $directory "$fileName.vsign-$([Guid]::NewGuid().ToString('N'))$extension"
try {
& $env:VSIGN_CLI vsign `
-s $env:VSIGN_SERVER `
--cert_from csign `
-u dbx-github-ci `
--cert_hash $env:VSIGN_CERT_HASH `
-k $env:VSIGN_KEY_PIN `
--hash sha256 `
-i $resolvedPath `
-o $signedPath
if ($LASTEXITCODE -ne 0) {
throw "VSign failed for $resolvedPath with exit code $LASTEXITCODE"
}
if (!(Test-Path -LiteralPath $signedPath -PathType Leaf)) {
throw "VSign did not create the signed output for $resolvedPath"
}
& "$PSScriptRoot/assert-authenticode.ps1" -Path $signedPath
Move-Item -LiteralPath $signedPath -Destination $resolvedPath -Force
Write-Host "Signed with TrustAsia VSign: $resolvedPath"
}
finally {
if (Test-Path -LiteralPath $signedPath) {
Remove-Item -LiteralPath $signedPath -Force
}
}
+131 -6
View File
@@ -83,6 +83,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MCP_RELEASE_TOKEN }}
- name: Download agent version patch
uses: actions/download-artifact@v4
@@ -146,7 +147,11 @@ jobs:
for module in "${MODULES[@]}"; do
TASKS+=(":${module}:shadowJar")
done
./gradlew "${TASKS[@]}" --parallel
# agents/common changes trigger a parallel mass rebuild of every
# packaged module; the Gradle daemon default heap is too small for
# ~25 shadowJar fat jars at once on a 16 GB runner.
./gradlew "${TASKS[@]}" --parallel \
-Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g"
working-directory: agents
- run: python3 scripts/validate_agent_jars.py
working-directory: agents
@@ -272,7 +277,6 @@ jobs:
["linux-aarch64"]="linux/arm64"
["linux-x64"]="linux/amd64"
["windows-aarch64"]="windows/arm64"
["windows-x64"]="windows/amd64"
)
for platform in "${!TARGETS[@]}"; do
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
@@ -284,11 +288,105 @@ jobs:
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
done
ls -lh ../../../release-native
# Go 1.20 is the last release that supports Windows 7; keep other targets on Go 1.22.
- name: Set up Go for Windows 7-compatible RabbitMQ agent
uses: actions/setup-go@v5
with:
go-version: "1.20.14"
cache-dependency-path: agents/drivers/rabbitmq/go.sum
- name: Build Windows 7-compatible RabbitMQ agent
shell: bash
working-directory: agents/drivers/rabbitmq
run: |
output="../../../release-native/dbx-agent-rabbitmq-windows-x64.exe"
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o "$output" .
go version -m "$output" | tee /tmp/rabbitmq-windows-x64-build-info.txt
grep -q ': go1\.20\.14$' /tmp/rabbitmq-windows-x64-build-info.txt
- uses: actions/upload-artifact@v4
with:
name: rabbitmq-native
path: "release-native/dbx-agent-rabbitmq-*"
build-rocketmq-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'rocketmq') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.x"
- name: Test RocketMQ native agent
working-directory: agents/drivers/rocketmq
run: go test -race ./...
- name: Cross-compile RocketMQ native agent
shell: bash
run: |
mkdir -p release-native
cd agents/drivers/rocketmq
declare -A TARGETS=(
["macos-aarch64"]="darwin/arm64"
["macos-x64"]="darwin/amd64"
["linux-aarch64"]="linux/arm64"
["linux-x64"]="linux/amd64"
["windows-aarch64"]="windows/arm64"
["windows-x64"]="windows/amd64"
)
for platform in "${!TARGETS[@]}"; do
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
output="../../../release-native/dbx-agent-rocketmq-${platform}"
if [[ "$goos" == "windows" ]]; then
output="${output}.exe"
fi
echo "Building $platform ($goos/$goarch)"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
done
ls -lh ../../../release-native
- uses: actions/upload-artifact@v4
with:
name: rocketmq-native
path: "release-native/dbx-agent-rocketmq-*"
build-zookeeper-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'zookeeper') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.x"
- name: Test ZooKeeper native agent
working-directory: agents/drivers/zookeeper
run: go test -race ./...
- name: Cross-compile ZooKeeper native agent
shell: bash
run: |
mkdir -p release-native
cd agents/drivers/zookeeper
declare -A TARGETS=(
["macos-aarch64"]="darwin/arm64"
["macos-x64"]="darwin/amd64"
["linux-aarch64"]="linux/arm64"
["linux-x64"]="linux/amd64"
["windows-aarch64"]="windows/arm64"
["windows-x64"]="windows/amd64"
)
for platform in "${!TARGETS[@]}"; do
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
output="../../../release-native/dbx-agent-zookeeper-${platform}"
if [[ "$goos" == "windows" ]]; then
output="${output}.exe"
fi
echo "Building $platform ($goos/$goarch)"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
done
ls -lh ../../../release-native
- uses: actions/upload-artifact@v4
with:
name: zookeeper-native
path: "release-native/dbx-agent-zookeeper-*"
build-cassandra-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'cassandra') }}
@@ -868,7 +966,7 @@ jobs:
retention-days: 1
release:
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-cassandra-native, build-hive-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-iotdb-native, build-duckdb-native, build-tdengine-native, build-jre, reuse-previous-assets]
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-rocketmq-native, build-zookeeper-native, build-cassandra-native, build-hive-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-iotdb-native, build-duckdb-native, build-tdengine-native, build-jre, reuse-previous-assets]
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: ubuntu-latest
steps:
@@ -889,6 +987,11 @@ jobs:
with:
token: ${{ steps.release-token.outputs.token }}
fetch-depth: 0
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- uses: gradle/actions/setup-gradle@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
@@ -985,11 +1088,13 @@ jobs:
native_only_label() {
local name="$1"
case "$name" in
kingbase) echo "人大金仓 KingbaseES" ;;
kingbase) echo "金仓KingbaseES" ;;
vastbase) echo "Vastbase" ;;
duckdb) echo "DuckDB" ;;
xugu) echo "虚谷 XuguDB" ;;
rabbitmq) echo "RabbitMQ" ;;
rocketmq) echo "Apache RocketMQ" ;;
zookeeper) echo "Apache ZooKeeper" ;;
cassandra) echo "Apache Cassandra" ;;
hive) echo "Apache Hive" ;;
neo4j) echo "Neo4j" ;;
@@ -1058,7 +1163,7 @@ jobs:
[ -n "$DRIVERS" ] && DRIVERS="${DRIVERS},"$'\n'
DRIVERS="${DRIVERS}$(generate_jar_entry "$name" "$label" "$f" "$jre_key" "$version" "$external_driver" "$native_json")"
done
for name in oracle xugu kingbase vastbase neo4j iotdb duckdb rabbitmq cassandra hive tdengine; do
for name in oracle xugu kingbase vastbase neo4j iotdb duckdb rabbitmq rocketmq zookeeper cassandra hive tdengine; do
version=$(get_module_version "$name")
[ -f "release/dbx-agent-${name}-${version}.jar" ] && continue
native_json=$(generate_native_platforms "$name" "$version")
@@ -1090,9 +1195,22 @@ jobs:
echo "=== agent-registry.json ==="
cat release/agent-registry.json
- name: Build managed JDBC offline payload
shell: bash
run: |
./plugins/jdbc/package.sh
JDBC_VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)"
node agents/scripts/build_offline_jdbc_payload.mjs \
release \
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}.zip" \
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}/bin/dbx-maven-resolver"
- name: Build offline ZIP bundles
run: bash agents/scripts/build_offline_zip.sh release
- name: Verify managed JDBC assets in all offline ZIPs
run: node agents/scripts/verify_offline_jdbc_release.mjs release
- name: Build single-driver packages
run: |
python3 agents/scripts/build_driver_zips.py release --cleanup-sources
@@ -1122,12 +1240,14 @@ jobs:
native_only_label() {
local name="$1"
case "$name" in
kingbase) echo "人大金仓 KingbaseES" ;;
kingbase) echo "金仓KingbaseES" ;;
vastbase) echo "Vastbase" ;;
duckdb) echo "DuckDB" ;;
oracle) echo "Oracle" ;;
xugu) echo "虚谷 XuguDB" ;;
rabbitmq) echo "RabbitMQ" ;;
rocketmq) echo "Apache RocketMQ" ;;
zookeeper) echo "Apache ZooKeeper" ;;
cassandra) echo "Apache Cassandra" ;;
hive) echo "Apache Hive" ;;
neo4j) echo "Neo4j" ;;
@@ -1177,6 +1297,11 @@ jobs:
"agents/go-common/gohive/"
"agents/go-common/gosasl/"
)
elif [ "$name" = "zookeeper" ]; then
LOG_PATHS+=(
"agents/go-common/go-gssapi/"
"agents/go-common/gosasl/"
)
fi
while IFS= read -r line; do
[ -n "$line" ] && NOTES="${NOTES}- ${line}"$'\n'
+185 -4
View File
@@ -71,6 +71,13 @@ jobs:
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
@@ -337,8 +344,8 @@ jobs:
RUSTC_WRAPPER: sccache
# The fast lane skips only system font discovery while retaining the other default capabilities.
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
steps:
@@ -378,6 +385,13 @@ jobs:
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
@@ -416,8 +430,8 @@ jobs:
RUSTC_WRAPPER: sccache
# The fast lane skips only system font discovery while retaining the other default capabilities.
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
steps:
@@ -455,6 +469,13 @@ jobs:
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
@@ -526,6 +547,46 @@ jobs:
- name: JDBC plugin package check
run: ./plugins/jdbc/package.sh
offline-jdbc-release:
needs: changes
if: needs.changes.outputs.offline_jdbc == 'true'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
cache: gradle
- name: Cache approved Maven assets
uses: actions/cache@v4
with:
path: ~/.cache/dbx-offline-jdbc-maven
key: offline-jdbc-${{ hashFiles('apps/desktop/src/lib/database/managedJdbcAssets.json') }}
- name: Build real managed JDBC payload
env:
DBX_OFFLINE_JDBC_MAVEN_CACHE: ~/.cache/dbx-offline-jdbc-maven
run: |
./plugins/jdbc/package.sh
JDBC_VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)"
node agents/scripts/build_offline_jdbc_payload.mjs \
release \
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}.zip" \
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}/bin/dbx-maven-resolver"
- name: Build and unpack all six platform ZIPs
run: |
printf '{}\n' > release/agent-registry.json
for platform in macos-aarch64 macos-x64 linux-x64 linux-aarch64 windows-x64 windows-aarch64; do
printf '%s\n' "$platform" > "release/dbx-jre-21-${platform}.tar.zst"
done
bash agents/scripts/build_offline_zip.sh release
node agents/scripts/verify_offline_jdbc_release.mjs release
nix-packaging:
needs: changes
if: needs.changes.outputs.nix == 'true'
@@ -548,6 +609,7 @@ jobs:
rust: ${{ steps.filter.outputs.rust }}
rust_full: ${{ steps.rust-mode.outputs.full }}
jdbc: ${{ steps.filter.outputs.jdbc }}
offline_jdbc: ${{ steps.filter.outputs.offline_jdbc }}
agents: ${{ steps.filter.outputs.agents }}
duckdb_windows: ${{ steps.filter.outputs.duckdb_windows }}
nix: ${{ steps.filter.outputs.nix }}
@@ -595,6 +657,15 @@ jobs:
- '.github/workflows/ci.yml'
jdbc:
- 'plugins/jdbc/**'
offline_jdbc:
- 'plugins/jdbc/**'
- 'agents/scripts/build_offline_jdbc_payload.mjs'
- 'agents/scripts/build_offline_zip.sh'
- 'agents/scripts/verify_offline_jdbc_release.mjs'
- 'apps/desktop/src/lib/database/managedJdbcAssets.json'
- '.github/scripts/offline-jdbc-payload.test.mjs'
- '.github/workflows/agents-release.yml'
- '.github/workflows/ci.yml'
agents:
- 'agents/**'
- 'crates/dbx-core/Cargo.toml'
@@ -740,6 +811,14 @@ jobs:
run: go test ./...
working-directory: agents/drivers/rabbitmq
- name: RocketMQ native agent tests
run: go test -race ./...
working-directory: agents/drivers/rocketmq
- name: ZooKeeper native agent tests
run: go test -race ./...
working-directory: agents/drivers/zookeeper
- name: Cassandra native agent tests
run: go test ./...
working-directory: agents/drivers/cassandra-go
@@ -772,6 +851,108 @@ jobs:
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-rabbitmq-linux-x64 .
working-directory: agents/drivers/rabbitmq
- name: RocketMQ native agent build
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-rocketmq-linux-x64 .
working-directory: agents/drivers/rocketmq
- name: ZooKeeper native agent cross-builds
shell: bash
working-directory: agents/drivers/zookeeper
run: |
set -euo pipefail
for target in darwin/arm64 darwin/amd64 linux/arm64 linux/amd64 windows/arm64 windows/amd64; do
IFS=/ read -r goos goarch <<< "$target"
output="/tmp/dbx-agent-zookeeper-${goos}-${goarch}"
[ "$goos" = windows ] && output="${output}.exe"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
done
- name: ZooKeeper native agent integration tests
shell: bash
working-directory: agents/drivers/zookeeper
run: |
set -euo pipefail
for version in 3.4.14 3.5.5 3.7.0 3.9.5; do
name="dbx-zookeeper-${version//./-}"
docker rm -fv "$name" >/dev/null 2>&1 || true
docker run -d --name "$name" -p 2181:2181 "zookeeper:$version"
cleanup() {
docker rm -fv "$name" >/dev/null 2>&1 || true
}
trap cleanup EXIT
ready=false
for _ in $(seq 1 90); do
if timeout 1 bash -c '</dev/tcp/127.0.0.1/2181' >/dev/null 2>&1; then
ready=true
break
fi
sleep 2
done
if [ "$ready" != true ]; then
docker logs "$name"
exit 1
fi
DBX_ZOOKEEPER_TEST_CONNECT_STRING=127.0.0.1:2181 \
go test -run '^TestZooKeeperIntegration$' -count=1 ./...
if [ "$version" = "3.7.0" ]; then
DBX_ZOOKEEPER_TEST_CONNECT_STRING=127.0.0.1:2181 \
go test -run '^TestZooKeeperLargeChildrenIntegration$' -count=1 ./...
fi
cleanup
trap - EXIT
done
- name: ZooKeeper SASL DIGEST-MD5 integration test
shell: bash
working-directory: agents/drivers/zookeeper
run: |
set -euo pipefail
name="dbx-zookeeper-sasl-3-7-0"
jaas_file=$(mktemp)
cat > "$jaas_file" <<'EOF'
Server {
org.apache.zookeeper.server.auth.DigestLoginModule required
user_dbx="dbx-secret";
};
EOF
chmod 0644 "$jaas_file"
docker rm -fv "$name" >/dev/null 2>&1 || true
docker run -d --name "$name" -p 2181:2181 \
-v "$jaas_file:/conf/server-jaas.conf:ro" \
-e ZOO_CFG_EXTRA="authProvider.1=org.apache.zookeeper.server.auth.SASLAuthenticationProvider" \
-e JVMFLAGS="-Djava.security.auth.login.config=/conf/server-jaas.conf -Dzookeeper.sasl.serverconfig=Server -Dzookeeper.sessionRequireClientSASLAuth=true" \
zookeeper:3.7.0
cleanup() {
docker rm -fv "$name" >/dev/null 2>&1 || true
}
trap cleanup EXIT
ready=false
for _ in $(seq 1 90); do
if timeout 1 bash -c '</dev/tcp/127.0.0.1/2181' >/dev/null 2>&1; then
ready=true
break
fi
sleep 2
done
if [ "$ready" != true ]; then
docker logs "$name"
exit 1
fi
DBX_ZOOKEEPER_TEST_CONNECT_STRING=127.0.0.1:2181 \
DBX_ZOOKEEPER_TEST_AUTH_SCHEME=sasl_digest \
DBX_ZOOKEEPER_TEST_USERNAME=dbx \
DBX_ZOOKEEPER_TEST_PASSWORD=dbx-secret \
go test -run '^TestZooKeeperIntegration$' -count=1 ./...
- name: RocketMQ native agent integration tests
shell: bash
working-directory: agents/drivers/rocketmq
run: |
set -euo pipefail
for version in 4.9.8 5.3.1; do
./scripts/run-integration.sh "$version"
done
- name: Cassandra native agent build
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-cassandra-linux-x64 .
working-directory: agents/drivers/cassandra-go
+93 -10
View File
@@ -117,11 +117,10 @@ jobs:
const updateLockDependency = (dependency, packageDirectory) => {
const escapedDependency = dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const dependencyPattern = new RegExp(`('${escapedDependency}':\\n\\s+specifier: )[^\\n]+(\\n\\s+version: )([^\\n]+)`);
const updated = lockfile.replace(dependencyPattern, `$1${version}$2link:../${packageDirectory}`);
if (updated === lockfile) {
if (!dependencyPattern.test(lockfile)) {
throw new Error(`Unable to update ${dependency} in pnpm-lock.yaml.`);
}
lockfile = updated;
lockfile = lockfile.replace(dependencyPattern, `$1${version}$2link:../${packageDirectory}`);
};
for (const dependency of Object.keys(mcpPackage.optionalDependencies ?? {})) {
updateLockDependency(dependency, dependency.replace("@dbx-app/", ""));
@@ -194,6 +193,7 @@ jobs:
- uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.MCP_RELEASE_TOKEN }}
- name: Download package release patch
uses: actions/download-artifact@v4
@@ -201,15 +201,10 @@ jobs:
name: package-release-patch
- name: Apply package release patch and push
env:
RELEASE_TOKEN: ${{ secrets.MCP_RELEASE_TOKEN }}
run: |
set -euo pipefail
TAG="${{ needs.prepare.outputs.tag }}"
VERSION="${{ needs.prepare.outputs.version }}"
if [ -n "${RELEASE_TOKEN}" ]; then
git remote set-url origin "https://x-access-token:${RELEASE_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin main --no-tags
@@ -348,6 +343,22 @@ jobs:
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 the post-compile packaging tail so
# "Show sccache stats" reflects the real counters.
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. All matrix legs
# are native builds, so plain CC/CXX select the target compiler;
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
case "${RUNNER_OS}" in
Linux)
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
;;
macOS)
echo "CC=${SCCACHE_PATH} clang"
echo "CXX=${SCCACHE_PATH} clang++"
;;
esac
} >> "$GITHUB_ENV"
- name: Rust cache
@@ -357,6 +368,34 @@ jobs:
cache-targets: false
cache-on-failure: true
- name: Setup full Perl for vendored OpenSSL on Windows
if: runner.os == 'Windows'
shell: pwsh
env:
PERL_ARCHIVE_URL: https://github.com/shogo82148/build-perl/releases/download/perl-5.42.3-20260803012115/perl-5.42.3-thr-win32-x64.zip
PERL_ARCHIVE_SHA256: 4f0ac6fe1a4221c1fa1804c2d81999bdecebd505a61d0d04720e9b3925cd6db4
run: |
$archive = Join-Path $env:RUNNER_TEMP "perl-5.42.3-thr-win32-x64.zip"
$destination = Join-Path $env:RUNNER_TEMP "openssl-perl"
curl.exe --fail --location --retry 5 --retry-all-errors $env:PERL_ARCHIVE_URL --output $archive
if ($LASTEXITCODE -ne 0) {
throw "Unable to download the full Perl runtime."
}
$actualHash = (Get-FileHash -Path $archive -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $env:PERL_ARCHIVE_SHA256) {
throw "Unexpected Perl archive SHA256: $actualHash"
}
Expand-Archive -Path $archive -DestinationPath $destination -Force
$perl = Get-ChildItem -Path $destination -Filter perl.exe -Recurse | Where-Object { $_.FullName -match '[\\/]bin[\\/]perl\.exe$' } | Select-Object -First 1
if (-not $perl) {
throw "Unable to locate perl.exe in the downloaded runtime."
}
& $perl.FullName -MParams::Check -e 1
if ($LASTEXITCODE -ne 0) {
throw "The downloaded Perl runtime cannot load Params::Check."
}
"OPENSSL_SRC_PERL=$($perl.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Install Linux native dependencies
if: runner.os == 'Linux'
run: |
@@ -460,7 +499,7 @@ jobs:
echo "@dbx-app/cli@${VERSION} already exists on npm; skipping."
exit 0
fi
pnpm --filter "@dbx-app/cli" publish --access public --provenance --no-git-checks
pnpm publish "./packages/cli" --access public --provenance --no-git-checks
publish-mcp-platforms:
name: Publish ${{ matrix.package-name }}
@@ -538,6 +577,22 @@ jobs:
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 the post-compile packaging tail so
# "Show sccache stats" reflects the real counters.
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. All matrix legs
# are native builds, so plain CC/CXX select the target compiler;
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
case "${RUNNER_OS}" in
Linux)
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
;;
macOS)
echo "CC=${SCCACHE_PATH} clang"
echo "CXX=${SCCACHE_PATH} clang++"
;;
esac
} >> "$GITHUB_ENV"
- name: Rust cache
@@ -547,6 +602,34 @@ jobs:
cache-targets: false
cache-on-failure: true
- name: Setup full Perl for vendored OpenSSL on Windows
if: runner.os == 'Windows'
shell: pwsh
env:
PERL_ARCHIVE_URL: https://github.com/shogo82148/build-perl/releases/download/perl-5.42.3-20260803012115/perl-5.42.3-thr-win32-x64.zip
PERL_ARCHIVE_SHA256: 4f0ac6fe1a4221c1fa1804c2d81999bdecebd505a61d0d04720e9b3925cd6db4
run: |
$archive = Join-Path $env:RUNNER_TEMP "perl-5.42.3-thr-win32-x64.zip"
$destination = Join-Path $env:RUNNER_TEMP "openssl-perl"
curl.exe --fail --location --retry 5 --retry-all-errors $env:PERL_ARCHIVE_URL --output $archive
if ($LASTEXITCODE -ne 0) {
throw "Unable to download the full Perl runtime."
}
$actualHash = (Get-FileHash -Path $archive -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $env:PERL_ARCHIVE_SHA256) {
throw "Unexpected Perl archive SHA256: $actualHash"
}
Expand-Archive -Path $archive -DestinationPath $destination -Force
$perl = Get-ChildItem -Path $destination -Filter perl.exe -Recurse | Where-Object { $_.FullName -match '[\\/]bin[\\/]perl\.exe$' } | Select-Object -First 1
if (-not $perl) {
throw "Unable to locate perl.exe in the downloaded runtime."
}
& $perl.FullName -MParams::Check -e 1
if ($LASTEXITCODE -ne 0) {
throw "The downloaded Perl runtime cannot load Params::Check."
}
"OPENSSL_SRC_PERL=$($perl.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Install Linux native dependencies
if: runner.os == 'Linux'
run: |
@@ -660,7 +743,7 @@ jobs:
echo "@dbx-app/mcp-server@${VERSION} already exists on npm; skipping."
exit 0
fi
pnpm --filter "@dbx-app/mcp-server" publish --access public --provenance --no-git-checks
pnpm publish "./packages/mcp-server" --access public --provenance --no-git-checks
publish-mcp-github-release:
name: Publish MCP GitHub Release assets
+4 -3
View File
@@ -193,8 +193,9 @@ jobs:
# ${AI_ANALYSIS}"
# fi
MSG=$(echo "$MSG_CONTENT" | jq -Rs .)
ssh "root@${HOST}" "curl -s -X POST http://localhost:6185/api/v1/im/message \
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 \
-H 'Authorization: Bearer ${ASTRBOT_API_KEY}' \
-H 'Content-Type: application/json' \
-d '{\"umo\": \"default:GroupMessage:1087880322\", \"message\": ${MSG}}'"
--data-binary @-"
+1
View File
@@ -452,6 +452,7 @@ jobs:
PYEOF
- name: Send to QQ group
continue-on-error: true
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
HOST: ${{ secrets.DEPLOY_HOST }}
+268 -78
View File
@@ -62,6 +62,7 @@ jobs:
- uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.MCP_RELEASE_TOKEN }}
- name: Find previous release tag
id: prev-tag
@@ -89,6 +90,7 @@ jobs:
TMP_DIR="$(mktemp -d)"
cp plugins/jdbc/build.gradle "$TMP_DIR/build.gradle"
cp plugins/jdbc/manifest.json "$TMP_DIR/manifest.json"
git restore plugins/jdbc/build.gradle plugins/jdbc/manifest.json
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -128,12 +130,6 @@ jobs:
target: x86_64-unknown-linux-gnu
- platform: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- platform: windows-2022
target: x86_64-pc-windows-msvc
arch: x64
- platform: windows-2022
target: aarch64-pc-windows-msvc
arch: arm64
runs-on: ${{ matrix.platform }}
env:
@@ -184,6 +180,23 @@ jobs:
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
# Linking, bundling, and signing run for 30+ minutes after the last
# rustc call; the server's 10-minute default idle timeout would
# exit first and zero the counters shown by "Show sccache stats".
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. All matrix legs
# are native builds, so plain CC/CXX select the target compiler;
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
case "${RUNNER_OS}" in
Linux)
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
;;
macOS)
echo "CC=${SCCACHE_PATH} clang"
echo "CXX=${SCCACHE_PATH} clang++"
;;
esac
} >> "$GITHUB_ENV"
- name: Compute Rust dependency hash
@@ -230,7 +243,9 @@ jobs:
if: startsWith(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev xdg-utils
# Bundle GTK input-method modules from the same Jammy ABI baseline
# instead of loading potentially incompatible modules from the host.
sudo apt-get install -y fcitx5-frontend-gtk3 ibus-gtk3 libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev xdg-utils
- name: Setup Tauri signing key
shell: bash
@@ -255,18 +270,212 @@ jobs:
releaseDraft: true
args: --target ${{ matrix.target }}
- name: Verify AppImage input methods
if: startsWith(matrix.platform, 'ubuntu')
shell: bash
run: ./.github/scripts/verify-appimage-input-methods.sh "target/${{ matrix.target }}/release/bundle/appimage"
- name: Show sccache stats
if: always()
continue-on-error: true
shell: bash
run: ${SCCACHE_PATH} --show-stats
build-windows:
needs: release-ready
environment: release-signing
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-pc-windows-msvc
arch: x64
- target: aarch64-pc-windows-msvc
arch: arm64
runs-on: windows-2022
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
VSIGN_CERT_HASH: ${{ vars.VSIGN_CERT_HASH }}
VSIGN_OPERATOR_SHA1: ${{ vars.VSIGN_OPERATOR_SHA1 }}
VSIGN_SERVER: ${{ vars.VSIGN_SERVER }}
VSIGN_TOOL_SHA256: ${{ vars.VSIGN_TOOL_SHA256 }}
steps:
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Install frontend dependencies
run: pnpm install
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
with:
targets: ${{ matrix.target }}
- 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}"
# Linking, bundling, and signing run for 30+ minutes after the last
# rustc call; the server's 10-minute default idle timeout would
# exit first and zero the counters shown by "Show sccache stats".
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. All matrix legs
# are native builds, so plain CC/CXX select the target compiler;
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
case "${RUNNER_OS}" in
Linux)
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
;;
macOS)
echo "CC=${SCCACHE_PATH} clang"
echo "CXX=${SCCACHE_PATH} clang++"
;;
esac
} >> "$GITHUB_ENV"
- name: Compute Rust dependency hash
id: deps-hash
shell: bash
run: |
{
find . -name Cargo.toml -not -path './target/*' -print0 \
| sort -z \
| xargs -0 sed -E '/^version = /d'
grep -v '^version = ' Cargo.lock
} | sha256sum | cut -d' ' -f1 | {
read -r hash
echo "hash=${hash:0:20}" >> "$GITHUB_OUTPUT"
}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./ -> target"
shared-key: release-${{ matrix.target }}-${{ steps.deps-hash.outputs.hash }}
add-rust-environment-hash-key: false
cache-targets: false
cache-on-failure: true
- name: Setup Tauri signing key
shell: bash
run: |
echo "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_BASE64 }}" | base64 --decode > "$RUNNER_TEMP/updater.key"
KEY_B64=$(base64 < "$RUNNER_TEMP/updater.key" | tr -d '\r\n')
echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV"
if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" ]; then
echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> "$GITHUB_ENV"
fi
- name: Setup TrustAsia VSign
shell: pwsh
env:
VSIGN_OPERATOR_PFX_B64: ${{ secrets.VSIGN_OPERATOR_PFX_B64 }}
VSIGN_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
run: ./.github/scripts/setup-vsign.ps1
- name: Build and sign Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
with:
tagName: ${{ github.ref_name }}
releaseDraft: true
args: --target ${{ matrix.target }} --config src-tauri/tauri.vsign.conf.json
- name: Verify Windows bundles
shell: pwsh
run: |
$bundleRoot = "target/${{ matrix.target }}/release/bundle"
$nsisInstallers = @(
Get-ChildItem (Join-Path $bundleRoot "nsis") -Filter "*.exe" -File -ErrorAction SilentlyContinue
)
$msiInstallers = @(
Get-ChildItem (Join-Path $bundleRoot "msi") -Filter "*.msi" -File -ErrorAction SilentlyContinue
)
if ($nsisInstallers.Count -eq 0) {
throw "No NSIS installer was produced for ${{ matrix.target }}"
}
if ($msiInstallers.Count -eq 0) {
throw "No MSI installer was produced for ${{ matrix.target }}"
}
./.github/scripts/assert-authenticode.ps1 -Path @(
$nsisInstallers.FullName
$msiInstallers.FullName
)
- name: Upload Windows WebView2 offline installer
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
run: |
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
$arch = "${{ matrix.arch }}"
$bundleDir = "target/${{ matrix.target }}/release/bundle/nsis"
$offlineName = "DBX_${version}_${arch}-webview2-offline-setup.exe"
$before = @{}
if (Test-Path $bundleDir) {
Get-ChildItem $bundleDir -Filter "*.exe" | ForEach-Object { $before[$_.FullName] = $_.LastWriteTimeUtc }
}
pnpm tauri bundle --bundles nsis --target ${{ matrix.target }} `
--config src-tauri/tauri.webview2-offline.conf.json `
--config src-tauri/tauri.vsign.conf.json
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
Where-Object { !$before.ContainsKey($_.FullName) -or $_.LastWriteTimeUtc -gt $before[$_.FullName] } |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (!$installer) {
throw "Missing WebView2 offline installer in ${bundleDir}"
}
./.github/scripts/assert-authenticode.ps1 -Path $installer.FullName
Copy-Item $installer.FullName $offlineName -Force
gh release upload "${env:GITHUB_REF_NAME}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber
- name: Upload Windows portable ZIP
if: matrix.arch == 'x64' || matrix.arch == 'arm64'
timeout-minutes: 20
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
run: |
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
$arch = "${{ matrix.arch }}"
@@ -278,14 +487,15 @@ jobs:
$appVersion = ($cargoMetadata.packages | Where-Object { $_.name -eq "dbx" } | Select-Object -First 1).version
if (!(Test-Path $exePath)) {
Write-Error "Missing Windows executable: $exePath"
exit 1
throw "Missing Windows executable: $exePath"
}
if (!$appVersion -or $appVersion -ne $version) {
Write-Error "Release tag version $version does not match the built DBX package version $appVersion"
exit 1
throw "Release tag version $version does not match the built DBX package version $appVersion"
}
./.github/scripts/vsign-sign.ps1 -FilePath $exePath
./.github/scripts/assert-authenticode.ps1 -Path $exePath
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
Copy-Item $exePath (Join-Path $portableDir "DBX.exe") -Force
Copy-Item "LICENSE" (Join-Path $portableDir "LICENSE") -Force
@@ -313,51 +523,41 @@ jobs:
$signatureName = "${zipName}.sig"
if (!(Test-Path $signatureName)) {
Write-Error "Missing portable update signature: $signatureName"
exit 1
throw "Missing portable update signature: $signatureName"
}
gh release upload "${env:GITHUB_REF_NAME}" $zipName $signatureName --repo "${env:GITHUB_REPOSITORY}" --clobber
- name: Upload Windows WebView2 offline installer
if: matrix.arch == 'x64' || matrix.arch == 'arm64'
- name: Show sccache stats
if: always()
continue-on-error: true
shell: bash
run: ${SCCACHE_PATH} --show-stats
- name: Remove temporary VSign files
if: always()
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
$arch = "${{ matrix.arch }}"
$bundleDir = "target/${{ matrix.target }}/release/bundle/nsis"
$offlineName = "DBX_${version}_${arch}-webview2-offline-setup.exe"
$before = @{}
if (Test-Path $bundleDir) {
Get-ChildItem $bundleDir -Filter "*.exe" | ForEach-Object { $before[$_.FullName] = $_.LastWriteTimeUtc }
$workDir = if ([string]::IsNullOrWhiteSpace($env:VSIGN_WORK_DIR)) {
Join-Path $env:RUNNER_TEMP "vsign-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
} else {
$env:VSIGN_WORK_DIR
}
pnpm tauri bundle --bundles nsis --target ${{ matrix.target }} --config src-tauri/tauri.webview2-offline.conf.json
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
Where-Object { !$before.ContainsKey($_.FullName) -or $_.LastWriteTimeUtc -gt $before[$_.FullName] } |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (!$installer) {
Write-Error "Missing WebView2 offline installer in ${bundleDir}"
exit 1
if (Test-Path -LiteralPath $workDir) {
Remove-Item -LiteralPath $workDir -Recurse -Force
}
Copy-Item $installer.FullName $offlineName -Force
gh release upload "${env:GITHUB_REF_NAME}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber
build-windows-7-offline:
needs: release-ready
runs-on: windows-2022
environment: release-signing
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
VSIGN_CERT_HASH: ${{ vars.VSIGN_CERT_HASH }}
VSIGN_OPERATOR_SHA1: ${{ vars.VSIGN_OPERATOR_SHA1 }}
VSIGN_SERVER: ${{ vars.VSIGN_SERVER }}
VSIGN_TOOL_SHA256: ${{ vars.VSIGN_TOOL_SHA256 }}
steps:
- uses: actions/checkout@v5
@@ -378,35 +578,6 @@ jobs:
toolchain: nightly-2026-07-22
components: rust-src
# sccache must be set up before the WebView2 loader/runtime scripts: they
# run `cargo fetch` under the job-wide RUSTC_WRAPPER=sccache, so installing
# sccache only after them made cargo abort with "program not found".
- 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}"
} >> "$GITHUB_ENV"
- name: Prepare Win7-compatible WebView2 loader
shell: pwsh
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
@@ -441,10 +612,19 @@ jobs:
shell: pwsh
run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
- name: Setup TrustAsia VSign
shell: pwsh
env:
VSIGN_OPERATOR_PFX_B64: ${{ secrets.VSIGN_OPERATOR_PFX_B64 }}
VSIGN_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
run: ./.github/scripts/setup-vsign.ps1
- name: Bundle and upload Windows 7 fixed-runtime installer
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
run: |
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
$bundleDir = "target/x86_64-win7-windows-msvc/release/bundle/nsis"
@@ -457,7 +637,9 @@ jobs:
exit 1
}
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc `
--config src-tauri/tauri.webview2-win7-fixed.conf.json `
--config src-tauri/tauri.vsign.conf.json
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
Sort-Object LastWriteTimeUtc -Descending |
@@ -468,15 +650,23 @@ jobs:
}
./.github/scripts/assert-win7-installer-content.ps1 -InstallerPath $installer.FullName
./.github/scripts/assert-authenticode.ps1 -Path $installer.FullName
Copy-Item $installer.FullName $offlineName -Force
gh release upload "${env:GITHUB_REF_NAME}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber
- name: Show sccache stats
- name: Remove temporary VSign files
if: always()
continue-on-error: true
shell: bash
run: ${SCCACHE_PATH} --show-stats
shell: pwsh
run: |
$workDir = if ([string]::IsNullOrWhiteSpace($env:VSIGN_WORK_DIR)) {
Join-Path $env:RUNNER_TEMP "vsign-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
} else {
$env:VSIGN_WORK_DIR
}
if (Test-Path -LiteralPath $workDir) {
Remove-Item -LiteralPath $workDir -Recurse -Force
}
static-browser:
# Fully static musl builds of the browser (dbx-web) variant. No glibc
@@ -535,7 +725,7 @@ jobs:
RUSTFLAGS: ${{ matrix.rustflags }}
run: |
cargo zigbuild --release -p dbx-web --target ${{ matrix.target }} \
--no-default-features --features "duckdb-sidecar,mq-admin"
--no-default-features --features "duckdb-sidecar,dynamodb,mq-admin,dbx-core/sqlite-bundled"
- name: Package static browser runtime
env:
@@ -581,7 +771,7 @@ jobs:
--repo "${GITHUB_REPOSITORY}" --clobber
cleanup-release-signatures:
needs: [build, build-windows-7-offline]
needs: [build, build-windows, build-windows-7-offline]
runs-on: ubuntu-latest
steps:
- name: Remove standalone updater signature assets
+20 -2
View File
@@ -31,11 +31,29 @@ jobs:
pip install awscli --quiet
aws s3 cp releases-cn.json "s3://${R2_BUCKET_NAME}/changelog/releases-cn.json" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--content-type application/json
--content-type application/json \
--cache-control "public, max-age=300, stale-while-revalidate=86400"
aws s3 cp index-cn.json "s3://${R2_BUCKET_NAME}/changelog/index-cn.json" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--content-type application/json \
--cache-control "public, max-age=300, stale-while-revalidate=86400"
aws s3 sync releases-cn/ "s3://${R2_BUCKET_NAME}/changelog/releases-cn/" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--delete --content-type application/json \
--cache-control "public, max-age=86400, stale-while-revalidate=604800"
if [ -f releases-en.json ]; then
aws s3 cp releases-en.json "s3://${R2_BUCKET_NAME}/changelog/releases-en.json" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--content-type application/json
--content-type application/json \
--cache-control "public, max-age=300, stale-while-revalidate=86400"
aws s3 cp index-en.json "s3://${R2_BUCKET_NAME}/changelog/index-en.json" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--content-type application/json \
--cache-control "public, max-age=300, stale-while-revalidate=86400"
aws s3 sync releases-en/ "s3://${R2_BUCKET_NAME}/changelog/releases-en/" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--delete --content-type application/json \
--cache-control "public, max-age=86400, stale-while-revalidate=604800"
fi
if [ -f latest-en.json ]; then
aws s3 cp latest-en.json "s3://${R2_BUCKET_NAME}/changelog/latest-en.json" \
@@ -0,0 +1,191 @@
name: VSign Operator Bootstrap
on:
push:
tags:
- "trustasia-vsign-init-*"
permissions:
contents: read
jobs:
bootstrap-operator:
runs-on: windows-2022
environment: release-signing
timeout-minutes: 15
steps:
- name: Download and verify VSign CLI
shell: pwsh
env:
VSIGN_CLI_URL: https://github.com/g5wsg/vsign-github-test1/releases/download/vsign-cli-v1/ssigncode.exe
VSIGN_CLI_SHA256: 25A680C653CF0E89BFD53D0A2399F4D748E33DE6644E15B0DAB29974765BFA40
run: |
$toolPath = Join-Path $env:RUNNER_TEMP "ssigncode.exe"
Invoke-WebRequest -Uri $env:VSIGN_CLI_URL -OutFile $toolPath
$actualHash = (Get-FileHash -LiteralPath $toolPath -Algorithm SHA256).Hash
if ($actualHash -ne $env:VSIGN_CLI_SHA256) {
throw "VSign CLI SHA256 mismatch. Expected $env:VSIGN_CLI_SHA256, got $actualHash"
}
"VSIGN_CLI=$toolPath" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
Write-Host "VSign CLI SHA256: $actualHash"
- name: Inspect VSign CLI commands
if: startsWith(github.ref_name, 'trustasia-vsign-init-probe-')
shell: pwsh
run: |
& $env:VSIGN_CLI version
if ($LASTEXITCODE -ne 0) {
throw "VSign CLI version command failed with exit code $LASTEXITCODE"
}
& $env:VSIGN_CLI install --help
if ($LASTEXITCODE -ne 0) {
throw "VSign CLI install help failed with exit code $LASTEXITCODE"
}
& $env:VSIGN_CLI vsign --help
if ($LASTEXITCODE -ne 0) {
throw "VSign CLI vsign help failed with exit code $LASTEXITCODE"
}
- name: Install and export VSign operator
if: startsWith(github.ref_name, 'trustasia-vsign-init-run-')
shell: pwsh
env:
VSIGN_SERVER: ${{ vars.VSIGN_SERVER }}
VSIGN_OPERATOR_TOKEN: ${{ secrets.VSIGN_OPERATOR_TOKEN }}
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
VSIGN_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
VSIGN_ARTIFACT_PWD: ${{ secrets.VSIGN_ARTIFACT_PWD }}
run: |
$requiredValues = @{
VSIGN_SERVER = $env:VSIGN_SERVER
VSIGN_OPERATOR_TOKEN = $env:VSIGN_OPERATOR_TOKEN
VSIGN_KEY_PIN = $env:VSIGN_KEY_PIN
VSIGN_OPERATOR_PWD = $env:VSIGN_OPERATOR_PWD
VSIGN_ARTIFACT_PWD = $env:VSIGN_ARTIFACT_PWD
}
foreach ($entry in $requiredValues.GetEnumerator()) {
if ([string]::IsNullOrWhiteSpace($entry.Value)) {
throw "$($entry.Key) is not configured"
}
}
$operatorDir = Join-Path $env:RUNNER_TEMP "vsign-operator"
$operatorFile = Join-Path $operatorDir "dbx-github-ci@ssigncode.pfx"
$bundleDir = Join-Path $env:RUNNER_TEMP "vsign-bundle"
$bundlePath = Join-Path $env:RUNNER_TEMP "vsign-operator-bundle.7z"
New-Item -ItemType Directory -Force -Path $operatorDir, $bundleDir | Out-Null
$env:SSIGNCODE_OPERATOR_STORE = "file"
$env:SSIGNCODE_OPERATOR_DIR = $operatorDir
$env:SSIGNCODE_OPERATOR_FILE = $operatorFile
$env:SSIGNCODE_OPERATOR_PWD = $env:VSIGN_OPERATOR_PWD
& $env:VSIGN_CLI install `
-s $env:VSIGN_SERVER `
--auth $env:VSIGN_OPERATOR_TOKEN `
--vsign `
--key_pin $env:VSIGN_KEY_PIN
if ($LASTEXITCODE -ne 0) {
throw "VSign operator installation failed with exit code $LASTEXITCODE"
}
$pfxCandidates = @(
Get-ChildItem -LiteralPath $operatorDir -Filter "*.pfx" -File -Recurse -ErrorAction SilentlyContinue
Get-ChildItem -LiteralPath $env:USERPROFILE -Filter "*@ssigncode.pfx" -File -Recurse -ErrorAction SilentlyContinue
) | Sort-Object LastWriteTimeUtc -Descending -Unique
if ($pfxCandidates.Count -eq 0) {
throw "VSign operator installation completed but no PFX file was found"
}
$sourcePfx = $pfxCandidates[0]
$flags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable `
-bor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet
$operatorCert = $null
foreach ($candidatePassword in @($env:VSIGN_OPERATOR_PWD, $env:VSIGN_KEY_PIN, "")) {
try {
$operatorCert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new(
$sourcePfx.FullName,
$candidatePassword,
$flags
)
if ($operatorCert.HasPrivateKey) {
break
}
$operatorCert.Dispose()
$operatorCert = $null
}
catch {
$operatorCert = $null
}
}
if ($null -eq $operatorCert) {
throw "The generated operator PFX could not be opened with the configured passwords"
}
try {
$exportedPfx = Join-Path $bundleDir "dbx-github-ci@ssigncode.pfx"
$pfxBytes = $operatorCert.Export(
[System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx,
$env:VSIGN_OPERATOR_PWD
)
[IO.File]::WriteAllBytes($exportedPfx, $pfxBytes)
$thumbprint = $operatorCert.Thumbprint.Replace(" ", "").ToUpperInvariant()
$metadata = [ordered]@{
operator = "dbx-github-ci"
sha1 = $thumbprint
expires = $operatorCert.NotAfter.ToUniversalTime().ToString("o")
pfx_sha256 = (Get-FileHash -LiteralPath $exportedPfx -Algorithm SHA256).Hash
run_id = $env:GITHUB_RUN_ID
}
$metadata | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $bundleDir "metadata.json") -Encoding utf8
$sevenZip = "C:\Program Files\7-Zip\7z.exe"
if (!(Test-Path -LiteralPath $sevenZip -PathType Leaf)) {
throw "7-Zip was not found on the Windows runner"
}
& $sevenZip a -t7z "-p$env:VSIGN_ARTIFACT_PWD" -mhe=on $bundlePath (Join-Path $bundleDir "*")
if ($LASTEXITCODE -ne 0) {
throw "Encrypted operator bundle creation failed with exit code $LASTEXITCODE"
}
"VSIGN_OPERATOR_SHA1=$thumbprint" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
"VSIGN_BUNDLE_PATH=$bundlePath" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
Write-Host "Operator PFX created and encrypted for retrieval."
Write-Host "Operator certificate SHA1: $thumbprint"
}
finally {
$operatorCert.Dispose()
}
- name: Upload encrypted operator bundle
if: startsWith(github.ref_name, 'trustasia-vsign-init-run-')
uses: actions/upload-artifact@v4
with:
name: vsign-operator-${{ github.run_id }}
path: ${{ env.VSIGN_BUNDLE_PATH }}
if-no-files-found: error
retention-days: 1
- name: Remove temporary VSign files
if: always()
shell: pwsh
run: |
foreach ($path in @(
(Join-Path $env:RUNNER_TEMP "ssigncode.exe"),
(Join-Path $env:RUNNER_TEMP "vsign-operator"),
(Join-Path $env:RUNNER_TEMP "vsign-bundle"),
(Join-Path $env:RUNNER_TEMP "vsign-operator-bundle.7z")
)) {
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Recurse -Force
}
}
+15 -1
View File
@@ -1,7 +1,8 @@
# Dependencies
node_modules/
node_modules
.claude/
.codex/
.cortexkit/
.workbuddy/
.superpowers/
.trellis/
@@ -15,10 +16,13 @@ CLAUDE.md
dist/
coverage/
/target/
vendor/wry/target/
vendor/wry/Cargo.lock
src-tauri/webview2-fixed-runtime/
plugins/jdbc/target/
plugins/jdbc/dependency-reduced-pom.xml
plugins/jdbc/lib/*.jar
agents/drivers/duckdb/target/
plugins/jdbc/.gradle/
plugins/jdbc/build/
plugins/jdbc/dist/
@@ -29,6 +33,10 @@ docs/superpowers/
/docs/aegis/
src-tauri/*.dylib
# Python
__pycache__/
*.py[cod]
# OS files
.DS_Store
Thumbs.db
@@ -42,6 +50,7 @@ Thumbs.db
# GitHub (keep workflows)
.github/*
!.github/CODEOWNERS
!.github/workflows/
!.github/scripts/
!.github/ISSUE_TEMPLATE/
@@ -54,6 +63,11 @@ Thumbs.db
# Temp
tmp/
# AI background-run dev artifacts (never commit)
.dlp-quarantine/
outputs/
target-ai-write-confirmation/
# Code review tool artifacts
review_diff.json
review_classified.json
Generated
+638 -59
View File
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli"]
exclude = ["vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/wry"]
exclude = ["vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/wry"]
[patch.crates-io]
# Tauri 2.11 uses ctor 0.8, which excludes Rust's win7 vendor. This vendors
@@ -17,14 +17,22 @@ pageant = { path = "vendor/pageant" }
# bundled Fixed Runtime path explicitly so Windows 7 does not fall back to an
# unavailable system Runtime. The vendored copy also intercepts the WebView2
# F6 "Focus Next Pane" accelerator to prevent a black screen on frameless
# Overlay-titlebar windows (see vendor/wry/src/webview2/mod.rs).
# Overlay-titlebar windows, and registers a ProcessFailed handler that
# auto-recovers the webview when the renderer process fails while the host
# process stays alive (black screen after an overnight idle session, see
# vendor/wry/src/webview2/mod.rs). Recovery is layered by process kind
# (browser exit is fatal, GPU exits are self-healed by WebView2) and bounded
# by a cooldown plus a maximum reload budget.
wry = { path = "vendor/wry" }
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
# Temporary compatibility patch for MySQL accounts using the deprecated
# sha256_password auth plugin. Remove after upstream support lands.
mysql_async = { git = "https://github.com/t8y2/mysql_async.git", rev = "b615bb0b10b5975182a4aa069cabc3d09021f6ab" }
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
# Temporary compatibility patches for deprecated sha256_password accounts and
# 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.
tiberius = { path = "vendor/tiberius" }
[profile.release]
panic = "abort"
+8 -3
View File
@@ -61,6 +61,10 @@ install:
docs-install:
cd docs && $(PNPM) install --frozen-lockfile
ifeq ($(OS),Windows_NT)
check-tauri-dev-port:
@powershell -NoProfile -Command "if (Get-NetTCPConnection -LocalPort $(TAURI_DEV_PORT) -State Listen -ErrorAction SilentlyContinue) { Write-Host 'Port $(TAURI_DEV_PORT) is already in use. DBX Tauri dev requires http://localhost:$(TAURI_DEV_PORT).'; Write-Host ''; Get-NetTCPConnection -LocalPort $(TAURI_DEV_PORT) -State Listen -ErrorAction SilentlyContinue | Format-Table LocalAddress,LocalPort,OwningProcess -AutoSize; Write-Host 'Stop the process above, then run make dev again.'; exit 1 }"
else
check-tauri-dev-port:
@if lsof -nP -iTCP:$(TAURI_DEV_PORT) -sTCP:LISTEN >/dev/null 2>&1; then \
echo "Port $(TAURI_DEV_PORT) is already in use. DBX Tauri dev requires http://localhost:$(TAURI_DEV_PORT)."; \
@@ -70,12 +74,13 @@ check-tauri-dev-port:
echo "Stop the process above, then run make dev again. Example: kill <PID>"; \
exit 1; \
fi
endif
dev: node_modules/.modules.yaml check-tauri-dev-port
$(PNPM) dev:tauri
dev-fast: node_modules/.modules.yaml check-tauri-dev-port
$(PNPM) tauri dev -- --no-default-features --features duckdb-sidecar
$(PNPM) tauri dev -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled
dev-web: node_modules/.modules.yaml
$(PNPM) dev:web
@@ -105,10 +110,10 @@ test: node_modules/.modules.yaml
$(PNPM) test
cargo-check-fast:
cargo check --no-default-features
cargo check --no-default-features --features sqlite-bundled
cargo-test-fast:
cargo test --no-default-features
cargo test --no-default-features --features sqlite-bundled
db-list:
@$(PNPM) db:env -- list
+91 -106
View File
@@ -1,12 +1,12 @@
<div align="center">
<p style="font-size: 18px; white-space: nowrap;"><strong>70+ 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 20 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
<p>
<img src="https://dl.dbxio.com/assets/readme-hero-20260806.png" alt="DBX screenshot" width="820" />
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX screenshot" width="820" />
</p>
<p>
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/github/downloads/t8y2/dbx/total?style=for-the-badge&color=blue" /></a>
<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://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="Join Discord" /></a>
@@ -27,18 +27,20 @@
<img src="https://img.shields.io/badge/SQL%20Server-CC2927?logo=microsoftsqlserver&logoColor=white" />
<img src="https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white" />
<img src="https://img.shields.io/badge/Elasticsearch-005571?logo=elasticsearch&logoColor=white" />
<img src="https://img.shields.io/badge/Meilisearch-FF5CAA?logo=meilisearch&logoColor=white" />
<img src="https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white" />
<img src="https://img.shields.io/badge/TiDB-DC150B?logo=tidb&logoColor=white" />
<img src="https://img.shields.io/badge/Doris-0052CC?logoColor=white" />
<img src="https://img.shields.io/badge/SelectDB-22C1C3?logoColor=white" />
<img src="https://img.shields.io/badge/StarRocks-5C2D91?logoColor=white" />
<img src="https://img.shields.io/badge/Redshift-8C4FFF?logo=amazonredshift&logoColor=white" />
<img src="https://img.shields.io/badge/Cloud%20Spanner-4285F4?logo=googlecloudspanner&logoColor=white" />
<img src="https://img.shields.io/badge/DM-3857FF?logoColor=white" />
<img src="https://img.shields.io/badge/OceanBase-006AFF?logoColor=white" />
<img src="https://img.shields.io/badge/openGauss-2B7BD9?logoColor=white" />
<img src="https://img.shields.io/badge/GaussDB-E60012?logoColor=white" />
<img src="https://img.shields.io/badge/KWDB-1c60e0?logoColor=white" />
<img src="https://img.shields.io/badge/KingBase-003B8E?logoColor=white" />
<img src="https://img.shields.io/badge/KingbaseES-003B8E?logoColor=white" />
<img src="https://img.shields.io/badge/TDengine-2F6FFF?logoColor=white" />
<img src="https://img.shields.io/badge/CockroachDB-6933FF?logoColor=white" />
<img src="https://img.shields.io/badge/InfluxDB-d30971?logo=influxdb&logoColor=white" />
@@ -61,6 +63,78 @@
</p>
</div>
## Sponsors & Partners
<table>
<tr>
<td align="center" valign="middle" width="220">
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/rainyun-dark.png" />
<img src="https://www.rainyun.com/img/logo.d193755d.png" alt="RainYun" height="48" />
</picture>
</a>
</td>
<td>
RainYun is a cloud service provider offering cloud servers, physical servers, game hosting, and developer-friendly infrastructure services.
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">Visit RainYun</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="220">
<a href="https://www.qiniu.com/" target="_blank">
<img src="https://www-static.qbox.me/_next/static/media/logo.0fc18feaa621d2068a7180631f742256.jpg" alt="Qiniu Cloud" height="64" />
</a>
</td>
<td>
Qiniu Cloud provides DBX with object storage, CDN, and other cloud infrastructure resources.
<a href="https://www.qiniu.com/" target="_blank">Visit Qiniu Cloud</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="220">
<a href="https://easysearch.cn" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/easysearch-dark.png" />
<img src="docs/public/sponsors/easysearch.png" alt="Easysearch" width="150" />
</picture>
</a>
</td>
<td>
Easysearch is an enterprise-grade distributed search engine compatible with Elasticsearch APIs, combining full-text, vector, geospatial search, real-time analytics, and AI capabilities in one platform.
<a href="https://easysearch.cn" target="_blank">Visit Easysearch</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="220">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/atlas-dark.svg" />
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
</picture>
</a>
</td>
<td>
Atlas Cloud gives developers one unified API for 400+ AI models across chat, image, video, and audio.
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">Visit Atlas Cloud</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="220">
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/trustasia-dark.svg" />
<img src="docs/public/sponsors/trustasia.png" alt="TrustAsia" height="44" />
</picture>
</a>
</td>
<td>
TrustAsia provides cloud-based code signing service for DBX, enabling trusted software through automated CI/CD builds.
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">Visit TrustAsia</a>
</td>
</tr>
</table>
## Why DBX?
<table>
@@ -88,9 +162,9 @@
## Features
### 70+ Databases, One Tool
### 90+ Databases, One Tool
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, 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, 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 ~20 MB app. No bundled Chromium.
### Query Editor
@@ -388,122 +462,33 @@ The installer will be in `src-tauri/target/release/bundle/`.
- [Web API reference](docs/content/docs/web-api.mdx) — HTTP API for Docker/Web deployments
- [Examples](examples/) — CLI, MCP, Docker, and API samples
## Community
<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>
[![LINUX DO](https://img.shields.io/badge/LINUX%20DO-Community-blue)](https://linux.do)
## Support DBX
DBX is free and open source, but ongoing maintenance, database compatibility testing, infrastructure, and release work require sustained time and resources.
### Sponsors & Partners
<table>
<tr>
<td align="center" width="180">
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
<img src="https://www.rainyun.com/img/logo.d193755d.png" alt="RainYun" height="48" />
</a>
</td>
<td>
RainYun is a cloud service provider offering cloud servers, physical servers, game hosting, and developer-friendly infrastructure services.
</td>
<td align="center" width="140">
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">Visit RainYun</a>
</td>
</tr>
<tr>
<td align="center" width="180">
<a href="https://www.qiniu.com/" target="_blank">
<img src="https://www-static.qbox.me/_next/static/media/logo.0fc18feaa621d2068a7180631f742256.jpg" alt="Qiniu Cloud" height="64" />
</a>
</td>
<td>
Qiniu Cloud provides DBX with object storage, CDN, and other cloud infrastructure resources.
</td>
<td align="center" width="140">
<a href="https://www.qiniu.com/" target="_blank">Visit Qiniu Cloud</a>
</td>
</tr>
<tr>
<td align="center" width="180">
<a href="https://easysearch.cn" target="_blank">
<img src="docs/public/sponsors/easysearch.png" alt="Easysearch" width="150" />
</a>
</td>
<td>
Easysearch is an enterprise-grade distributed search engine compatible with Elasticsearch APIs, combining full-text, vector, geospatial search, real-time analytics, and AI capabilities in one platform.
</td>
<td align="center" width="140">
<a href="https://easysearch.cn" target="_blank">Visit Easysearch</a>
</td>
</tr>
<tr>
<td align="center" width="180">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
</a>
</td>
<td>
Atlas Cloud gives developers one unified API for 400+ AI models across chat, image, video, and audio.
</td>
<td align="center" width="140">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">Visit Atlas Cloud</a>
</td>
</tr>
</table>
- [Support DBX](https://my.feishu.cn/wiki/WMTkwdATDiiu4rk14JMcoyhTnoh) — voluntary donations via WeChat or Alipay
- [Sponsors & Partners](https://my.feishu.cn/wiki/CgOWwwTzzify79k9Oq8cXpUNn6e) — sponsorship, infrastructure, tools, and community collaboration
## FAQ
<details>
<summary><strong>Is DBX free?</strong></summary>
Yes. DBX is open source under Apache-2.0. All features are free.
</details>
<details>
<summary><strong>Does DBX phone home?</strong></summary>
No. DBX does not collect telemetry. The auto-update feature checks GitHub Releases for new versions — you can disable it in settings.
</details>
<details>
<summary><strong>Can I use DBX without an internet connection?</strong></summary>
Yes. The desktop app works fully offline. For air-gapped driver installs, download offline driver packages from the [Offline Drivers page](https://dbxio.com/en/drivers) on an internet-connected machine, transfer them to the offline machine, then import them in DBX from Settings > Driver Manager. AI features need network access to the model endpoint (or a local model via Ollama).
</details>
<details>
<summary><strong>How is DBX different from DBeaver / TablePlus / Beekeeper Studio?</strong></summary>
DBX is 20 MB with no runtime dependencies for its native database features (no system Java or Python required). AI is built into the application, while MCP is provided as a separately installed Rust companion package or native binary. It supports 70+ databases across desktop, Docker, and web from a shared Rust core.
</details>
<details>
<summary><strong>What databases are supported?</strong></summary>
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, and more. Agent-based profiles extend support to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and custom JDBC connections. Message queue admin (Pulsar, Kafka, RocketMQ) is also supported.
</details>
<details>
<summary><strong>How do I report a bug or request a feature?</strong></summary>
Open an issue on <a href="https://github.com/t8y2/dbx/issues">GitHub Issues</a>.
</details>
## Contributors
<a href="https://github.com/t8y2/dbx/graphs/contributors">
<img src="https://contrib.rocks/image?repo=t8y2/dbx" />
</a>
## Community
<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>
[![LINUX DO](https://img.shields.io/badge/LINUX%20DO-Community-blue)](https://linux.do)
## Star History
<a href="https://www.star-history.com/?repos=t8y2%2Fdbx&type=date&legend=top-left">
<a href="https://star-history.dera.page/#t8y2/dbx&type=date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&legend=top-left" />
</picture>
</a>
+86 -69
View File
@@ -1,12 +1,12 @@
<div align="center">
<p style="font-size: 18px; white-space: nowrap;"><strong>20 MB 驾驭 70+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
<p style="font-size: 18px; white-space: nowrap;"><strong>20 MB 驾驭 90+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
<p>
<img src="https://dl.dbxio.com/assets/readme-hero-20260806.png" alt="DBX 截图" width="820" />
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX 截图" width="820" />
</p>
<p>
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/github/downloads/t8y2/dbx/total?style=for-the-badge&color=blue" /></a>
<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://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="加入 Discord" /></a>
@@ -27,18 +27,20 @@
<img src="https://img.shields.io/badge/SQL%20Server-CC2927?logo=microsoftsqlserver&logoColor=white" />
<img src="https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white" />
<img src="https://img.shields.io/badge/Elasticsearch-005571?logo=elasticsearch&logoColor=white" />
<img src="https://img.shields.io/badge/Meilisearch-FF5CAA?logo=meilisearch&logoColor=white" />
<img src="https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white" />
<img src="https://img.shields.io/badge/TiDB-DC150B?logo=tidb&logoColor=white" />
<img src="https://img.shields.io/badge/Doris-0052CC?logoColor=white" />
<img src="https://img.shields.io/badge/SelectDB-22C1C3?logoColor=white" />
<img src="https://img.shields.io/badge/StarRocks-5C2D91?logoColor=white" />
<img src="https://img.shields.io/badge/Redshift-8C4FFF?logo=amazonredshift&logoColor=white" />
<img src="https://img.shields.io/badge/Cloud%20Spanner-4285F4?logo=googlecloudspanner&logoColor=white" />
<img src="https://img.shields.io/badge/DM-3857FF?logoColor=white" />
<img src="https://img.shields.io/badge/OceanBase-006AFF?logoColor=white" />
<img src="https://img.shields.io/badge/openGauss-2B7BD9?logoColor=white" />
<img src="https://img.shields.io/badge/GaussDB-E60012?logoColor=white" />
<img src="https://img.shields.io/badge/KWDB-1c60e0?logoColor=white" />
<img src="https://img.shields.io/badge/KingBase-003B8E?logoColor=white" />
<img src="https://img.shields.io/badge/KingbaseES-003B8E?logoColor=white" />
<img src="https://img.shields.io/badge/TDengine-2F6FFF?logoColor=white" />
<img src="https://img.shields.io/badge/CockroachDB-6933FF?logoColor=white" />
<img src="https://img.shields.io/badge/InfluxDB-d30971?logo=influxdb&logoColor=white" />
@@ -61,6 +63,78 @@
</p>
</div>
## 赞助商与合作伙伴
<table>
<tr>
<td align="center" valign="middle" width="220">
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/rainyun-dark.png" />
<img src="https://www.rainyun.com/img/logo.d193755d.png" alt="雨云" height="48" />
</picture>
</a>
</td>
<td>
雨云是面向开发者和站长的云服务提供商,提供云服务器、物理服务器、游戏云和配套基础设施服务。
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">访问雨云</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="220">
<a href="https://www.qiniu.com/" target="_blank">
<img src="https://www-static.qbox.me/_next/static/media/logo.0fc18feaa621d2068a7180631f742256.jpg" alt="七牛云" height="64" />
</a>
</td>
<td>
七牛云为 DBX 提供对象存储、CDN 等云基础设施资源支持。
<a href="https://www.qiniu.com/" target="_blank">访问七牛云</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="220">
<a href="https://easysearch.cn" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/easysearch-dark.png" />
<img src="docs/public/sponsors/easysearch.png" alt="Easysearch" width="150" />
</picture>
</a>
</td>
<td>
Easysearch 是一款企业级分布式搜索引擎,兼容 ES API、融合全文检索、向量检索、地理空间位置检索、实时分析与 AI 能力,为企业提供统一的数据检索与智能分析基础设施。
<a href="https://easysearch.cn" target="_blank">访问 Easysearch</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="220">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/atlas-dark.svg" />
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
</picture>
</a>
</td>
<td>
Atlas Cloud 为开发者提供统一的多模态 AI API,可通过一个接口访问聊天、图像、视频和音频等 400+ 模型。
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">访问 Atlas Cloud</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="220">
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/trustasia-dark.svg" />
<img src="docs/public/sponsors/trustasia.png" alt="TrustAsia" height="44" />
</picture>
</a>
</td>
<td>
由 TrustAsia 提供代码签名云签服务,实现 CICD 自动化构建可信软件。
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">访问 TrustAsia</a>
</td>
</tr>
</table>
## 为什么选择 DBX?
<table>
@@ -88,9 +162,9 @@
## 功能特性
### 70+ 种数据库,一个工具搞定
### 90+ 种数据库,一个工具搞定
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、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、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 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
### 查询编辑器
@@ -401,63 +475,6 @@ make package
DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础设施建设和版本发布都需要长期投入时间与资源。
### 赞助商与合作伙伴
<table>
<tr>
<td align="center" width="180">
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
<img src="https://www.rainyun.com/img/logo.d193755d.png" alt="雨云" height="48" />
</a>
</td>
<td>
雨云是面向开发者和站长的云服务提供商,提供云服务器、物理服务器、游戏云和配套基础设施服务。
</td>
<td align="center" width="140">
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">访问雨云</a>
</td>
</tr>
<tr>
<td align="center" width="180">
<a href="https://www.qiniu.com/" target="_blank">
<img src="https://www-static.qbox.me/_next/static/media/logo.0fc18feaa621d2068a7180631f742256.jpg" alt="七牛云" height="64" />
</a>
</td>
<td>
七牛云为 DBX 提供对象存储、CDN 等云基础设施资源支持。
</td>
<td align="center" width="140">
<a href="https://www.qiniu.com/" target="_blank">访问七牛云</a>
</td>
</tr>
<tr>
<td align="center" width="180">
<a href="https://easysearch.cn" target="_blank">
<img src="docs/public/sponsors/easysearch.png" alt="Easysearch" width="150" />
</a>
</td>
<td>
Easysearch 是一款企业级分布式搜索引擎,兼容 ES API、融合全文检索、向量检索、地理空间位置检索、实时分析与 AI 能力,为企业提供统一的数据检索与智能分析基础设施。
</td>
<td align="center" width="140">
<a href="https://easysearch.cn" target="_blank">访问 Easysearch</a>
</td>
</tr>
<tr>
<td align="center" width="180">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
</a>
</td>
<td>
Atlas Cloud 为开发者提供统一的多模态 AI API,可通过一个接口访问聊天、图像、视频和音频等 400+ 模型。
</td>
<td align="center" width="140">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">访问 Atlas Cloud</a>
</td>
</tr>
</table>
- [支持 DBX](https://my.feishu.cn/wiki/WMTkwdATDiiu4rk14JMcoyhTnoh) —— 通过微信或支付宝自愿捐助
- [赞助商与合作伙伴](https://my.feishu.cn/wiki/CgOWwwTzzify79k9Oq8cXpUNn6e) —— 支持基础设施、开发工具、服务或社区合作
@@ -481,12 +498,12 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
<details>
<summary><strong>DBX 和 DBeaver / TablePlus / Beekeeper Studio 有什么区别?</strong></summary>
DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 70+ 数据库、桌面端、Docker 和 Web。
DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 90+ 数据库、桌面端、Docker 和 Web。
</details>
<details>
<summary><strong>支持哪些数据库?</strong></summary>
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
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 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
</details>
<details>
@@ -502,11 +519,11 @@ MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHo
## Star History
<a href="https://www.star-history.com/?repos=t8y2%2Fdbx&type=date&legend=top-left">
<a href="https://star-history.dera.page/#t8y2/dbx&type=date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&legend=top-left" />
</picture>
</a>
+1 -1
View File
@@ -3,7 +3,7 @@
!drivers/uxdb/libs/uxdbjdbc-2.1.2.3p.jre8.jar
gradle.properties
build/
*/bin/
bin/
.gradle/
.DS_Store
openspec
+12 -6
View File
@@ -12,7 +12,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
|-------|----------|-------------|
| access | Microsoft Access | UCanAccess |
| dameng | 达梦 DM8 | DM JDBC |
| kingbase | 人大金仓 KingbaseES | gokb Go native agent |
| kingbase | 金仓KingbaseES | gokb Go native agent |
| vastbase | Vastbase | openGauss Go native agent |
| uxdb | UXDB | UXDB JDBC |
| goldendb | GoldenDB | MySQL Connector/J |
@@ -36,20 +36,24 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
| neo4j | Neo4j | Official Neo4j Go Driver native agent |
| cassandra | Apache Cassandra 2.1+ | Apache cassandra-gocql-driver native agent |
| bigquery | Google BigQuery | BigQuery JDBC |
| spanner | Google Cloud Spanner | Google Cloud Spanner JDBC |
| kylin | Apache Kylin | Kylin JDBC |
| ignite | Apache Ignite | Ignite JDBC thin (ignite-core) |
| ignite3 | Apache Ignite 3 | Ignite 3 JDBC thin (ignite-jdbc) |
| sundb | SunDB | SunDB JDBC |
| tdengine | TDengine 2.4+ | taos-connector-rust native WebSocket agent |
| yashandb | 崖山 YashanDB | YashanDB JDBC |
| xugu | 虚谷 XuguDB | XuguDB Go native agent |
| iotdb | Apache IoTDB | Apache IoTDB Go Client native agent |
| etcd | etcd | jetcd |
| zookeeper | Apache ZooKeeper | Apache Curator |
| zookeeper | Apache ZooKeeper | go-zookeeper native agent |
| rabbitmq | RabbitMQ | amqp091-go native agent |
| rocketmq | Apache RocketMQ | rocketmq-admin-go native agent |
## Multi-JRE Support
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, `rabbitmq`, `rocketmq`, and `zookeeper`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
## JDBC Connection Pooling
@@ -75,7 +79,7 @@ Set `DBX_AGENT_JDBC_POOL_ENABLED=false` for a runtime-level compatibility fallba
For new agents, prefer a **native (Go or Rust) driver** over a Java/JDBC agent whenever a mature, license-compatible native driver is available. Native agents ship as a single self-contained executable with no JRE, which significantly reduces memory footprint and startup time — the JVM baseline that every Java agent pays even when idle is avoided entirely.
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/cassandra-go` (Apache cassandra-gocql-driver), `drivers/duckdb`, `drivers/hive-go` (native HS2), `drivers/iotdb` (Apache IoTDB Go Client), `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/vastbase-go` (openGauss connector), `drivers/tdengine` (taos-connector-rust), `drivers/xugu`, and `drivers/rabbitmq` (amqp091-go) as reference implementations. No JRE download or management is needed.
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/cassandra-go` (Apache cassandra-gocql-driver), `drivers/duckdb`, `drivers/hive-go` (native HS2), `drivers/iotdb` (Apache IoTDB Go Client), `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/vastbase-go` (openGauss connector), `drivers/tdengine` (taos-connector-rust), `drivers/xugu`, `drivers/rabbitmq` (amqp091-go), `drivers/rocketmq` (rocketmq-admin-go), and `drivers/zookeeper` (go-zookeeper) as reference implementations. No JRE download or management is needed.
- **Java/JDBC** — the default fallback when only a JDBC driver exists for the database, or when the native driver is immature or unmaintained. Most agents still fall in this category.
Native agents implement the same JSON-RPC contract and `versions.json` registration as Java agents; they ship an `agent` executable instead of `agent.jar`. If both native and Java source implementations exist for the same database, publish only the native artifact unless the Java variant has a separately registered compatibility profile, such as `oracle-legacy` / `oracle-10g`.
@@ -95,9 +99,11 @@ Requires JDK 21 (Gradle toolchain auto-downloads if needed).
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
(cd drivers/xugu && go build -o agent .)
(cd drivers/rabbitmq && go build -o agent .)
(cd drivers/rocketmq && go build -o agent .)
(cd drivers/zookeeper && go build -o agent .)
```
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/cassandra-go`, `drivers/duckdb`, `drivers/hive-go`, `drivers/iotdb`, `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/vastbase-go`, `drivers/tdengine`, `drivers/xugu`, and `drivers/rabbitmq`.
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/cassandra-go`, `drivers/duckdb`, `drivers/hive-go`, `drivers/iotdb`, `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/vastbase-go`, `drivers/tdengine`, `drivers/xugu`, `drivers/rabbitmq`, `drivers/rocketmq`, and `drivers/zookeeper`.
### Local DBX Runtime Test
@@ -111,7 +117,7 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
Restart DBX or disconnect and reconnect the database so the new agent process loads the replacement JAR.
Native agents such as `cassandra`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`.
Native agents such as `cassandra`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, `rabbitmq`, `rocketmq`, and `zookeeper` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`.
## Versioning
+12 -6
View File
@@ -12,7 +12,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
|-------|----------|-------------|
| access | Microsoft Access | UCanAccess |
| dameng | 达梦 DM8 | DM JDBC |
| kingbase | 人大金仓 KingbaseES | gokb Go 原生 agent |
| kingbase | 金仓KingbaseES | gokb Go 原生 agent |
| vastbase | Vastbase | openGauss Go 原生 agent |
| uxdb | 优炫 UXDB | UXDB JDBC |
| goldendb | GoldenDB | MySQL Connector/J |
@@ -36,20 +36,24 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
| neo4j | Neo4j | 官方 Neo4j Go Driver 原生 Agent |
| cassandra | Apache Cassandra 2.1+ | Apache cassandra-gocql-driver 原生 Agent |
| bigquery | Google BigQuery | BigQuery JDBC |
| spanner | Google Cloud Spanner | Google Cloud Spanner JDBC |
| kylin | Apache Kylin | Kylin JDBC |
| ignite | Apache Ignite | Ignite JDBC thin(ignite-core) |
| ignite3 | Apache Ignite 3 | Ignite 3 JDBC thin(ignite-jdbc) |
| sundb | SunDB | SunDB JDBC |
| tdengine | TDengine 2.4+ | taos-connector-rust 原生 WebSocket agent |
| yashandb | 崖山 YashanDB | YashanDB JDBC |
| xugu | 虚谷 XuguDB | XuguDB Go 原生 agent |
| iotdb | Apache IoTDB | Apache IoTDB Go Client 原生 Agent |
| etcd | etcd | jetcd |
| zookeeper | Apache ZooKeeper | Apache Curator |
| zookeeper | Apache ZooKeeper | go-zookeeper 原生 Agent |
| rabbitmq | RabbitMQ | amqp091-go 原生 agent |
| rocketmq | Apache RocketMQ | rocketmq-admin-go 原生 agent |
## 多 JRE 支持
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu`、`rabbitmq`、`rocketmq` 和 `zookeeper`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
## JDBC 连接池
@@ -75,7 +79,7 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
对于新 agent,只要存在成熟、许可证兼容的原生驱动,优先选择**原生(Go 或 Rust)驱动**而非 Java/JDBC agent。原生 agent 以单一自包含可执行文件发布,无需 JRE,可显著降低内存占用和启动时间 —— 完全避开 Java agent 即便空闲也要付出的 JVM 基线开销。
- **原生(Go/Rust)** —— 存在可用原生驱动时首选。参考 `drivers/cassandra-go`(Apache cassandra-gocql-driver)、`drivers/duckdb`、`drivers/hive-go`(原生 HS2)、`drivers/iotdb`(Apache IoTDB Go Client)、`drivers/oracle-go`(go-ora)、`drivers/kingbase-go`(gokb)、`drivers/vastbase-go`(openGauss connector)、`drivers/tdengine`(taos-connector-rust)、`drivers/xugu` 和 `drivers/rabbitmq`(amqp091-go)。无需 JRE 下载与管理。
- **原生(Go/Rust)** —— 存在可用原生驱动时首选。参考 `drivers/cassandra-go`(Apache cassandra-gocql-driver)、`drivers/duckdb`、`drivers/hive-go`(原生 HS2)、`drivers/iotdb`(Apache IoTDB Go Client)、`drivers/oracle-go`(go-ora)、`drivers/kingbase-go`(gokb)、`drivers/vastbase-go`(openGauss connector)、`drivers/tdengine`(taos-connector-rust)、`drivers/xugu`、`drivers/rabbitmq`(amqp091-go)、`drivers/rocketmq`(rocketmq-admin-go)和 `drivers/zookeeper`(go-zookeeper)。无需 JRE 下载与管理。
- **Java/JDBC** —— 当某数据库只有 JDBC 驱动,或原生驱动不成熟、缺乏维护时的默认兜底方案。多数 agent 仍属此类。
原生 agent 实现与 Java agent 相同的 JSON-RPC 契约和 `versions.json` 登记;它发布的是 `agent` 可执行文件而非 `agent.jar`。若同一数据库同时保留原生和 Java 源码实现,默认只发布原生产物;只有 Java 变体以独立兼容配置登记时才同时发布,例如 `oracle-legacy` / `oracle-10g`。
@@ -95,9 +99,11 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
(cd drivers/xugu && go build -o agent .)
(cd drivers/rabbitmq && go build -o agent .)
(cd drivers/rocketmq && go build -o agent .)
(cd drivers/zookeeper && go build -o agent .)
```
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/cassandra-go`、`drivers/duckdb`、`drivers/hive-go`、`drivers/iotdb`、`drivers/oracle-go`、`drivers/kingbase-go`、`drivers/vastbase-go`、`drivers/tdengine`、`drivers/xugu` 和 `drivers/rabbitmq` 构建。
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/cassandra-go`、`drivers/duckdb`、`drivers/hive-go`、`drivers/iotdb`、`drivers/oracle-go`、`drivers/kingbase-go`、`drivers/vastbase-go`、`drivers/tdengine`、`drivers/xugu`、`drivers/rabbitmq`、`drivers/rocketmq` 和 `drivers/zookeeper` 构建。
### 本地 DBX 运行时测试
@@ -111,7 +117,7 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
重启 DBX 或断开重连数据库,使新 agent 进程加载替换后的 JAR。
`cassandra`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
`cassandra`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu`、`rabbitmq`、`rocketmq` 和 `zookeeper` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
## 版本管理
+3 -3
View File
@@ -3,12 +3,12 @@ plugins {
}
def infrastructureProjects = ['common', 'test-support'] as Set
def legacyStandaloneProjects = ['mongodb', 'kafka', 'rocketmq'] as Set
def legacyStandaloneProjects = ['mongodb', 'kafka'] as Set
def pooledJdbcProjects = [
'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo',
'informix', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
'snowflake', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
'ignite', 'ignite3', 'informix', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
'snowflake', 'spanner', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
'vertica', 'yashandb'
] as Set
def agentProjects = subprojects.findAll { !infrastructureProjects.contains(it.name) }
@@ -14,6 +14,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
private static final String GAUSSDB_COMPATIBILITY_SQL =
@@ -89,7 +90,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
return unchecked(() -> {
loadDriver(params);
try (Connection conn = openTestConnection(params)) {
boolean valid = conn != null && conn.isValid(5);
boolean valid = isConnectionValid(conn, 5);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", valid);
if (valid) {
@@ -274,6 +275,31 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
return true;
}
final boolean isConnectionValid(Connection connection, int timeoutSecs) {
if (connection == null) {
return false;
}
try {
if (connection.isClosed()) {
return false;
}
String validationQuery = connectionValidationQuery();
if (validationQuery == null || validationQuery.isBlank()) {
return connection.isValid(timeoutSecs);
}
try (Statement statement = connection.createStatement()) {
try {
statement.setQueryTimeout(timeoutSecs);
} catch (Exception | AbstractMethodError ignored) {
}
statement.execute(validationQuery);
return true;
}
} catch (Exception | AbstractMethodError ignored) {
return false;
}
}
final synchronized boolean usesConnectionPool() {
return poolRegistry != null;
}
@@ -457,7 +483,18 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
protected abstract String buildJdbcUrl(ConnectParams params);
protected Connection openConnection(ConnectParams params) throws Exception {
return DriverManager.getConnection(buildJdbcUrl(params), params.getUsername(), params.getPassword());
return DriverManager.getConnection(buildJdbcUrl(params), buildConnectionProperties(params));
}
protected Properties buildConnectionProperties(ConnectParams params) {
Properties properties = new Properties();
if (params.getUsername() != null) {
properties.setProperty("user", params.getUsername());
}
if (params.getPassword() != null) {
properties.setProperty("password", params.getPassword());
}
return properties;
}
protected Connection openTestConnection(ConnectParams params) throws Exception {
@@ -473,6 +510,10 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
protected void afterDisconnect() throws Exception {
}
protected String connectionValidationQuery() {
return null;
}
protected void beforeQueryExecution(Connection connection, int timeoutSecs) throws Exception {
}
@@ -574,6 +615,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
return registry.borrow(
identity,
JdbcSessionRole.from(params.getSessionRole()),
connectionValidationQuery(),
() -> openInitializedConnection(params)
);
}
@@ -638,6 +680,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
appendPoolIdentity(identity, "driverPath" + index, driverPaths.get(index));
}
}
appendPoolIdentity(identity, "driverProfile", params.getDriver_profile());
return identity.toString();
}
@@ -42,6 +42,9 @@ public final class AgentProtocol {
public static final String METHOD_GET_EXPLAIN_INFO = "get_explain_info";
public static final String METHOD_EXECUTE_BATCH = "execute_batch";
public static final String METHOD_EXECUTE_TRANSACTION = "execute_transaction";
public static final String METHOD_BEGIN_MANUAL_TRANSACTION = "begin_manual_transaction";
public static final String METHOD_COMMIT_MANUAL_TRANSACTION = "commit_manual_transaction";
public static final String METHOD_ROLLBACK_MANUAL_TRANSACTION = "rollback_manual_transaction";
public static final String METHOD_DISCONNECT = "disconnect";
public static final String METHOD_SHUTDOWN = "shutdown";
@@ -64,10 +67,12 @@ public final class AgentProtocol {
public static final String MONGO_METHOD_CLONE_COLLECTION = "clone_collection";
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_UPDATE_DOCUMENT = "update_document";
public static final String MONGO_METHOD_UPDATE_DOCUMENTS = "update_documents";
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";
public static final String KV_METHOD_LIST_PREFIX = "kv_list_prefix";
public static final String KV_METHOD_GET = "kv_get";
@@ -120,6 +125,8 @@ public final class AgentProtocol {
public static final String CAPABILITY_ETCD_AUTH = "etcd_auth";
public static final String CAPABILITY_MONGO_DROP_DATABASE = "mongo_drop_database";
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_MULTI_SESSION = "multi_session";
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
@@ -153,7 +160,9 @@ public final class AgentProtocol {
CAPABILITY_ETCD_LEASE,
CAPABILITY_ETCD_AUTH,
CAPABILITY_MONGO_DROP_DATABASE,
CAPABILITY_MONGO_CLONE_COLLECTION
CAPABILITY_MONGO_CLONE_COLLECTION,
CAPABILITY_MONGO_RUN_COMMAND,
CAPABILITY_MONGO_INSERT_DOCUMENTS
));
public static final List<String> MULTI_SESSION_CAPABILITIES;
@@ -194,6 +203,9 @@ public final class AgentProtocol {
METHOD_GET_EXPLAIN_INFO,
METHOD_EXECUTE_BATCH,
METHOD_EXECUTE_TRANSACTION,
METHOD_BEGIN_MANUAL_TRANSACTION,
METHOD_COMMIT_MANUAL_TRANSACTION,
METHOD_ROLLBACK_MANUAL_TRANSACTION,
METHOD_DISCONNECT,
METHOD_SHUTDOWN
));
@@ -212,11 +224,15 @@ public final class AgentProtocol {
List<String> mongoCapabilities = new java.util.ArrayList<>(CAPABILITIES);
mongoCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE);
mongoCapabilities.add(CAPABILITY_MONGO_CLONE_COLLECTION);
mongoCapabilities.add(CAPABILITY_MONGO_RUN_COMMAND);
mongoCapabilities.add(CAPABILITY_MONGO_INSERT_DOCUMENTS);
MONGO_LEGACY_CAPABILITIES = Collections.unmodifiableList(mongoCapabilities);
List<String> mongoMultiSessionCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE);
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_CLONE_COLLECTION);
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_RUN_COMMAND);
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_INSERT_DOCUMENTS);
MONGO_LEGACY_MULTI_SESSION_CAPABILITIES = Collections.unmodifiableList(mongoMultiSessionCapabilities);
List<String> jdbcCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
@@ -255,10 +271,12 @@ public final class AgentProtocol {
MONGO_METHOD_CLONE_COLLECTION,
MONGO_METHOD_DROP_DATABASE,
MONGO_METHOD_INSERT_DOCUMENT,
MONGO_METHOD_INSERT_DOCUMENTS,
MONGO_METHOD_UPDATE_DOCUMENT,
MONGO_METHOD_UPDATE_DOCUMENTS,
MONGO_METHOD_DELETE_DOCUMENT,
MONGO_METHOD_DELETE_DOCUMENTS
MONGO_METHOD_DELETE_DOCUMENTS,
MONGO_METHOD_RUN_COMMAND
));
public static final List<String> KV_METHODS = Collections.unmodifiableList(Arrays.asList(
@@ -17,6 +17,8 @@ public enum CompletionAssistantCandidateKind {
FUNCTION,
@SerializedName("column")
COLUMN,
@SerializedName("sequence")
SEQUENCE,
@SerializedName("object")
OBJECT
}
@@ -18,5 +18,7 @@ public enum CompletionAssistantObjectKind {
@SerializedName("function")
FUNCTION,
@SerializedName("column")
COLUMN
COLUMN,
@SerializedName("sequence")
SEQUENCE
}
@@ -23,6 +23,7 @@ public final class ConnectParams {
private String gbase_server;
private String informix_server;
private String sessionRole;
private String driver_profile;
public ConnectParams() {
this("", 0, "", "", "", "", "", false, "", Collections.emptyList());
@@ -209,6 +210,14 @@ public final class ConnectParams {
this.sessionRole = sessionRole;
}
public String getDriver_profile() {
return driver_profile;
}
public void setDriver_profile(String driver_profile) {
this.driver_profile = driver_profile;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
@@ -231,13 +240,14 @@ public final class ConnectParams {
&& Objects.equals(client_key_path, that.client_key_path)
&& Objects.equals(gbase_server, that.gbase_server)
&& Objects.equals(informix_server, that.informix_server)
&& Objects.equals(sessionRole, that.sessionRole);
&& Objects.equals(sessionRole, that.sessionRole)
&& Objects.equals(driver_profile, that.driver_profile);
}
@Override
public int hashCode() {
return Objects.hash(host, port, database, username, password, url_params, connection_string,
port_explicit, mysql_compat_mode, jdbc_driver_class, jdbc_driver_paths, ssl, ca_cert_path, client_cert_path, client_key_path, gbase_server, informix_server, sessionRole);
port_explicit, mysql_compat_mode, jdbc_driver_class, jdbc_driver_paths, ssl, ca_cert_path, client_cert_path, client_key_path, gbase_server, informix_server, sessionRole, driver_profile);
}
@Override
@@ -260,6 +270,7 @@ public final class ConnectParams {
+ ", gbase_server=" + gbase_server
+ ", informix_server=" + informix_server
+ ", sessionRole=" + sessionRole
+ ", driver_profile=" + driver_profile
+ ")";
}
}
@@ -13,13 +13,18 @@ public final class IndexInfo {
private String index_type;
private List<String> included_columns;
private String comment;
// Parallel to `columns`: true at index i means columns[i] is a raw expression (e.g. sourced
// from pg_get_indexdef), not a plain column name. Empty when the introspection source
// 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;
public IndexInfo() {
this("", Collections.emptyList(), false, false);
}
public IndexInfo(String name, List<String> columns, boolean is_unique, boolean is_primary) {
this(name, columns, is_unique, is_primary, null, null, null, null);
this(name, columns, is_unique, is_primary, null, null, null, null, Collections.emptyList());
}
public IndexInfo(
@@ -31,6 +36,20 @@ public final class IndexInfo {
String index_type,
List<String> included_columns,
String comment
) {
this(name, columns, is_unique, is_primary, filter, index_type, included_columns, comment, Collections.emptyList());
}
public IndexInfo(
String name,
List<String> columns,
boolean is_unique,
boolean is_primary,
String filter,
String index_type,
List<String> included_columns,
String comment,
List<Boolean> key_is_expression
) {
this.name = name;
this.columns = columns == null ? Collections.emptyList() : columns;
@@ -40,6 +59,7 @@ public final class IndexInfo {
this.index_type = index_type;
this.included_columns = included_columns;
this.comment = comment;
this.key_is_expression = key_is_expression == null ? Collections.emptyList() : key_is_expression;
}
public String getName() {
@@ -74,6 +94,10 @@ public final class IndexInfo {
return comment;
}
public List<Boolean> getKey_is_expression() {
return key_is_expression;
}
public void setName(String name) {
this.name = name;
}
@@ -106,6 +130,10 @@ public final class IndexInfo {
this.comment = comment;
}
public void setKey_is_expression(List<Boolean> key_is_expression) {
this.key_is_expression = key_is_expression;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
@@ -118,12 +146,15 @@ public final class IndexInfo {
&& 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(comment, that.comment)
&& Objects.equals(key_is_expression, that.key_is_expression);
}
@Override
public int hashCode() {
return Objects.hash(name, columns, is_unique, is_primary, filter, index_type, included_columns, comment);
return Objects.hash(
name, columns, is_unique, is_primary, filter, index_type, included_columns, comment, key_is_expression
);
}
@Override
@@ -136,6 +167,7 @@ public final class IndexInfo {
+ ", index_type=" + index_type
+ ", included_columns=" + included_columns
+ ", comment=" + comment
+ ", key_is_expression=" + key_is_expression
+ ")";
}
}
@@ -165,7 +165,7 @@ public class JdbcAgentProfile {
return schemaSwitchPrefix + " " + quote + schema.replace(quote, quote + quote) + quote;
}
private static String appendUrlParams(String url, String urlParams) {
public static String appendUrlParams(String url, String urlParams) {
String params = trimUrlParams(urlParams);
if (params.isEmpty()) {
return url;
@@ -81,10 +81,14 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
}
Lease borrow(String identity, ConnectionFactory connectionFactory) throws Exception {
return borrow(identity, JdbcSessionRole.WORKLOAD, connectionFactory);
return borrow(identity, JdbcSessionRole.WORKLOAD, null, connectionFactory);
}
Lease borrow(String identity, JdbcSessionRole role, ConnectionFactory connectionFactory) throws Exception {
return borrow(identity, role, null, connectionFactory);
}
Lease borrow(String identity, JdbcSessionRole role, String connectionTestQuery, ConnectionFactory connectionFactory) throws Exception {
String key = digest(identity);
while (true) {
if (closed.get()) {
@@ -96,7 +100,7 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
}
PoolEntry entry;
try {
entry = pools.computeIfAbsent(key, ignored -> createPoolEntry(key, connectionFactory));
entry = pools.computeIfAbsent(key, ignored -> createPoolEntry(key, connectionTestQuery, connectionFactory));
} catch (PoolCreationException error) {
throw error.unwrap();
}
@@ -130,7 +134,7 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
return entry != null && entry.hasActiveLeases();
}
private PoolEntry createPoolEntry(String key, ConnectionFactory connectionFactory) {
private PoolEntry createPoolEntry(String key, String connectionTestQuery, ConnectionFactory connectionFactory) {
try {
ConnectionFactoryDataSource factoryDataSource = new ConnectionFactoryDataSource(
connectionFactory,
@@ -146,6 +150,9 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
config.setMinimumIdle(settings.minimumIdle);
config.setConnectionTimeout(settings.connectionTimeoutMillis);
config.setValidationTimeout(settings.validationTimeoutMillis);
if (connectionTestQuery != null && !connectionTestQuery.isBlank()) {
config.setConnectionTestQuery(connectionTestQuery);
}
config.setIdleTimeout(settings.idleTimeoutMillis);
config.setMaxLifetime(settings.maxLifetimeMillis);
config.setInitializationFailTimeout(-1L);
@@ -171,8 +171,10 @@ public final class JsonRpcServer {
boolean valid = false;
if (conn != null) {
try {
valid = !conn.isClosed() && conn.isValid(2);
} catch (Exception ignored) {
valid = agent instanceof AbstractJdbcAgent jdbcAgent
? jdbcAgent.isConnectionValid(conn, 2)
: !conn.isClosed() && conn.isValid(2);
} catch (Exception | AbstractMethodError ignored) {
}
}
if (!valid) {
@@ -370,8 +372,10 @@ public final class JsonRpcServer {
}
boolean valid = false;
try {
valid = !conn.isClosed() && conn.isValid(2);
} catch (Exception ignored) {
valid = agent instanceof AbstractJdbcAgent validationAgent
? validationAgent.isConnectionValid(conn, 2)
: !conn.isClosed() && conn.isValid(2);
} catch (Exception | AbstractMethodError ignored) {
}
if (valid) {
lastConnectionValidationTimeMillis = now;
@@ -533,47 +533,87 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
return listIndexesFromCatalogArrays(schema, table, attributeCache);
}
return unchecked(() -> {
List<IndexInfo> result = new ArrayList<>();
// One row per index key position rather than array_agg/GROUP BY: avoids
// rs.getArray(...), whose JDBC support is flaky enough across these vendor drivers
// that readAttributeNumbers() below already has to defensively catch
// SQLException/UnsupportedOperationException/IncompatibleClassChangeError around it.
// LEFT JOIN pg_attribute (instead of an inner JOIN) so expression key parts
// (attnum = 0, e.g. functional index columns) survive instead of being silently
// dropped; their text comes from pg_get_indexdef instead of a.attname, and
// is_expression records which case applied so Rust never has to guess from
// characters in the text (#6312 review).
Map<String, IndexBuilder> byName = new LinkedHashMap<>();
String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " +
"ix.indisunique AS is_unique, ix.indisprimary AS is_primary, " +
"array_agg(a.attname ORDER BY k.n) AS columns " +
"(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 " +
"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 " +
"JOIN " + profile.catalogRelation("namespace") + " n ON n.oid = t.relnamespace " +
"JOIN " + profile.catalogRelation("am") + " am ON am.oid = i.relam " +
"JOIN LATERAL (SELECT unnest(ix.indkey) AS attnum, generate_series(1, array_length(ix.indkey, 1)) AS n) AS k ON true " +
"JOIN " + profile.catalogRelation("attribute") + " a ON a.attrelid = t.oid AND a.attnum = k.attnum " +
"LEFT JOIN " + profile.catalogRelation("attribute") + " a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 " +
"WHERE n.nspname = ? AND t.relname = ? " +
"GROUP BY i.relname, am.amname, ix.indisunique, ix.indisprimary " +
"ORDER BY i.relname";
"ORDER BY i.relname, k.n";
try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
Object[] columnArray = (Object[]) rs.getArray("columns").getArray();
List<String> columns = new ArrayList<>();
for (Object column : columnArray) {
columns.add(String.valueOf(column));
}
result.add(new IndexInfo(
rs.getString("index_name"),
columns,
rs.getBoolean("is_unique"),
rs.getBoolean("is_primary"),
null,
rs.getString("index_type"),
null,
null
));
String indexName = rs.getString("index_name");
String indexType = rs.getString("index_type");
boolean isUnique = rs.getBoolean("is_unique");
boolean isPrimary = rs.getBoolean("is_primary");
IndexBuilder builder = byName.computeIfAbsent(
indexName,
name -> new IndexBuilder(name, indexType, isUnique, isPrimary)
);
builder.columns.add(rs.getString("column_text"));
builder.keyIsExpression.add(rs.getBoolean("is_expression"));
}
}
}
List<IndexInfo> result = new ArrayList<>();
for (IndexBuilder builder : byName.values()) {
result.add(new IndexInfo(
builder.name,
builder.columns,
builder.isUnique,
builder.isPrimary,
null,
builder.indexType,
null,
null,
builder.keyIsExpression
));
}
return result;
});
}
private static final class IndexBuilder {
private final String name;
private final String indexType;
private final boolean isUnique;
private final boolean isPrimary;
private final List<String> columns = new ArrayList<>();
private final List<Boolean> keyIsExpression = new ArrayList<>();
private IndexBuilder(String name, String indexType, boolean isUnique, boolean isPrimary) {
this.name = name;
this.indexType = indexType;
this.isUnique = isUnique;
this.isPrimary = isPrimary;
}
}
// No PostgresLikeAgentProfile currently calls withCatalogAttributeArraysMappedInJava(), so this
// path is unreachable by any driver today (Highgo and the rest use listIndexes(...) above). It
// still filters out expression key parts (attnum <= 0) via mapRequiredAttributeNumbers and
// drops the whole index when that happens, and reports no key_is_expression provenance for the
// indexes it does return. Left as-is rather than expanding this fix onto dead code; a future
// profile enabling this path would need the same treatment as listIndexes(...) above.
private List<IndexInfo> listIndexesFromCatalogArrays(
String schema,
String table,
@@ -582,7 +622,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
return unchecked(() -> {
List<CatalogIndex> catalogIndexes = new ArrayList<>();
String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " +
"ix.indisunique AS is_unique, ix.indisprimary AS is_primary, " +
"(ix.indisunique AND ix.indisvalid) AS is_unique, ix.indisprimary AS is_primary, " +
"ix.indkey AS column_numbers " +
"FROM " + profile.catalogRelation("index") + " ix " +
"JOIN " + profile.catalogRelation("class") + " t ON t.oid = ix.indrelid " +
@@ -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"],
"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"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"commonMethods": [
@@ -36,9 +36,12 @@
"get_explain_info",
"execute_batch",
"execute_transaction",
"begin_manual_transaction",
"commit_manual_transaction",
"rollback_manual_transaction",
"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", "update_document", "update_documents", "delete_document", "delete_documents"],
"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"],
"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", "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", "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": [
@@ -40,10 +40,13 @@
"get_explain_info",
"execute_batch",
"execute_transaction",
"begin_manual_transaction",
"commit_manual_transaction",
"rollback_manual_transaction",
"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", "update_document", "update_documents", "delete_document", "delete_documents"],
"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"],
"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"
@@ -16,6 +16,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import java.util.List;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -25,6 +26,19 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AbstractJdbcAgentTest {
@Test
void buildsStandardJdbcCredentialPropertiesByDefault() {
TestAgent agent = new TestAgent(new TrackingConnection());
Properties properties = agent.buildConnectionProperties(
new ConnectParams("localhost", 0, "demo", "user", "secret", "", "", false)
);
assertEquals("user", properties.getProperty("user"));
assertEquals("secret", properties.getProperty("password"));
assertEquals(2, properties.size());
}
@Test
void ownsConnectionLifecycleAndConnectedState() {
TrackingConnection tracking = new TrackingConnection();
@@ -113,6 +127,19 @@ class AbstractJdbcAgentTest {
assertEquals(1, tracking.closeCount);
}
@Test
void validatesLegacyJdbcConnectionsWithConfiguredQueryInsteadOfIsValid() {
TrackingConnection tracking = new TrackingConnection();
tracking.isValidUnsupported = true;
TestAgent agent = new TestAgent(tracking);
agent.validationQuery = "SELECT 1";
assertTrue(agent.testConnection(new ConnectParams()));
assertEquals(0, tracking.isValidCount);
assertEquals(Arrays.asList("setQueryTimeout:5", "execute:SELECT 1"), tracking.calls);
}
@Test
void testConnectionCanSkipOpeningAPhysicalConnection() {
TrackingConnection tracking = new TrackingConnection();
@@ -325,6 +352,7 @@ class AbstractJdbcAgentTest {
private int afterConnectCount;
private int afterDisconnectCount;
private boolean skipTestConnectionOpen;
private String validationQuery;
private TestAgent(TrackingConnection tracking) {
this.tracking = tracking;
@@ -361,6 +389,11 @@ class AbstractJdbcAgentTest {
afterDisconnectCount += 1;
}
@Override
protected String connectionValidationQuery() {
return validationQuery;
}
@Override
public List<DatabaseInfo> listDatabases() {
return Collections.emptyList();
@@ -422,6 +455,7 @@ class AbstractJdbcAgentTest {
private String identifierQuote = "\"";
private boolean compatibilityQueryFails;
private int compatibilityQueryCount;
private boolean isValidUnsupported;
private Connection connection() {
return proxy(Connection.class, new MethodHandler() {
@@ -436,6 +470,9 @@ class AbstractJdbcAgentTest {
}
if ("isValid".equals(name)) {
isValidCount += 1;
if (isValidUnsupported) {
throw new AbstractMethodError("legacy JDBC driver");
}
return true;
}
if ("close".equals(name)) {
@@ -128,6 +128,54 @@ class PostgresLikeAgentTest {
assertEquals(Integer.valueOf(255), columns.get(1).getCharacter_maximum_length());
}
@Test
void listIndexesPreservesRealColumnVsExpressionProvenance() {
// PR #6312 review (round 2): the Highgo/Java Agent metadata path itself must carry
// per-key column-vs-expression provenance, not just the native Rust postgres.rs path.
// Fakes a mixed index: a plain column, a real column whose name is deliberately
// collision-prone (the round-1 review's own "order item" example — spaces, so it would
// be misclassified by the old character-based heuristic), and a genuine expression key
// part sourced from pg_get_indexdef (attnum = 0, so a.attname comes back null and
// COALESCE falls through to the expression text).
String expressionKeyPart = "COALESCE(height, '-1'::integer::double precision)";
TestPostgresLikeAgent agent = new TestPostgresLikeAgent(preparedConnection(resultSet(
new String[]{"index_name", "index_type", "is_unique", "is_primary", "column_text", "is_expression"},
new Object[][]{
{"uq_tankong", "btree", true, false, "sta_id", false},
{"uq_tankong", "btree", true, false, "order item", false},
{"uq_tankong", "btree", true, false, expressionKeyPart, true}
}
)));
agent.connect(new ConnectParams());
List<IndexInfo> indexes = agent.listIndexes("public", "tankong_data");
assertEquals(1, indexes.size());
IndexInfo index = indexes.get(0);
assertEquals(java.util.Arrays.asList("sta_id", "order item", expressionKeyPart), index.getColumns());
assertEquals(java.util.Arrays.asList(false, false, true), index.getKey_is_expression());
assertTrue(index.getIs_unique());
assertFalse(index.getIs_primary());
assertEquals("btree", index.getIndex_type());
}
@Test
void listIndexesQueryLeftJoinsAttributesInsteadOfDroppingExpressionKeys() {
// Regression guard for the actual #6295/#6312 bug: an inner JOIN on pg_attribute has no
// matching row for an expression key part (attnum = 0), so it silently disappears from
// the result instead of surfacing as an expression. The join must be a LEFT JOIN gated
// on k.attnum > 0, and expression text must come from pg_get_indexdef.
TestPostgresLikeAgent agent = new TestPostgresLikeAgent();
agent.connect(new ConnectParams());
agent.listIndexes("app", "orders");
String sql = String.join("\n", MetadataSqlFake.statements);
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);
}
@Test
void tableDdlIncludesNamedCheckConstraints() {
ConstraintDdlAgent agent = new ConstraintDdlAgent();
+10
View File
@@ -11,6 +11,16 @@ Protocol v2 allows one Agent process to serve multiple isolated database session
- `close_session` closes the session resources, query cursors, and table-read cursors without affecting other sessions.
- `shutdown` closes all sessions and terminates the runtime.
## Manual (interactive) transactions
When a runtime advertises the `transaction` capability and supports sticky sessions, DBX may open a dedicated workload session and call:
- `begin_manual_transaction` `{ schema? }` — pin one physical connection / start an open transaction
- `execute_query` (and related query methods) — run on the open transaction until commit/rollback
- `commit_manual_transaction` / `rollback_manual_transaction` — end the interactive transaction
This is separate from one-shot `execute_transaction`, which begins, runs a statement list, and commits/rolls back inside a single RPC. Runtimes that reconnect a session (`validate_session`) must clear any open manual transaction on that session.
`agentSessionId` identifies a logical database connection. Existing `sessionId` fields remain pagination cursor identifiers and must not be used as logical connection identifiers.
`sessionRole` is `workload` by default. DBX sends `metadata` for object-tree, completion, and other read-only metadata sessions. New runtimes use this role to preserve metadata checkout capacity; older runtimes may ignore the field.
+2 -1
View File
@@ -358,7 +358,8 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
case "list_foreign_keys":
return []foreignKeyInfo{}, false, nil
case "list_triggers":
return []triggerInfo{}, false, nil
result, err := s.listTriggers(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "get_object_source":
return nil, false, errors.New("object source is not supported by Cassandra")
case "get_table_ddl":
+38
View File
@@ -82,6 +82,15 @@ type triggerInfo struct {
Timing string `json:"timing"`
}
const cassandraListTriggersCQL = "SELECT trigger_name, options FROM system_schema.triggers WHERE keyspace_name = ? AND table_name = ?"
type metadataIterator interface {
Scan(...any) bool
Close() error
}
type metadataQuery func(string, ...any) metadataIterator
type metadataListConstraints struct {
Filter string
Limit int
@@ -321,6 +330,35 @@ func (s *server) querySystemIndexes(schema, table string) ([]indexInfo, error) {
return result, nil
}
func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) {
session, err := s.runtime.sessionFor("")
if err != nil {
return nil, err
}
return listTriggersWithQuery(func(query string, values ...any) metadataIterator {
return session.Query(query, values...).Iter()
}, schema, table)
}
func listTriggersWithQuery(query metadataQuery, schema, table string) ([]triggerInfo, error) {
iter := query(cassandraListTriggersCQL, schema, table)
result := []triggerInfo{}
var name string
var options map[string]string
for iter.Scan(&name, &options) {
// Cassandra trigger metadata stores only the trigger class. Cassandra
// invokes every trigger before any DML statement, so expose those fixed
// semantics through the common TriggerInfo contract.
result = append(result, triggerInfo{Name: name, Event: "DML", Timing: "BEFORE"})
options = nil
}
if err := iter.Close(); err != nil {
return nil, err
}
sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name })
return result, nil
}
func mergeIndexes(first, second []indexInfo) []indexInfo {
byName := make(map[string]indexInfo, len(first)+len(second))
for _, index := range first {
@@ -1,12 +1,37 @@
package main
import (
"errors"
"reflect"
"testing"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
type triggerMetadataIterator struct {
rows []struct {
name string
options map[string]string
}
index int
closeErr error
}
func (i *triggerMetadataIterator) Scan(dest ...any) bool {
if i.index >= len(i.rows) {
return false
}
row := i.rows[i.index]
i.index++
*dest[0].(*string) = row.name
*dest[1].(*map[string]string) = row.options
return true
}
func (i *triggerMetadataIterator) Close() error {
return i.closeErr
}
func TestColumnsIndexesAndDDLFromMetadata(t *testing.T) {
textType := gocql.NewNativeType(4, gocql.TypeVarchar, "")
intType := gocql.NewNativeType(4, gocql.TypeInt, "")
@@ -84,3 +109,53 @@ func TestQuoteCQLIdentifierEscapesQuotes(t *testing.T) {
t.Fatalf("unexpected quoted identifier: %s", got)
}
}
func TestListTriggersQueriesExactTableAndMapsMetadata(t *testing.T) {
var statement string
var values []any
triggers, err := listTriggersWithQuery(func(query string, args ...any) metadataIterator {
statement = query
values = args
return &triggerMetadataIterator{rows: []struct {
name string
options map[string]string
}{
{name: "capture_changes", options: map[string]string{"class": "example.CaptureDataTrigger"}},
}}
}, "dev", "example")
if err != nil {
t.Fatal(err)
}
if statement != cassandraListTriggersCQL {
t.Fatalf("unexpected trigger query: %s", statement)
}
if !reflect.DeepEqual(values, []any{"dev", "example"}) {
t.Fatalf("trigger filters = %#v", values)
}
want := []triggerInfo{{Name: "capture_changes", Event: "DML", Timing: "BEFORE"}}
if !reflect.DeepEqual(triggers, want) {
t.Fatalf("triggers = %#v, want %#v", triggers, want)
}
}
func TestListTriggersReturnsEmptySlice(t *testing.T) {
triggers, err := listTriggersWithQuery(func(string, ...any) metadataIterator {
return &triggerMetadataIterator{}
}, "dev", "empty_table")
if err != nil {
t.Fatal(err)
}
if triggers == nil || len(triggers) != 0 {
t.Fatalf("triggers = %#v, want a non-nil empty slice", triggers)
}
}
func TestListTriggersReturnsQueryError(t *testing.T) {
wantErr := errors.New("trigger metadata unavailable")
triggers, err := listTriggersWithQuery(func(string, ...any) metadataIterator {
return &triggerMetadataIterator{closeErr: wantErr}
}, "dev", "example")
if !errors.Is(err, wantErr) || triggers != nil {
t.Fatalf("triggers = %#v, err = %v", triggers, err)
}
}
@@ -7,6 +7,7 @@ import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.MultiSessionJsonRpcServer;
@@ -18,12 +19,18 @@ import com.dbx.agent.QueryPageResult;
import com.dbx.agent.QueryResult;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
import com.dbx.agent.StandardJdbcMetadata;
import java.io.PrintStream;
import java.io.Reader;
import java.net.URL;
import java.net.URLClassLoader;
import java.net.SocketTimeoutException;
import java.nio.file.Paths;
import java.sql.Blob;
import java.sql.Clob;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
@@ -40,16 +47,33 @@ import java.sql.Types;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class DamengAgent extends AbstractJdbcAgent {
private static final Logger LOGGER = Logger.getLogger("com.dbx.agent.dameng");
private static final String AGENT_VERSION = "9999.06.04.1-fix-default";
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+)\\.");
private static final JdbcAgentProfile DM6_METADATA_PROFILE = new JdbcAgentProfile(
"dm6.jdbc.driver.DmDriver",
"jdbc:dm6://{host}:{port}/{database}",
5236,
true
);
private static final String DAMENG_CLASSIFIED_OBJECT_TYPE_SQL =
"CASE WHEN o.OBJECT_TYPE = 'MATERIALIZED VIEW' OR (o.OBJECT_TYPE = 'VIEW' AND mv.MVIEW_NAME IS NOT NULL) "
+ "THEN 'MATERIALIZED_VIEW' ELSE o.OBJECT_TYPE END";
@@ -81,6 +105,14 @@ public final class DamengAgent extends AbstractJdbcAgent {
) mv ON mv.OWNER = o.OWNER AND mv.MVIEW_NAME = o.OBJECT_NAME
""".stripIndent().trim();
private String connectedUsername;
private Driver externalDriver;
private URLClassLoader externalDriverLoader;
private List<URL> externalDriverUrls;
private String externalDriverClass;
private volatile boolean legacyJdbcMetadata;
private volatile boolean dbmsOutputInitializationSupported = true;
private final Map<Object, Boolean> dbmsOutputInitializedConnections =
Collections.synchronizedMap(new WeakHashMap<>());
@Override
protected String driverClass() {
@@ -94,31 +126,246 @@ public final class DamengAgent extends AbstractJdbcAgent {
@Override
protected void loadDriver(ConnectParams params) throws Exception {
withSuppressedStdout(() -> super.loadDriver(params));
withSuppressedStdout(() -> {
if (params.getJdbc_driver_paths() == null || params.getJdbc_driver_paths().isEmpty()) {
// Keep an existing external loader open: pooled connections may
// still hold classes loaded from it. Only stop using it here.
externalDriver = null;
super.loadDriver(params);
return;
}
List<URL> urls = new ArrayList<>();
for (String path : params.getJdbc_driver_paths()) {
urls.add(Paths.get(path).toUri().toURL());
}
String driverClass = params.getJdbc_driver_class();
if (driverClass == null || driverClass.trim().isEmpty()) {
driverClass = driverClass();
}
// Reuse the loader for unchanged paths and class: recreating it
// would strand classes that live pooled connections still need
// (lazy driver classes would fail with NoClassDefFoundError). A
// loader dropped for changed paths is never closed here for the
// same reason; disconnect() releases the active one.
if (externalDriverLoader != null
&& urls.equals(externalDriverUrls)
&& driverClass.equals(externalDriverClass)) {
if (externalDriver == null) {
externalDriver = (Driver) Class.forName(driverClass, true, externalDriverLoader)
.getDeclaredConstructor()
.newInstance();
}
return;
}
externalDriverLoader = new URLClassLoader(
urls.toArray(new URL[0]),
ClassLoader.getPlatformClassLoader()
);
externalDriverUrls = urls;
externalDriverClass = driverClass;
externalDriver = (Driver) Class.forName(driverClass, true, externalDriverLoader)
.getDeclaredConstructor()
.newInstance();
});
}
@Override
protected Connection openConnection(ConnectParams params) throws Exception {
return withSuppressedStdout(
() -> DriverManager.getConnection(buildUrl(params), params.getUsername(), params.getPassword())
);
return withSuppressedStdout(() -> {
if (externalDriver == null) {
return DriverManager.getConnection(buildUrl(params), params.getUsername(), params.getPassword());
}
Properties properties = new Properties();
if (params.getUsername() != null) {
properties.setProperty("user", params.getUsername());
}
if (params.getPassword() != null) {
properties.setProperty("password", params.getPassword());
}
Connection connection = externalDriver.connect(buildUrl(params), properties);
if (connection == null) {
throw new SQLException("Selected Dameng JDBC driver does not accept URL: " + buildUrl(params));
}
return connection;
});
}
@Override
protected String connectionValidationQuery() {
return "SELECT 1";
}
@Override
public synchronized void disconnect() {
super.disconnect();
try {
closeExternalDriverLoader();
} catch (Exception error) {
// Best-effort release during teardown.
}
}
private void closeExternalDriverLoader() throws Exception {
externalDriver = null;
externalDriverUrls = null;
externalDriverClass = null;
if (externalDriverLoader != null) {
externalDriverLoader.close();
externalDriverLoader = null;
}
}
@Override
protected void afterConnect(ConnectParams params, Connection connection) {
connectedUsername = params.getUsername();
legacyJdbcMetadata = usesLegacyJdbcMetadata(connection);
}
static boolean usesLegacyJdbcMetadata(Connection connection) {
try {
int major = connection.getMetaData().getDatabaseMajorVersion();
if (major > 0) {
return major < 7;
}
} catch (Exception | AbstractMethodError ignored) {
}
try {
String version = connection.getMetaData().getDatabaseProductVersion();
if (version == null) {
return false;
}
Matcher matcher = DATABASE_VERSION_MAJOR_PATTERN.matcher(version);
return matcher.find() && Integer.parseInt(matcher.group(1)) < 7;
} catch (Exception | AbstractMethodError ignored) {
return false;
}
}
@Override
protected void afterPhysicalConnect(ConnectParams params, Connection connection) throws SQLException {
protected void afterPhysicalConnect(ConnectParams params, Connection connection) {
// DBMS_OUTPUT is initialized lazily because some DM versions can block here indefinitely.
}
private void initializeDbmsOutputIfNeeded(String sql) throws SQLException {
if (legacyJdbcMetadata || !dbmsOutputInitializationSupported || !isDbmsOutputStatement(sql)) {
return;
}
Connection connection = requireConnected();
Object physicalConnection = physicalConnectionIdentity(connection);
synchronized (dbmsOutputInitializedConnections) {
if (dbmsOutputInitializedConnections.containsKey(physicalConnection)) {
return;
}
dbmsOutputInitializedConnections.put(physicalConnection, Boolean.TRUE);
}
Integer originalNetworkTimeout = applyDbmsOutputNetworkTimeout(connection);
SQLException setupError = null;
try (Statement statement = connection.createStatement()) {
statement.setQueryTimeout(DBMS_OUTPUT_ENABLE_TIMEOUT_SECS);
statement.execute("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;");
} catch (SQLException error) {
if (!isIgnorableDbmsOutputError(error)) {
throw error;
setupError = error;
}
boolean setupTimedOut = setupError != null && isTimeoutError(setupError);
if (originalNetworkTimeout != null && !setupTimedOut) {
try {
connection.setNetworkTimeout(Runnable::run, originalNetworkTimeout);
} catch (SQLException restoreError) {
dbmsOutputInitializationSupported = false;
if (setupError != null) {
restoreError.addSuppressed(setupError);
}
throw restoreError;
}
}
if (setupError == null) {
return;
}
if (setupTimedOut) {
// Force Hikari to discard this connection. Its retry uses the same agent
// instance and skips optional DBMS_OUTPUT initialization.
dbmsOutputInitializationSupported = false;
throw setupError;
}
if (isIgnorableDbmsOutputError(setupError)) {
dbmsOutputInitializationSupported = false;
return;
}
throw setupError;
}
private static boolean isDbmsOutputStatement(String sql) {
if (sql == null) {
return false;
}
int start = skipSqlTrivia(sql, 0);
return startsWithKeyword(sql, start, "CALL")
|| startsWithKeyword(sql, start, "BEGIN")
|| startsWithKeyword(sql, start, "DECLARE")
|| startsWithKeyword(sql, start, "EXEC")
|| startsWithKeyword(sql, start, "EXECUTE");
}
private Object physicalConnectionIdentity(Connection connection) {
try {
String driverClassName = externalDriver == null ? driverClass() : externalDriver.getClass().getName();
int packageSeparator = driverClassName.lastIndexOf('.');
if (packageSeparator < 0) {
return connection;
}
String connectionClassName = driverClassName.substring(0, packageSeparator + 1) + "DmdbConnection";
ClassLoader loader = externalDriverLoader == null ? getClass().getClassLoader() : externalDriverLoader;
Class<?> connectionClass = Class.forName(connectionClassName, false, loader);
return connection.unwrap(connectionClass);
} catch (Exception | AbstractMethodError ignored) {
return connection;
}
}
private static boolean startsWithKeyword(String sql, int start, String keyword) {
int end = start + keyword.length();
return end <= sql.length()
&& sql.regionMatches(true, start, keyword, 0, keyword.length())
&& (end == sql.length() || !isIdentifierPart(sql.charAt(end)));
}
private static Integer applyDbmsOutputNetworkTimeout(Connection connection) throws SQLException {
try {
int originalNetworkTimeout = connection.getNetworkTimeout();
connection.setNetworkTimeout(Runnable::run, DBMS_OUTPUT_ENABLE_NETWORK_TIMEOUT_MILLIS);
return originalNetworkTimeout;
} catch (SQLFeatureNotSupportedException | AbstractMethodError | UnsupportedOperationException ignored) {
return null;
}
}
private static boolean isTimeoutError(Throwable error) {
return isTimeoutError(error, Collections.newSetFromMap(new IdentityHashMap<>()));
}
private static boolean isTimeoutError(Throwable error, Set<Throwable> visited) {
if (error == null || !visited.add(error)) {
return false;
}
if (error instanceof SQLTimeoutException || error instanceof SocketTimeoutException) {
return true;
}
String message = error.getMessage();
if (message != null) {
String normalized = message.toLowerCase(Locale.ROOT);
if (normalized.contains("timeout") || normalized.contains("timed out") || normalized.contains("超时")) {
return true;
}
}
if (error instanceof SQLException sqlError && isTimeoutError(sqlError.getNextException(), visited)) {
return true;
}
return isTimeoutError(error.getCause(), visited);
}
private static boolean isIgnorableDbmsOutputError(SQLException error) {
@@ -211,11 +458,17 @@ public final class DamengAgent extends AbstractJdbcAgent {
@Override
public List<DatabaseInfo> listDatabases() {
if (legacyJdbcMetadata) {
return unchecked(() -> listJdbcSchemas().stream().map(DatabaseInfo::new).toList());
}
return unchecked(() -> listVisibleUsers().stream().map(DatabaseInfo::new).toList());
}
@Override
public List<String> listSchemas() {
if (legacyJdbcMetadata) {
return StandardJdbcMetadata.INSTANCE.listSchemas(requireConnected(), DM6_METADATA_PROFILE);
}
return unchecked(() -> {
try {
return listVisibleSchemas();
@@ -285,6 +538,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
private List<TableInfo> queryConstrainedTables(String schema, MetadataListConstraints constraints) {
if (legacyJdbcMetadata) {
return executeJdbcMetadataTables(schema, constraints);
}
if (!constraints.includesTableLikeTypes()) {
return List.of();
}
@@ -434,6 +690,92 @@ public final class DamengAgent extends AbstractJdbcAgent {
return false;
}
/**
* 判断错误是否属于「元数据不可用」:DBMS_METADATA 系统包未安装/无权限、
* 或系统字典视图缺失/无权限。典型 DM 错误码:[‑3325] 包/对象解析失败、
* [‑2207] 无法解析的成员访问表达式(如 SF_DBMS_METADATA_RETURN_DDL)。
* 此类错误应触发源码/DDL 降级,而不是让双击对象查看源码直接报错。
*/
private static boolean isDamengMetadataUnavailableError(Throwable error) {
for (Throwable current = error; current != null; current = current.getCause()) {
if (!(current instanceof SQLException sqlError)) {
continue;
}
for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) {
int errorCode = candidate.getErrorCode();
if (errorCode == -3325 || errorCode == -2207) {
return true;
}
String message = candidate.getMessage();
if (message == null) {
continue;
}
String normalized = message.toLowerCase(Locale.ROOT);
boolean metadataObject = normalized.contains("all_objects")
|| normalized.contains("sysobjects")
|| normalized.contains("all_dependencies")
|| normalized.contains("all_tab_comments")
|| normalized.contains("dbms_metadata")
|| normalized.contains("get_ddl")
|| normalized.contains("return_ddl")
|| normalized.contains("all_source")
|| normalized.contains("all_views")
|| normalized.contains("all_triggers")
|| normalized.contains("all_sequences")
|| normalized.contains("systexts");
boolean permissionDenied = normalized.contains("权限")
|| normalized.contains("privilege")
|| normalized.contains("permission")
|| normalized.contains("access denied")
|| normalized.contains("not authorized");
boolean missingObject = normalized.contains("解析失败")
|| normalized.contains("无法解析")
|| normalized.contains("不存在")
|| normalized.contains("not exist")
|| normalized.contains("does not exist")
|| normalized.contains("not found")
|| normalized.contains("未发现");
if (metadataObject && (permissionDenied || missingObject)) {
return true;
}
}
}
return false;
}
/** 连接类错误(断连/超时等)不属于元数据不可用,必须继续上抛。 */
private static boolean isDamengConnectionError(Throwable error) {
for (Throwable current = error; current != null; current = current.getCause()) {
if (current instanceof SQLRecoverableException
|| current instanceof SQLNonTransientConnectionException
|| current instanceof SQLTransientConnectionException) {
return true;
}
if (!(current instanceof SQLException sqlError)) {
continue;
}
for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) {
String message = candidate.getMessage();
if (message == null) {
continue;
}
String normalized = message.toLowerCase(Locale.ROOT);
if (normalized.contains("connection reset")
|| normalized.contains("connection lost")
|| normalized.contains("connection closed")
|| normalized.contains("关闭的连接")
|| normalized.contains("网络通信异常")
|| normalized.contains("通信异常")
|| normalized.contains("socket")
|| normalized.contains("timed out")
|| normalized.contains("超时")) {
return true;
}
}
}
return false;
}
private List<TableInfo> executeConstrainedTables(MetadataQuery query, MetadataListConstraints constraints) {
return unchecked(() -> {
List<TableInfo> result = new ArrayList<>();
@@ -717,6 +1059,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
private List<ObjectInfo> queryConstrainedObjects(String schema, MetadataListConstraints constraints) {
if (legacyJdbcMetadata) {
return executeJdbcMetadataObjects(schema, constraints);
}
if (!includesSupportedObjectTypes(constraints)) {
return List.of();
}
@@ -887,18 +1232,352 @@ public final class DamengAgent extends AbstractJdbcAgent {
public ObjectSource getObjectSource(String schema, String name, String objectType) {
return unchecked(() -> {
String dbmsType = damengDdlObjectType(objectType);
String source;
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, dbmsType);
stmt.setString(2, name);
stmt.setString(3, schema);
try (ResultSet rs = stmt.executeQuery()) {
source = rs.next() ? coalesce(readTextColumn(rs, 1)) : "";
RuntimeException dbmsError;
try {
String source = readDbmsMetadataObjectSource(dbmsType, name, schema);
return new ObjectSource(name, objectType, schema, source);
} catch (Exception error) {
// 检查型异常(SQLException 等)由 unchecked 透传后在此处统一处理。
RuntimeException runtimeError = error instanceof RuntimeException runtime ? runtime : new RuntimeException(error);
if (!isDamengMetadataUnavailableError(runtimeError)) {
throw runtimeError;
}
dbmsError = runtimeError;
}
try {
return buildCatalogObjectSource(schema, name, objectType, dbmsError);
} catch (RuntimeException fallbackError) {
fallbackError.addSuppressed(dbmsError);
throw fallbackError;
}
});
}
private String readDbmsMetadataObjectSource(String dbmsType, String name, String schema) throws Exception {
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, dbmsType);
stmt.setString(2, name);
stmt.setString(3, schema);
try (ResultSet rs = stmt.executeQuery()) {
return rs.next() ? coalesce(readTextColumn(rs, 1)) : "";
}
}
}
/**
* 在 DBMS_METADATA 系统包缺失/无权限时(典型错误:[‑3325] 包/对象解析失败、
* [‑2207] 无法解析的成员访问表达式,如 SF_DBMS_METADATA_RETURN_DDL),
* 从系统字典视图重建对象源码:
* <ul>
* <li>VIEW/MATERIALIZED_VIEW → ALL_VIEWS.TEXT</li>
* <li>TRIGGER → ALL_TRIGGERS.TRIGGER_BODY</li>
* <li>SEQUENCE → 由 ALL_SEQUENCES 元数据重建 CREATE SEQUENCE 语句</li>
* <li>PROCEDURE/FUNCTION/PACKAGE/PACKAGE_BODY/TYPE/TYPE_BODY → ALL_SOURCE 按 LINE 拼接,
* 再以 SYS.SYSOBJECTS + SYS.SYSTEXTS 作为最后一层</li>
* </ul>
* 所有字典来源都不可用时返回带原因说明的占位源码(不可编辑),避免双击对象查看源码直接报错;
* 连接类错误(断连/超时等)不属于元数据不可用,继续上抛由上层处理会话。
*/
private ObjectSource buildCatalogObjectSource(
String schema,
String name,
String objectType,
RuntimeException dbmsError
) throws Exception {
String type = normalizeObjectSourceType(objectType);
switch (type) {
case "VIEW", "MATERIALIZED_VIEW" -> {
ObjectSource fromView = catalogTextSource(
schema,
name,
objectType,
"SELECT TEXT FROM ALL_VIEWS WHERE OWNER = ? AND VIEW_NAME = ?",
false
);
if (fromView != null) {
return fromView;
}
}
return new ObjectSource(name, objectType, schema, source);
});
case "TRIGGER" -> {
ObjectSource fromTrigger = catalogTextSource(
schema,
name,
objectType,
"SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = ? AND TRIGGER_NAME = ?",
false
);
if (fromTrigger != null) {
return fromTrigger;
}
}
case "SEQUENCE" -> {
ObjectSource fromSequence = catalogSequenceSource(schema, name, objectType);
if (fromSequence != null) {
return fromSequence;
}
}
case "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY" -> {
ObjectSource fromAllSource = catalogRoutineSource(schema, name, objectType);
if (fromAllSource != null) {
return fromAllSource;
}
ObjectSource fromSystemText = catalogRoutineSystemText(schema, name, objectType);
if (fromSystemText != null) {
return fromSystemText;
}
}
default -> throw new IllegalArgumentException("Unsupported object type: " + objectType);
}
return unavailableObjectSource(schema, name, objectType, dbmsError);
}
/**
* 字典视图来源(ALL_VIEWS.TEXT / ALL_TRIGGERS.TRIGGER_BODY)通常只包含对象正文、
* 不含完整的 CREATE/ALTER 语句头,不能作为可执行 DDL 保存,故标记为不可在线编辑。
*/
private static final String CATALOG_BODY_HINT =
"-- 以下内容来自系统字典视图,仅为对象正文,可能不包含完整语句头,不可在线编辑。\n";
/** 读取单行单列文本(视图/触发器源码),空结果返回空串。 */
private String scalarText(String sql, String schema, String name) throws Exception {
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, name);
try (ResultSet rs = stmt.executeQuery()) {
return rs.next() ? coalesce(readTextColumn(rs, 1)) : "";
}
}
}
private ObjectSource catalogTextSource(String schema, String name, String objectType, String sql, boolean editable) {
try {
String text = scalarText(sql, schema, name);
if (!notBlank(text)) {
return null;
}
if (!editable) {
text = CATALOG_BODY_HINT + text;
}
return new ObjectSource(name, objectType, schema, text, editable);
} catch (RuntimeException error) {
return metadataTierError(error);
} catch (Exception error) {
return metadataTierError(new RuntimeException(error));
}
}
private ObjectSource catalogSequenceSource(String schema, String name, String objectType) {
try {
String sql = """
SELECT MIN_VALUE, MAX_VALUE, INCREMENT_BY, CYCLE_FLAG, ORDER_FLAG, CACHE_SIZE
FROM ALL_SEQUENCES
WHERE SEQUENCE_OWNER = ? AND SEQUENCE_NAME = ?
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, name);
try (ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) {
return null;
}
StringBuilder ddl = new StringBuilder("CREATE SEQUENCE ").append(qualifiedName(schema, name));
String minValue = rs.getString("MIN_VALUE");
String maxValue = rs.getString("MAX_VALUE");
String increment = rs.getString("INCREMENT_BY");
if (increment != null && !"1".equals(increment.trim())) {
ddl.append(" INCREMENT BY ").append(increment.trim());
}
if (minValue != null && !"1".equals(minValue.trim())) {
ddl.append(" MINVALUE ").append(minValue.trim());
}
if (maxValue != null) {
ddl.append(" MAXVALUE ").append(maxValue.trim());
}
String cache = rs.getString("CACHE_SIZE");
if (cache != null && !"0".equals(cache.trim())) {
ddl.append(" CACHE ").append(cache.trim());
} else {
ddl.append(" NOCACHE");
}
ddl.append("Y".equalsIgnoreCase(rs.getString("CYCLE_FLAG")) ? " CYCLE" : " NOCYCLE");
ddl.append("Y".equalsIgnoreCase(rs.getString("ORDER_FLAG")) ? " ORDER" : " NOORDER");
ddl.append(";");
// 序列 DDL 由元数据重建,不提供在线编辑。
return new ObjectSource(name, objectType, schema, ddl.toString(), false);
}
}
} catch (RuntimeException error) {
return metadataTierError(error);
} catch (Exception error) {
return metadataTierError(new RuntimeException(error));
}
}
private ObjectSource catalogRoutineSource(String schema, String name, String objectType) {
try {
String type = normalizeObjectSourceType(objectType);
List<String> typeCandidates = switch (type) {
// 不同 DM 版本对函数/过程的 TYPE 取值不一致(有的统一为 PROCEDURE),逐一尝试。
case "FUNCTION" -> List.of("FUNCTION", "PROCEDURE");
case "PROCEDURE" -> List.of("PROCEDURE", "FUNCTION");
case "PACKAGE" -> List.of("PACKAGE");
case "PACKAGE_BODY" -> List.of("PACKAGE BODY", "PACKAGE_BODY");
case "TYPE" -> List.of("TYPE");
case "TYPE_BODY" -> List.of("TYPE BODY", "TYPE_BODY");
default -> List.of();
};
for (String candidate : typeCandidates) {
String source = readAllSourceLines(schema, name, candidate);
if (notBlank(source)) {
return catalogRoutineObjectSource(schema, name, objectType, source);
}
}
// 兜底:忽略 TYPE 过滤按名称读取全部源码行(同一 schema 内对象名唯一)。
String anyTypeSource = readAllSourceLines(schema, name, null);
if (notBlank(anyTypeSource)) {
return catalogRoutineObjectSource(schema, name, objectType, anyTypeSource);
}
return null;
} catch (RuntimeException error) {
return metadataTierError(error);
} catch (Exception error) {
return metadataTierError(new RuntimeException(error));
}
}
private String readAllSourceLines(String schema, String name, String sourceType) throws Exception {
String sql = sourceType == null
? "SELECT TEXT FROM ALL_SOURCE WHERE OWNER = ? AND NAME = ? ORDER BY LINE"
: "SELECT TEXT FROM ALL_SOURCE WHERE OWNER = ? AND NAME = ? AND TYPE = ? ORDER BY LINE";
StringBuilder source = new StringBuilder();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, name);
if (sourceType != null) {
stmt.setString(3, sourceType);
}
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String line = coalesce(readTextColumn(rs, 1));
source.append(line);
if (!line.endsWith("\n")) {
source.append("\n");
}
}
}
}
return source.toString();
}
private static ObjectSource catalogRoutineObjectSource(
String schema,
String name,
String objectType,
String source
) {
String normalizedSource = source.stripLeading();
String upperSource = normalizedSource.toUpperCase(Locale.ROOT);
boolean executable = upperSource.startsWith("CREATE ") || upperSource.startsWith("ALTER ");
if (!executable) {
String declarationType = normalizeObjectSourceType(objectType).replace('_', ' ');
if (startsWithRoutineDeclaration(normalizedSource, declarationType)) {
normalizedSource = "CREATE OR REPLACE " + normalizedSource;
executable = true;
}
}
if (!executable) {
normalizedSource = CATALOG_BODY_HINT + normalizedSource;
}
return new ObjectSource(name, objectType, schema, normalizedSource, executable);
}
private static boolean startsWithRoutineDeclaration(String source, String declarationType) {
if (!source.regionMatches(true, 0, declarationType, 0, declarationType.length())) {
return false;
}
return source.length() == declarationType.length()
|| Character.isWhitespace(source.charAt(declarationType.length()));
}
private ObjectSource catalogRoutineSystemText(String schema, String name, String objectType) {
try {
// DM 将过程/函数/包/类型的定义文本按行存放在 SYS.SYSTEXTS,
// SYS.SYSOBJECTS 的 SCHID 关联所属 schema;适用于 ALL_SOURCE 不可用的实例。
String sql = """
SELECT t.TEXT
FROM SYS.SYSTEXTS t
JOIN SYS.SYSOBJECTS o ON o.ID = t.ID
JOIN SYS.SYSOBJECTS s ON s.ID = o.SCHID AND s.TYPE$ = 'SCH' AND s.NAME = ?
WHERE o.NAME = ?
ORDER BY t.LINE
""".stripIndent().trim();
StringBuilder source = new StringBuilder();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, name);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String line = coalesce(readTextColumn(rs, 1));
source.append(line);
if (!line.endsWith("\n")) {
source.append("\n");
}
}
}
}
return notBlank(source.toString())
? catalogRoutineObjectSource(schema, name, objectType, source.toString())
: null;
} catch (RuntimeException error) {
return metadataTierError(error);
} catch (Exception error) {
return metadataTierError(new RuntimeException(error));
}
}
/**
* 元数据降级分层中的单层错误处理:连接类错误继续上抛(会话处理需要真实错误),
* 元数据不可用/未知错误返回 {@code null} 让调用方尝试下一层。
*/
private static ObjectSource metadataTierError(RuntimeException error) {
if (isDamengConnectionError(error)) {
throw error;
}
LOGGER.log(
Level.FINE,
"Dameng metadata catalog tier unavailable for source fallback: " + firstLine(error.getMessage()),
error
);
return null;
}
/** 所有降级来源都不可用时返回带原因说明的占位源码,避免双击对象查看源码直接报错。 */
private ObjectSource unavailableObjectSource(
String schema,
String name,
String objectType,
RuntimeException dbmsError
) {
String reason = dbmsError == null ? "未知" : firstLine(dbmsError.getMessage());
String source = "-- 无法获取 " + qualifiedName(schema, name) + "(" + objectType + ")的完整定义。\n"
+ "-- 当前连接缺少 DBMS_METADATA 系统包(未安装或无执行权限),系统字典中也没有可用源码。\n"
+ "-- 如需完整 DDL,请由 DBA 执行 SP_CREATE_SYSTEM_PACKAGES(1) 安装系统包后重试。\n"
+ "-- 底层错误:" + reason;
return new ObjectSource(name, objectType, schema, source, false);
}
private static String firstLine(String message) {
if (message == null || message.isEmpty()) {
return "未知";
}
int newline = message.indexOf('\n');
return newline < 0 ? message.trim() : message.substring(0, newline).trim();
}
private static String normalizeObjectSourceType(String objectType) {
String value = objectType == null ? "" : objectType.trim().toUpperCase(Locale.ROOT);
return value.replace(' ', '_');
}
static String damengDdlObjectType(String objectType) {
@@ -912,12 +1591,18 @@ public final class DamengAgent extends AbstractJdbcAgent {
case "PACKAGE_BODY", "PACKAGE BODY" -> "PKG_BODY";
// DM DBMS_METADATA accepts TRIGGER directly and returns executable CREATE OR REPLACE DDL.
case "TRIGGER" -> "TRIGGER";
// 用户自定义类型:与 Oracle 兼容的 DBMS_METADATA 对象类型名。
case "TYPE" -> "TYPE";
case "TYPE_BODY", "TYPE BODY" -> "TYPE BODY";
default -> throw new IllegalArgumentException("Unsupported object type: " + objectType);
};
}
@Override
public String getTableDdl(String schema, String table) {
if (legacyJdbcMetadata) {
return super.getTableDdl(schema, table);
}
try {
return unchecked(() -> {
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
@@ -939,7 +1624,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
throw new IllegalArgumentException("Table not found: " + schema + "." + table);
});
} catch (RuntimeException error) {
if (!isDamengMetadataPermissionError(error)) {
if (!isDamengMetadataUnavailableError(error)) {
throw error;
}
try {
@@ -953,6 +1638,15 @@ public final class DamengAgent extends AbstractJdbcAgent {
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
if (legacyJdbcMetadata) {
return StandardJdbcMetadata.INSTANCE.getColumns(
requireConnected(),
DM6_METADATA_PROFILE,
getConfiguredDatabase(),
schema,
table
);
}
return unchecked(() -> {
Set<String> pkColumns = new java.util.HashSet<>();
String pkSql = """
@@ -1057,6 +1751,15 @@ public final class DamengAgent extends AbstractJdbcAgent {
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
if (legacyJdbcMetadata) {
return StandardJdbcMetadata.INSTANCE.listIndexes(
requireConnected(),
DM6_METADATA_PROFILE,
getConfiguredDatabase(),
schema,
table
);
}
return unchecked(() -> {
List<IndexInfo> result = new ArrayList<>();
String sql = """
@@ -1097,6 +1800,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
@Override
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
if (legacyJdbcMetadata) {
return StandardJdbcMetadata.INSTANCE.listForeignKeys(requireConnected(), schema, table);
}
return unchecked(() -> {
List<ForeignKeyInfo> result = new ArrayList<>();
String sql = """
@@ -1128,6 +1834,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
@Override
public List<TriggerInfo> listTriggers(String schema, String table) {
if (legacyJdbcMetadata) {
return StandardJdbcMetadata.INSTANCE.listTriggers(schema, table);
}
return unchecked(() -> {
List<TriggerInfo> result = new ArrayList<>();
String sql = """
@@ -1156,6 +1865,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
// DM JDBC reports raw EXPLAIN as an update count; its driver API is the only source of plan rows.
return executeExplainQuery(explainSql, schema, options);
}
uncheckedVoid(() -> initializeDbmsOutputIfNeeded(sql));
return JdbcExecutor.current().execute(
requireConnected(),
sql,
@@ -1188,7 +1898,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
String message = (String) value;
return message.isEmpty() ? List.of() : message.lines().toList();
} catch (Exception ignored) {
} catch (Exception | AbstractMethodError ignored) {
return List.of();
}
}
@@ -1279,6 +1989,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
false
);
}
uncheckedVoid(() -> initializeDbmsOutputIfNeeded(sql));
return JdbcExecutor.current().executePage(
requireConnected(),
sql,
@@ -1316,7 +2027,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
case Types.FLOAT, Types.REAL -> rs.getFloat(index);
case Types.DOUBLE -> rs.getDouble(index);
case Types.DECIMAL, Types.NUMERIC -> rs.getBigDecimal(index);
case Types.BOOLEAN, Types.BIT -> rs.getBoolean(index);
case Types.BOOLEAN -> rs.getBoolean(index);
case Types.BIT -> rs.getByte(index);
case Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR,
Types.NCHAR, Types.NVARCHAR, Types.LONGNVARCHAR,
Types.CLOB, Types.NCLOB -> rs.getString(index);
@@ -1385,6 +2097,11 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
private static String buildUrl(ConnectParams params) {
String connectionString = params.getConnection_string() == null ? "" : params.getConnection_string().trim();
if (!connectionString.isEmpty()
&& !connectionString.regionMatches(true, 0, "dm://", 0, "dm://".length())) {
return connectionString;
}
String database = params.getDatabase() == null ? "" : params.getDatabase().trim();
String suffix = database.isEmpty() ? "" : "/" + database;
String url = "jdbc:dm://" + params.getHost() + ":" + params.getPort() + suffix;
@@ -1,6 +1,7 @@
package com.dbx.agent.dameng;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.ObjectSource;
@@ -25,6 +26,31 @@ import java.util.Arrays;
import java.util.List;
class DamengAgentMetadataTest {
@Test
void detectsLegacyDamengMetadataFromDatabaseVersion() {
Assertions.assertTrue(DamengAgent.usesLegacyJdbcMetadata(versionConnection(6, "6.0.2.79")));
Assertions.assertFalse(DamengAgent.usesLegacyJdbcMetadata(versionConnection(8, "8.1.5.45")));
Assertions.assertTrue(DamengAgent.usesLegacyJdbcMetadata(versionConnection(0, "DM Database Server x64 V6.0.2.79")));
}
@Test
void legacyDamengUsesJdbcMetadataWithoutQueryingDm8CatalogViews() {
DamengAgent agent = new DamengAgent();
List<String> metadataCalls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, legacyMetadataConnection(metadataCalls));
setLegacyJdbcMetadata(agent, true);
List<DatabaseInfo> databases = agent.listDatabases();
List<String> schemas = agent.listSchemas();
List<TableInfo> tables = agent.listTables("DBX_TEST");
Assertions.assertEquals(List.of("DBX_TEST", "SYSDBA"), databases.stream().map(DatabaseInfo::getName).toList());
Assertions.assertEquals(List.of("DBX_TEST", "SYSDBA"), schemas);
Assertions.assertEquals(List.of("CONNECTION_SMOKE"), tables.stream().map(TableInfo::getName).toList());
Assertions.assertTrue(metadataCalls.contains("getSchemas"), metadataCalls.toString());
Assertions.assertTrue(metadataCalls.contains("getTables:DBX\\_TEST"), metadataCalls.toString());
}
@Test
void usesColumnCommentsMetadataQuery() {
DamengAgent agent = new DamengAgent();
@@ -508,6 +534,341 @@ class DamengAgentMetadataTest {
Assertions.assertTrue(source.getSource().contains("CREATE VIEW"), source.getSource());
}
@Test
void fallsBackToAllSourceFunctionDdlWhenDbmsMetadataPackageIsMissing() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(
missingDbmsMetadataPackageError(),
sqls,
List.of(
Arrays.asList("CREATE OR REPLACE FUNCTION \"APP\".\"CALC_SCORE\"(V_ID INT) RETURN INT AS"),
Arrays.asList("BEGIN"),
Arrays.asList(" RETURN V_ID * 2;"),
Arrays.asList("END;")
),
null,
List.of(),
null
)
);
ObjectSource source = agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION");
Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE FUNCTION"), source.getSource());
Assertions.assertTrue(source.getSource().contains("RETURN V_ID * 2;"), source.getSource());
Assertions.assertTrue(source.isEditable());
Assertions.assertEquals(1, sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("SYS.SYSTEXTS")), String.join("\n", sqls));
}
@Test
void fallsBackToAllSourceTypeDdlWhenDbmsMetadataPackageIsMissing() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(
missingDbmsMetadataPackageError(),
sqls,
List.of(
Arrays.asList("CREATE OR REPLACE TYPE \"APP\".\"T_ADDR\" AS OBJECT("),
Arrays.asList(" CITY VARCHAR2(64)"),
Arrays.asList(")")
),
null,
List.of(),
null
)
);
ObjectSource source = agent.getObjectSource("APP", "T_ADDR", "TYPE");
Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE TYPE"), source.getSource());
Assertions.assertTrue(source.isEditable());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
}
@Test
void fallsBackToAllSourcePackageBodyDdlWhenDbmsMetadataPackageIsMissing() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(
missingDbmsMetadataPackageError(),
sqls,
List.of(
Arrays.asList("CREATE OR REPLACE PACKAGE BODY \"APP\".\"PKG_DEMO\" AS"),
Arrays.asList(" PROCEDURE P IS BEGIN NULL; END;"),
Arrays.asList("END PKG_DEMO;")
),
null,
List.of(),
null
)
);
ObjectSource source = agent.getObjectSource("APP", "PKG_DEMO", "PACKAGE_BODY");
Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE PACKAGE BODY"), source.getSource());
Assertions.assertTrue(source.isEditable());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
}
@Test
void wrapsBareAllSourcePackageBodyAsExecutableDdl() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(
missingDbmsMetadataPackageError(),
sqls,
List.of(
Arrays.asList("PACKAGE BODY \"APP\".\"PKG_DEMO\" AS"),
Arrays.asList(" PROCEDURE P IS BEGIN NULL; END;"),
Arrays.asList("END PKG_DEMO;")
),
null,
List.of(),
null
)
);
ObjectSource source = agent.getObjectSource("APP", "PKG_DEMO", "PACKAGE_BODY");
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE PACKAGE BODY"), source.getSource());
Assertions.assertTrue(source.isEditable());
}
@Test
void fallsBackToViewTextWhenDbmsMetadataPackageIsMissing() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(missingDbmsMetadataPackageError(), sqls, List.of(), null, List.of(), null)
);
ObjectSource source = agent.getObjectSource("APP", "V_ACTIVE_USERS", "VIEW");
Assertions.assertTrue(source.getSource().contains("SELECT 1 AS ID FROM DUAL"), source.getSource());
// 字典视图正文不包含完整 CREATE VIEW 头,标记为不可编辑。
Assertions.assertFalse(source.isEditable());
Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_VIEWS")), String.join("\n", sqls));
}
@Test
void fallsBackToTriggerBodyWhenDbmsMetadataPackageIsMissing() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(missingDbmsMetadataPackageError(), sqls, List.of(), null, List.of(), null)
);
ObjectSource source = agent.getObjectSource("APP", "TRG_USERS", "TRIGGER");
Assertions.assertTrue(source.getSource().contains("CREATE TRIGGER"), source.getSource());
// 触发器正文可能不含完整创建语句头,标记为不可编辑。
Assertions.assertFalse(source.isEditable());
Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_TRIGGERS")), String.join("\n", sqls));
}
@Test
void reconstructsSequenceDdlWhenDbmsMetadataPackageIsMissing() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(missingDbmsMetadataPackageError(), sqls, List.of(), null, List.of(), null)
);
ObjectSource source = agent.getObjectSource("APP", "SEQ_ORDERS", "SEQUENCE");
Assertions.assertTrue(
source.getSource().contains("CREATE SEQUENCE \"APP\".\"SEQ_ORDERS\""),
source.getSource()
);
Assertions.assertTrue(source.getSource().contains("INCREMENT BY 5"), source.getSource());
Assertions.assertTrue(source.getSource().contains("MAXVALUE 9999999999"), source.getSource());
Assertions.assertTrue(source.getSource().contains("CACHE 20"), source.getSource());
Assertions.assertTrue(source.getSource().contains("NOCYCLE"), source.getSource());
Assertions.assertFalse(source.isEditable());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SEQUENCES")), String.join("\n", sqls));
}
@Test
void fallsBackToSysTextsWhenAllSourceIsUnavailable() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(
missingDbmsMetadataPackageError(),
sqls,
List.of(),
missingCatalogViewError("ALL_SOURCE"),
List.of(
Arrays.asList("CREATE OR REPLACE FUNCTION \"APP\".\"CALC_SCORE\"(V_ID INT) RETURN INT AS"),
Arrays.asList("BEGIN"),
Arrays.asList(" RETURN V_ID * 2;"),
Arrays.asList("END;")
),
null
)
);
ObjectSource source = agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION");
Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE FUNCTION"), source.getSource());
Assertions.assertTrue(source.isEditable());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("SYS.SYSTEXTS") && sql.contains("SYS.SYSOBJECTS")), String.join("\n", sqls));
}
@Test
void wrapsBareSysTextsTypeAsExecutableDdl() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(
missingDbmsMetadataPackageError(),
sqls,
List.of(),
missingCatalogViewError("ALL_SOURCE"),
List.of(
Arrays.asList("TYPE \"APP\".\"T_ADDR\" AS OBJECT("),
Arrays.asList(" CITY VARCHAR2(64)"),
Arrays.asList(")")
),
null
)
);
ObjectSource source = agent.getObjectSource("APP", "T_ADDR", "TYPE");
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE TYPE"), source.getSource());
Assertions.assertTrue(source.isEditable());
}
@Test
void keepsUnrecognizedRoutineCatalogTextReadOnly() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(
missingDbmsMetadataPackageError(),
sqls,
List.of(Arrays.asList("BEGIN NULL; END;")),
null,
List.of(),
null
)
);
ObjectSource source = agent.getObjectSource("APP", "PKG_DEMO", "PACKAGE");
Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource());
Assertions.assertFalse(source.isEditable());
}
@Test
void returnsUnavailablePlaceholderWhenAllCatalogSourcesFail() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(
missingDbmsMetadataPackageError(),
sqls,
List.of(),
missingCatalogViewError("ALL_SOURCE"),
List.of(),
missingCatalogViewError("SYS.SYSTEXTS")
)
);
ObjectSource source = agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION");
Assertions.assertFalse(source.isEditable());
Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource());
Assertions.assertTrue(source.getSource().contains("SP_CREATE_SYSTEM_PACKAGES"), source.getSource());
Assertions.assertTrue(source.getSource().contains("DBMS_METADATA"), source.getSource());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("SYS.SYSTEXTS")), String.join("\n", sqls));
}
@Test
void propagatesConnectionResetWithoutCatalogFallback() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(
new SQLException("DBMS_METADATA.GET_DDL connection reset"),
sqls,
List.of(),
null,
List.of(),
null
)
);
RuntimeException error = Assertions.assertThrows(
RuntimeException.class,
() -> agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION")
);
Assertions.assertEquals("DBMS_METADATA.GET_DDL connection reset", error.getCause().getMessage());
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
}
@Test
void doesNotFallBackForUnrelatedDbmsMetadataErrors() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
SQLException unrelated = new SQLException("DBMS_METADATA.GET_DDL 内部执行错误", "99999", -9001);
TestSupport.setPrivateConnection(
agent,
catalogFallbackConnection(unrelated, sqls, List.of(), null, List.of(), null)
);
Assertions.assertThrows(
RuntimeException.class,
() -> agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION")
);
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
}
@Test
void tableDdlFallsBackWhenDbmsMetadataPackageIsMissing() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(
agent,
metadataConnectionWithDbmsMetadataError(
sqls,
"[-2207]:无法解析的成员访问表达式[SF_DBMS_METADATA_RETURN_DDL]\n[-3325]:包/对象[DBMS_METADATA]解析失败"
)
);
String ddl = agent.getTableDdl("APP", "USERS");
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("ALL_TAB_COLUMNS")), String.join("\n", sqls));
Assertions.assertEquals(1, sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count());
}
@Test
void triggerMetadataDoesNotRequireOracleTriggerTypeColumn() {
DamengAgent agent = new DamengAgent();
@@ -1000,6 +1361,56 @@ class DamengAgentMetadataTest {
}
}
private static void setLegacyJdbcMetadata(DamengAgent agent, boolean value) {
try {
Field field = DamengAgent.class.getDeclaredField("legacyJdbcMetadata");
field.setAccessible(true);
field.set(agent, value);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Unable to set legacy JDBC metadata mode", e);
}
}
private static Connection versionConnection(int majorVersion, String productVersion) {
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> switch (method.getName()) {
case "getDatabaseMajorVersion" -> majorVersion;
case "getDatabaseProductVersion" -> productVersion;
default -> defaultValue(method.getReturnType());
});
return proxy(Connection.class, (method, args) ->
"getMetaData".equals(method.getName()) ? metadata : defaultValue(method.getReturnType())
);
}
private static Connection legacyMetadataConnection(List<String> calls) {
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
switch (method.getName()) {
case "getSchemas":
calls.add("getSchemas");
return metadataResultSet(List.of(List.of("SYSDBA"), List.of("DBX_TEST")));
case "getTableTypes":
return metadataResultSet(List.of(List.of("", "TABLE", "")));
case "getTables":
Assertions.assertNull(args[3], "DM6 JDBC rejects non-null getTables types");
calls.add("getTables:" + args[1]);
return metadataResultSet(List.of(List.of("CONNECTION_SMOKE", "TABLE", "smoke table")));
case "getSearchStringEscape":
return "\\";
default:
return defaultValue(method.getReturnType());
}
});
return proxy(Connection.class, (method, args) -> {
if ("getMetaData".equals(method.getName())) {
return metadata;
}
if ("prepareStatement".equals(method.getName())) {
throw new AssertionError("Legacy metadata mode must not query DM8 catalog views: " + args[0]);
}
return defaultValue(method.getReturnType());
});
}
private static Connection objectSourceConnection(List<String> params, String source) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
@@ -1016,6 +1427,78 @@ class DamengAgentMetadataTest {
});
}
/** 模拟 DM 实例未安装 DBMS_METADATA 系统包时的典型错误([‑2207]/[‑3325])。 */
private static SQLException missingDbmsMetadataPackageError() {
return new SQLException(
"[-2207]:无法解析的成员访问表达式[SF_DBMS_METADATA_RETURN_DDL]\n"
+ "[-3325]:包/对象[DBMS_METADATA]解析失败",
"22000",
-3325
);
}
/** 模拟系统字典视图(ALL_SOURCE / SYS.SYSTEXTS 等)不存在的错误。 */
private static SQLException missingCatalogViewError(String view) {
return new SQLException("表或视图不存在: " + view, "42000", -2106);
}
/**
* DBMS_METADATA 失败时走系统字典降级的连接:ALL_VIEWS/ALL_TRIGGERS/ALL_SEQUENCES
* 固定返回一行样例数据;ALL_SOURCE / SYS.SYSTEXTS 的行与错误可通过参数定制。
*/
private static Connection catalogFallbackConnection(
SQLException dbmsMetadataError,
List<String> sqls,
List<List<Object>> allSourceRows,
SQLException allSourceError,
List<List<Object>> systemTextRows,
SQLException systemTextError
) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
if ("prepareStatement".equals(name)) {
String sql = (String) args[0];
if (sqls != null) {
sqls.add(sql);
}
if (sql.contains("DBMS_METADATA.GET_DDL")) {
if (dbmsMetadataError != null) {
return failingMetadataStatement(dbmsMetadataError);
}
return metadataStatement(List.of(List.of("CREATE OR REPLACE PROCEDURE placeholder")));
}
if (sql.contains("FROM ALL_SOURCE")) {
if (allSourceError != null) {
return failingMetadataStatement(allSourceError);
}
return metadataStatement(allSourceRows);
}
if (sql.contains("SYS.SYSTEXTS")) {
if (systemTextError != null) {
return failingMetadataStatement(systemTextError);
}
return metadataStatement(systemTextRows);
}
if (sql.contains("FROM ALL_VIEWS")) {
return metadataStatement(List.of(List.of("SELECT 1 AS ID FROM DUAL")));
}
if (sql.contains("FROM ALL_TRIGGERS")) {
return metadataStatement(List.of(List.of("CREATE TRIGGER \"APP\".\"TRG_USERS\" BEFORE INSERT ON \"APP\".\"USERS\" BEGIN NULL; END;")));
}
if (sql.contains("FROM ALL_SEQUENCES")) {
return metadataStatement(List.of(List.of("1", "9999999999", "5", "N", "N", "20")));
}
}
if ("close".equals(name)) {
return null;
}
if ("isClosed".equals(name)) {
return false;
}
return defaultValue(method.getReturnType());
});
}
private static Connection restrictedCrossOwnerMetadataConnection(List<String> sqls) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
@@ -1274,6 +1757,12 @@ class DamengAgentMetadataTest {
case "CHAR_USED" -> string(rows, index[0], 7);
case "COMMENTS", "COMMENT$", "REMARKS" -> string(rows, index[0], rows.get(index[0]).size() - 1);
case "COLNAME" -> string(rows, index[0], 0);
case "MIN_VALUE" -> string(rows, index[0], 0);
case "MAX_VALUE" -> string(rows, index[0], 1);
case "INCREMENT_BY" -> string(rows, index[0], 2);
case "CYCLE_FLAG" -> string(rows, index[0], 3);
case "ORDER_FLAG" -> string(rows, index[0], 4);
case "CACHE_SIZE" -> string(rows, index[0], 5);
default -> null;
};
}
@@ -20,6 +20,23 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DamengAgentPagingTest {
@Test
void executeQueryPageSerializesBitValuesAsNumericBytes() {
assertBitResponse(Boolean.FALSE, "0");
assertBitResponse(Boolean.TRUE, "1");
}
@Test
void executeQueryPagePreservesNullBitAndBooleanValues() {
String nullBitResponse = executeSingleValueQuery("FLAG", Types.BIT, null, null, null);
String booleanResponse = executeSingleValueQuery("FLAG", Types.BOOLEAN, Boolean.TRUE, null, null);
assertFalse(nullBitResponse.contains("\"error\""), nullBitResponse);
assertTrue(nullBitResponse.contains("\"rows\":[[null]]"), nullBitResponse);
assertFalse(booleanResponse.contains("\"error\""), booleanResponse);
assertTrue(booleanResponse.contains("\"rows\":[[true]]"), booleanResponse);
}
@Test
void executeQueryPageStringifiesTimestampValuesBeforeJsonRpcSerialization() {
DamengAgent agent = new DamengAgent();
@@ -118,6 +135,40 @@ class DamengAgentPagingTest {
assertFalse(response.contains("SerialBlob"), response);
}
private static void assertBitResponse(Boolean value, String expected) {
String response = executeSingleValueQuery("FLAG", Types.BIT, value, null, null);
assertFalse(response.contains("\"error\""), response);
assertTrue(response.contains("\"rows\":[[" + expected + "]]"), response);
assertFalse(response.contains("\"rows\":[[" + value + "]]"), response);
}
private static String executeSingleValueQuery(
String columnLabel,
int sqlType,
Object objectValue,
String stringValue,
byte[] bytesValue
) {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(
agent,
singleValueConnection(columnLabel, sqlType, objectValue, stringValue, bytesValue)
);
return handleRequest(new JsonRpcServer(agent), """
{
"jsonrpc": "2.0",
"id": 1,
"method": "execute_query_page",
"params": {
"sql": "SELECT FLAG FROM sample",
"schema": "SYS",
"pageSize": 100
}
}
""");
}
private static Connection singleValueConnection(
String columnLabel,
int sqlType,
@@ -191,6 +242,24 @@ class DamengAgentPagingTest {
if ("getObject".equals(name)) {
return objectValue;
}
if ("getBoolean".equals(name)) {
if (objectValue instanceof Boolean value) {
return value;
}
if (objectValue instanceof Number value) {
return value.intValue() != 0;
}
return false;
}
if ("getByte".equals(name)) {
if (objectValue instanceof Boolean value) {
return value ? (byte) 1 : (byte) 0;
}
if (objectValue instanceof Number value) {
return value.byteValue();
}
return (byte) 0;
}
if ("getString".equals(name)) {
return stringValue;
}
@@ -198,7 +267,7 @@ class DamengAgentPagingTest {
return bytesValue;
}
if ("wasNull".equals(name)) {
return false;
return objectValue == null && stringValue == null && bytesValue == null;
}
if ("close".equals(name)) {
return null;
@@ -12,13 +12,14 @@ import com.dbx.agent.test.JdbcAgentFake;
import com.dbx.agent.test.TestSupport;
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.SocketTimeoutException;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.SQLTimeoutException;
import java.sql.SQLTransientConnectionException;
import java.sql.Statement;
import java.util.ArrayList;
@@ -40,7 +41,7 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
@Override
protected String resultSetSql() {
return "CALL SP_SAMPLE()";
return "VALUES (1)";
}
@Test
@@ -60,72 +61,152 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
}
@Test
void physicalConnectionsEnableDbmsOutputWithoutChangingUserSql() throws Exception {
void physicalConnectionsDoNotEnableDbmsOutput() throws Exception {
List<String> executedSql = new ArrayList<>();
List<Integer> queryTimeouts = new ArrayList<>();
List<Integer> networkTimeouts = new ArrayList<>();
DamengAgent agent = new DamengAgent();
agent.afterPhysicalConnect(null, printMessageConnection(null, executedSql, queryTimeouts));
agent.afterPhysicalConnect(null, printMessageConnection(null, executedSql, queryTimeouts, networkTimeouts));
assertEquals(List.of(5), queryTimeouts);
assertEquals(List.of("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;"), executedSql);
assertTrue(queryTimeouts.isEmpty());
assertTrue(networkTimeouts.isEmpty());
assertTrue(executedSql.isEmpty());
}
@Test
void physicalConnectionsIgnoreUnsupportedOrRestrictedDbmsOutput() {
void ordinaryQueriesDoNotEnableDbmsOutput() {
List<String> executedSql = new ArrayList<>();
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql));
assertDoesNotThrow(() -> agent.afterPhysicalConnect(
null,
failingDbmsOutputConnection(new SQLFeatureNotSupportedException("unsupported", "0A000"))
));
assertDoesNotThrow(() -> agent.afterPhysicalConnect(
null,
failingDbmsOutputConnection(new SQLException("permission denied", "42000"))
));
agent.executeQuery("SELECT 1 FROM DUAL", null, new ExecuteQueryOptions());
assertEquals(List.of("SELECT 1 FROM DUAL"), executedSql);
}
@Test
void physicalConnectionsIgnoreTimedOutDbmsOutputInitialization() {
DamengAgent agent = new DamengAgent();
void outputStatementsEnableDbmsOutputAfterCommentsWithKeywordBoundaries() {
assertLazilyEnablesDbmsOutput("CALL LOG_ONLY_PROCEDURE()");
assertLazilyEnablesDbmsOutput(" -- leading comment\nBEGIN NULL; END;");
assertLazilyEnablesDbmsOutput("/* leading block */ DECLARE value INT; BEGIN NULL; END;");
assertLazilyEnablesDbmsOutput("EXEC LOG_ONLY_PROCEDURE");
assertLazilyEnablesDbmsOutput("EXECUTE LOG_ONLY_PROCEDURE");
assertDoesNotThrow(() -> agent.afterPhysicalConnect(
null,
failingDbmsOutputConnection(new SQLTimeoutException("DBMS_OUTPUT enable timed out"))
));
assertDoesNotEnableDbmsOutput("CALLBACK()");
assertDoesNotEnableDbmsOutput("BEGINNING SELECT 1");
assertDoesNotEnableDbmsOutput("DECLARE_VALUE INT");
assertDoesNotEnableDbmsOutput("EXECUTOR RUN");
assertDoesNotEnableDbmsOutput("EXECUTE_IMMEDIATE 'SELECT 1'");
assertDoesNotEnableDbmsOutput("/* CALL hidden in a comment */ SELECT 1");
}
@Test
void physicalConnectionsPropagateConnectionFailures() {
void legacyDamengSkipsUnavailableDbmsOutputInitialization() {
List<String> executedSql = new ArrayList<>();
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql));
setLegacyJdbcMetadata(agent, true);
agent.executeQuery("BEGIN NULL; END;", null, new ExecuteQueryOptions());
assertEquals(List.of("BEGIN NULL; END;"), executedSql);
}
@Test
void outputStatementsEnableDbmsOutputOncePerPhysicalConnection() {
List<String> firstConnectionSql = new ArrayList<>();
List<String> secondConnectionSql = new ArrayList<>();
Object firstPhysicalConnection = new Object();
Object secondPhysicalConnection = new Object();
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(
agent,
printMessageConnection(null, firstConnectionSql, firstPhysicalConnection, null)
);
agent.executeQuery("CALL FIRST_PROCEDURE()", null, new ExecuteQueryOptions());
TestSupport.setPrivateConnection(
agent,
printMessageConnection(null, firstConnectionSql, firstPhysicalConnection, null)
);
agent.executeQuery("BEGIN NULL; END;", null, new ExecuteQueryOptions());
TestSupport.setPrivateConnection(
agent,
printMessageConnection(null, secondConnectionSql, secondPhysicalConnection, null)
);
agent.executeQuery("EXEC SECOND_PROCEDURE", null, new ExecuteQueryOptions());
assertEquals(List.of(
"BEGIN DBMS_OUTPUT.ENABLE(1000000); END;",
"CALL FIRST_PROCEDURE()",
"BEGIN NULL; END;"
), firstConnectionSql);
assertEquals(List.of(
"BEGIN DBMS_OUTPUT.ENABLE(1000000); END;",
"EXEC SECOND_PROCEDURE"
), secondConnectionSql);
}
@Test
void unsupportedOrRestrictedDbmsOutputDoesNotBlockUserSql() {
assertDbmsOutputFailureFallsBack(new SQLFeatureNotSupportedException("unsupported", "0A000"));
assertDbmsOutputFailureFallsBack(new SQLException("permission denied", "42000"));
}
@Test
void dbmsOutputTimeoutDisablesFutureAttempts() {
DamengAgent agent = new DamengAgent();
SQLException timeout = new SQLException("network communication failed");
timeout.initCause(new SocketTimeoutException("Read timed out"));
List<String> firstConnectionSql = new ArrayList<>();
List<String> retryConnectionSql = new ArrayList<>();
List<Integer> firstNetworkTimeouts = new ArrayList<>();
TestSupport.setPrivateConnection(agent, statementConnection(
null,
firstConnectionSql,
new ArrayList<>(),
firstNetworkTimeouts,
new Object(),
timeout
));
RuntimeException error = assertThrows(
RuntimeException.class,
() -> agent.executeQuery("CALL FIRST_PROCEDURE()", null, new ExecuteQueryOptions())
);
assertSame(timeout, error.getCause());
assertEquals(List.of("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;"), firstConnectionSql);
assertEquals(List.of(5_000), firstNetworkTimeouts);
TestSupport.setPrivateConnection(agent, printMessageConnection(
null,
retryConnectionSql,
new Object(),
null
));
agent.executeQuery("CALL RETRY_PROCEDURE()", null, new ExecuteQueryOptions());
assertEquals(List.of("CALL RETRY_PROCEDURE()"), retryConnectionSql);
}
@Test
void dbmsOutputInitializationPropagatesConnectionFailures() {
SQLException transientFailure = new SQLTransientConnectionException("connection closed");
SQLException sqlStateFailure = new SQLException("connection failure", "08006");
SQLException wrappedFailure = new SQLException("permission denied", "42000");
wrappedFailure.initCause(new SQLTransientConnectionException("connection closed"));
assertSame(transientFailure, assertThrows(
SQLException.class,
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(transientFailure))
));
assertSame(sqlStateFailure, assertThrows(
SQLException.class,
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(sqlStateFailure))
));
assertSame(wrappedFailure, assertThrows(
SQLException.class,
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(wrappedFailure))
));
assertDbmsOutputFailurePropagates(transientFailure);
assertDbmsOutputFailurePropagates(sqlStateFailure);
assertDbmsOutputFailurePropagates(wrappedFailure);
}
@Test
void physicalConnectionsPropagateUnrelatedSetupFailures() {
DamengAgent agent = new DamengAgent();
void dbmsOutputInitializationPropagatesUnrelatedSetupFailures() {
SQLException failure = new SQLException("resource busy", "HY000");
assertSame(failure, assertThrows(
SQLException.class,
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(failure))
));
assertDbmsOutputFailurePropagates(failure);
}
@Test
@@ -142,7 +223,45 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
assertEquals(List.of("Message"), result.getColumns());
assertEquals(List.of(List.of("first"), List.of("中文日志")), result.getRows());
assertEquals(List.of("CALL LOG_ONLY_PROCEDURE('input')"), executedSql);
assertEquals(List.of(
"BEGIN DBMS_OUTPUT.ENABLE(1000000); END;",
"CALL LOG_ONLY_PROCEDURE('input')"
), executedSql);
}
@Test
void legacyStatementWithoutJdbc4UnwrapReturnsNoPrintMessages() {
Statement statement = (Statement) Proxy.newProxyInstance(
DamengAgentTest.class.getClassLoader(),
new Class<?>[]{Statement.class},
(unused, method, args) -> {
if ("unwrap".equals(method.getName())) {
throw new AbstractMethodError("legacy JDBC driver");
}
return defaultValue(method.getReturnType());
}
);
assertEquals(List.of(), DamengAgent.statementPrintMessages(statement));
}
@Test
void pagedOutputStatementsEnableDbmsOutputLazily() {
List<String> executedSql = new ArrayList<>();
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, printMessageConnection("paged message", executedSql));
QueryPageResult result = agent.executeQueryPage(
"/* output */ CALL PAGED_LOG_PROCEDURE()",
null,
new QueryPageOptions(100, 100, 1000)
);
assertEquals(List.of(
"BEGIN DBMS_OUTPUT.ENABLE(1000000); END;",
"/* output */ CALL PAGED_LOG_PROCEDURE()"
), executedSql);
assertEquals(List.of(List.of("paged message")), result.getRows());
}
@Test
@@ -382,26 +501,57 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
List<String> executedSql,
List<Integer> queryTimeouts
) {
return statementConnection(printMessage, executedSql, queryTimeouts, null);
return printMessageConnection(printMessage, executedSql, queryTimeouts, new ArrayList<>());
}
private static Connection failingDbmsOutputConnection(SQLException failure) {
return statementConnection(null, new ArrayList<>(), new ArrayList<>(), failure);
private static Connection printMessageConnection(
String printMessage,
List<String> executedSql,
List<Integer> queryTimeouts,
List<Integer> networkTimeouts
) {
return statementConnection(
printMessage,
executedSql,
queryTimeouts,
networkTimeouts,
new Object(),
null
);
}
private static Connection printMessageConnection(
String printMessage,
List<String> executedSql,
Object physicalConnection,
SQLException dbmsOutputFailure
) {
return statementConnection(
printMessage,
executedSql,
new ArrayList<>(),
new ArrayList<>(),
physicalConnection,
dbmsOutputFailure
);
}
private static Connection statementConnection(
String printMessage,
List<String> executedSql,
List<Integer> queryTimeouts,
SQLException executeFailure
List<Integer> networkTimeouts,
Object physicalConnection,
SQLException dbmsOutputFailure
) {
InvocationHandler statementHandler = (Object unused, Method method, Object[] args) -> {
switch (method.getName()) {
case "execute":
if (executeFailure != null) {
throw executeFailure;
String sql = (String) args[0];
executedSql.add(sql);
if (dbmsOutputFailure != null && sql.contains("DBMS_OUTPUT.ENABLE")) {
throw dbmsOutputFailure;
}
executedSql.add((String) args[0]);
return false;
case "setQueryTimeout":
queryTimeouts.add((Integer) args[0]);
@@ -420,10 +570,27 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
statementHandler
);
InvocationHandler connectionHandler = (Object unused, Method method, Object[] args) -> {
if (method.getName().equals("createStatement")) {
return statement;
switch (method.getName()) {
case "createStatement":
return statement;
case "getNetworkTimeout":
return 0;
case "setNetworkTimeout":
networkTimeouts.add((Integer) args[1]);
return null;
case "isWrapperFor":
return ((Class<?>) args[0]).getName().equals("dm.jdbc.driver.DmdbConnection");
case "unwrap":
return ((Class<?>) args[0]).getName().equals("dm.jdbc.driver.DmdbConnection")
? physicalConnection
: null;
case "equals":
return unused == args[0];
case "hashCode":
return System.identityHashCode(unused);
default:
return defaultValue(method.getReturnType());
}
return defaultValue(method.getReturnType());
};
return (Connection) Proxy.newProxyInstance(
DamengAgentTest.class.getClassLoader(),
@@ -432,6 +599,66 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
);
}
private static void assertLazilyEnablesDbmsOutput(String sql) {
List<String> executedSql = new ArrayList<>();
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql));
agent.executeQuery(sql, null, new ExecuteQueryOptions());
assertEquals(2, executedSql.size());
assertEquals("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;", executedSql.get(0));
}
private static void assertDoesNotEnableDbmsOutput(String sql) {
List<String> executedSql = new ArrayList<>();
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql));
agent.executeQuery(sql, null, new ExecuteQueryOptions());
assertEquals(List.of(sql), executedSql);
}
private static void assertDbmsOutputFailureFallsBack(SQLException failure) {
List<String> executedSql = new ArrayList<>();
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql, new Object(), failure));
assertDoesNotThrow(() -> agent.executeQuery("CALL LOG_ONLY_PROCEDURE()", null, new ExecuteQueryOptions()));
assertDoesNotThrow(() -> agent.executeQuery("BEGIN NULL; END;", null, new ExecuteQueryOptions()));
assertEquals(List.of(
"BEGIN DBMS_OUTPUT.ENABLE(1000000); END;",
"CALL LOG_ONLY_PROCEDURE()",
"BEGIN NULL; END;"
), executedSql);
}
private static void assertDbmsOutputFailurePropagates(SQLException failure) {
List<String> executedSql = new ArrayList<>();
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql, new Object(), failure));
RuntimeException error = assertThrows(
RuntimeException.class,
() -> agent.executeQuery("CALL LOG_ONLY_PROCEDURE()", null, new ExecuteQueryOptions())
);
assertSame(failure, error.getCause());
assertEquals(List.of("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;"), executedSql);
}
private static void setLegacyJdbcMetadata(DamengAgent agent, boolean value) {
try {
Field field = DamengAgent.class.getDeclaredField("legacyJdbcMetadata");
field.setAccessible(true);
field.set(agent, value);
} catch (ReflectiveOperationException error) {
throw new IllegalStateException("Unable to set legacy JDBC metadata mode", error);
}
}
private static Object defaultValue(Class<?> type) {
if (type == Boolean.TYPE) return false;
if (type == Byte.TYPE) return (byte) 0;
@@ -21,6 +21,42 @@ class DamengAgentUrlTest {
Assertions.assertEquals("jdbc:dm://127.0.0.1:5236/MAIN", url);
}
@Test
void rebuildsLegacyDbxUrlFromConnectionFields() throws Exception {
ConnectParams params = new ConnectParams(
"127.0.0.1",
5236,
"MAIN",
"SYSDBA",
"pwd",
"",
"dm://SYSDBA:SYSDBA@legacy-host:5236/OLD",
false
);
String url = invokeBuildUrl(params);
Assertions.assertEquals("jdbc:dm://127.0.0.1:5236/MAIN", url);
}
@Test
void usesCompleteCustomJdbcUrlWithoutRewritingIt() throws Exception {
ConnectParams params = new ConnectParams(
"ignored",
5236,
"IGNORED",
"",
"",
"ssl=true",
"jdbc:dm6://dm6.internal:5237/MAIN?compatibleMode=oracle",
false
);
String url = invokeBuildUrl(params);
Assertions.assertEquals("jdbc:dm6://dm6.internal:5237/MAIN?compatibleMode=oracle", url);
}
@Test
void appendsDmJdbcUrlParameters() throws Exception {
String url = invokeBuildUrl(new ConnectParams(
+185 -9
View File
@@ -875,6 +875,35 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -1227,10 +1256,13 @@ dependencies = [
"minijinja",
"mongodb",
"mysql_async",
"nix",
"notify",
"openssl",
"pageant",
"percent-encoding",
"portpicker",
"postgres-openssl",
"prometheus-parse",
"quick-xml",
"rayon",
@@ -1263,6 +1295,7 @@ dependencies = [
"unicode-width",
"uuid",
"webpki-roots 0.26.11",
"windows-sys 0.61.2",
"winnow",
"zip 4.6.1",
"zstd",
@@ -1467,6 +1500,15 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "duckdb"
version = "1.10505.0"
@@ -1647,6 +1689,20 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55"
[[package]]
name = "fast-socks5"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d09fe4a491909a716088083eeb5bcc25427330fdbcd4ecd3dfa5469b3da795df"
dependencies = [
"anyhow",
"async-trait",
"log",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1710,6 +1766,21 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -2241,7 +2312,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.57.0",
]
[[package]]
@@ -2769,7 +2840,6 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
@@ -2792,6 +2862,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -3032,6 +3108,7 @@ dependencies = [
"bson",
"derive-where",
"derive_more",
"fast-socks5",
"futures-core",
"futures-io",
"futures-util",
@@ -3082,7 +3159,7 @@ dependencies = [
[[package]]
name = "mysql-common-derive"
version = "0.32.2"
source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=77085e91e5081309d585153e3b656ce33bc1fe74#77085e91e5081309d585153e3b656ce33bc1fe74"
source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=47740d85a8277167a5717afac785d37b46a36296#47740d85a8277167a5717afac785d37b46a36296"
dependencies = [
"darling",
"heck",
@@ -3099,7 +3176,7 @@ dependencies = [
[[package]]
name = "mysql_async"
version = "0.37.0"
source = "git+https://github.com/t8y2/mysql_async.git?rev=2be6e392eb9b06d20dcd2d8ed8eae748d413c9ec#2be6e392eb9b06d20dcd2d8ed8eae748d413c9ec"
source = "git+https://github.com/t8y2/mysql_async.git?rev=b615bb0b10b5975182a4aa069cabc3d09021f6ab#b615bb0b10b5975182a4aa069cabc3d09021f6ab"
dependencies = [
"bytes",
"crossbeam-queue",
@@ -3128,7 +3205,7 @@ dependencies = [
[[package]]
name = "mysql_common"
version = "0.38.0"
source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=77085e91e5081309d585153e3b656ce33bc1fe74#77085e91e5081309d585153e3b656ce33bc1fe74"
source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=47740d85a8277167a5717afac785d37b46a36296#47740d85a8277167a5717afac785d37b46a36296"
dependencies = [
"base64 0.22.1",
"bitflags 2.13.1",
@@ -3341,6 +3418,31 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"foreign-types",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
@@ -3353,6 +3455,28 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-src"
version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]]
name = "p256"
version = "0.14.0-rc.7"
@@ -3663,10 +3787,22 @@ dependencies = [
"rand 0.8.7",
]
[[package]]
name = "postgres-openssl"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06743eefaa1a5c0ef2ccb6d9abf6528790a229eabd62ddcabf9b2a3aeff09fa4"
dependencies = [
"openssl",
"tokio",
"tokio-openssl",
"tokio-postgres",
]
[[package]]
name = "postgres-protocol"
version = "0.6.12"
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=115f9fef10f0fc3669b5337955e4eb461fc349a6#115f9fef10f0fc3669b5337955e4eb461fc349a6"
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=ac8ad476de59093d33ab9f294531c956e0c6a724#ac8ad476de59093d33ab9f294531c956e0c6a724"
dependencies = [
"base64 0.23.0",
"byteorder",
@@ -3685,7 +3821,7 @@ dependencies = [
[[package]]
name = "postgres-types"
version = "0.2.14"
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=115f9fef10f0fc3669b5337955e4eb461fc349a6#115f9fef10f0fc3669b5337955e4eb461fc349a6"
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=ac8ad476de59093d33ab9f294531c956e0c6a724#ac8ad476de59093d33ab9f294531c956e0c6a724"
dependencies = [
"bytes",
"chrono",
@@ -3801,6 +3937,12 @@ dependencies = [
"regex",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "psm"
version = "0.1.31"
@@ -3831,6 +3973,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -4133,6 +4285,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"futures-core",
"futures-util",
"http",
@@ -5253,7 +5407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -5460,10 +5614,21 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tokio-openssl"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59df6849caa43bb7567f9a36f863c447d95a11d5903c9cc334ba32576a27eadd"
dependencies = [
"openssl",
"openssl-sys",
"tokio",
]
[[package]]
name = "tokio-postgres"
version = "0.7.18"
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=115f9fef10f0fc3669b5337955e4eb461fc349a6#115f9fef10f0fc3669b5337955e4eb461fc349a6"
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=ac8ad476de59093d33ab9f294531c956e0c6a724#ac8ad476de59093d33ab9f294531c956e0c6a724"
dependencies = [
"async-trait",
"byteorder",
@@ -5520,6 +5685,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
+4 -4
View File
@@ -37,10 +37,10 @@ tokio-util = "0.7"
uuid = { version = "1", features = ["v4"] }
[patch.crates-io]
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
mysql_async = { git = "https://github.com/t8y2/mysql_async.git", rev = "2be6e392eb9b06d20dcd2d8ed8eae748d413c9ec" }
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
mysql_async = { git = "https://github.com/t8y2/mysql_async.git", rev = "b615bb0b10b5975182a4aa069cabc3d09021f6ab" }
[profile.release]
panic = "abort"
+1 -3
View File
@@ -23,7 +23,6 @@ impl DuckDbConnection {
}
// Preserve PoisonError ownership so close_connection can still release the contained DuckDB handle.
#[cfg(test)]
#[allow(clippy::result_large_err)]
fn into_inner(self) -> std::sync::LockResult<duckdb::Connection> {
self.connection.into_inner()
@@ -116,8 +115,7 @@ pub fn is_memory_database_path(path: &str) -> bool {
/// Unlike relying on Drop, this calls `duckdb_disconnect` synchronously
/// so the file handle is released before this function returns.
/// On Windows this prevents "file already in use" errors when reconnecting.
#[cfg(test)]
fn close_connection(con: Arc<DuckDbConnection>) {
pub(crate) fn close_connection(con: Arc<DuckDbConnection>) {
match Arc::try_unwrap(con) {
Ok(handle) => match handle.into_inner() {
Ok(conn) => {
+19 -2
View File
@@ -50,8 +50,8 @@ mod tests {
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
con.execute_batch("CREATE TABLE users (id INTEGER, name VARCHAR)").expect("create table");
let inserted = duckdb_execute(&con, "INSERT INTO users VALUES (1, 'Ada') RETURNING id, name")
.expect("insert returning");
let inserted =
duckdb_execute(&con, "INSERT INTO users VALUES (1, 'Ada') RETURNING id, name").expect("insert returning");
let updated = duckdb_execute(&con, "UPDATE users SET name = 'Ada Lovelace' RETURNING id, name")
.expect("update returning");
let deleted = duckdb_execute(&con, "DELETE FROM users RETURNING id, name").expect("delete returning");
@@ -144,6 +144,21 @@ mod tests {
);
}
#[test]
fn duckdb_execute_resolves_current_date_arithmetic() {
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
let result = duckdb_execute(&con, "SELECT CURRENT_DATE - 4 AS d").expect("execute CURRENT_DATE - literal");
let expected = (Utc::now().date_naive() - ChronoDuration::days(4)).to_string();
assert_eq!(result.rows[0][0], serde_json::Value::String(expected));
con.execute_batch("CREATE VIEW recent AS SELECT CURRENT_DATE - 4 AS start_date, CURRENT_DATE - 1 AS end_date")
.expect("create view using CURRENT_DATE arithmetic");
let view_result =
duckdb_execute(&con, "SELECT * FROM recent").expect("query view built on CURRENT_DATE arithmetic");
assert_eq!(view_result.rows.len(), 1);
}
#[test]
fn duckdb_execute_formats_temporal_values_by_column_type() {
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
@@ -378,6 +393,8 @@ pub fn duckdb_execute_with_max_rows(
) -> Result<db::QueryResult, String> {
let start = std::time::Instant::now();
let row_limit = query_result_row_limit(max_rows);
let sql = crate::sql::rewrite_duckdb_current_date_literal_arithmetic(sql);
let sql = sql.as_ref();
if crate::sql::starts_with_duckdb_result_sql_keyword(sql) {
let mut stmt = con.prepare(sql).map_err(|e| e.to_string())?;
+57 -5
View File
@@ -148,6 +148,27 @@ impl DuckDbWorkerSession {
Ok(connection.interrupt_handle())
}
/// Takes and closes the connection so DuckDB's shutdown checkpoint runs and
/// the database WAL is removed before this worker exits. Returns false —
/// leaving the connection untouched — when the connection is poisoned
/// (dropping it aborts the process) so the caller falls back to an
/// immediate exit and the WAL replays on the next open.
fn close_connection_for_shutdown(&mut self) -> bool {
if self.connection.is_none() {
return true;
}
if !self.is_connection_healthy() {
return false;
}
match self.connection.take() {
Some(connection) => {
crate::connection::close_connection(connection);
true
}
None => true,
}
}
/// Probes whether the connection is still usable after an execute error.
///
/// duckdb-rs 1.10503.1 has a bug where `Connection::prepare()` failing at the
@@ -205,6 +226,21 @@ struct WorkerHandleResult {
}
impl DuckDbWorkerRuntime {
/// Closes the session connection when the worker is idle and the connection
/// is healthy, so the DuckDB shutdown checkpoint removes the WAL. Returns
/// false when a query is running (the session is locked or an interrupt is
/// registered) or the connection is poisoned; callers then keep the legacy
/// immediate-exit behavior and rely on WAL replay at the next open.
fn close_session_for_shutdown(&self) -> bool {
if self.active_interrupt.lock().unwrap_or_else(|e| e.into_inner()).is_some() {
return false;
}
match self.session.try_lock() {
Ok(mut session) => session.close_connection_for_shutdown(),
Err(_) => false,
}
}
async fn handle_request(
&self,
request: DuckDbWorkerRequest,
@@ -327,10 +363,18 @@ impl DuckDbWorkerRuntime {
shutdown: false,
}
}
DuckDbWorkerMethod::Shutdown => WorkerHandleResult {
response: Some(DuckDbWorkerResponse::ok(request.id, serde_json::json!({ "shutdown": true }))),
shutdown: true,
},
DuckDbWorkerMethod::Shutdown => {
// Close the connection before acking: the checkpoint that removes
// the WAL must finish before the parent observes this response
// (or the process exit it waits for). Busy or poisoned sessions
// skip the close; the parent's kill fallback and WAL replay on
// the next open cover them.
self.close_session_for_shutdown();
WorkerHandleResult {
response: Some(DuckDbWorkerResponse::ok(request.id, serde_json::json!({ "shutdown": true }))),
shutdown: true,
}
}
}
}
@@ -377,7 +421,15 @@ pub async fn run_stdio_worker() -> Result<(), String> {
loop {
let line = lines.next_line().await.map_err(|e| e.to_string())?;
let Some(line) = line else {
std::process::exit(0);
// Parent closed stdin (exit or crash). Idle workers checkpoint and
// remove their WAL on the normal return path; a worker still running
// a query must not wait for it (nor drop a poisoned connection),
// so it keeps the legacy immediate exit and the WAL replays on the
// next open.
if !runtime.close_session_for_shutdown() {
std::process::exit(0);
}
break;
};
if line.trim().is_empty() {
continue;
+188
View File
@@ -193,6 +193,127 @@ fn parse_identifier_after_as(input: &str) -> Option<String> {
(!name.is_empty()).then(|| name.to_string())
}
/// Works around a DuckDB binder bug: `CURRENT_DATE - 4` (a bare numeric literal directly
/// against the `CURRENT_DATE` keyword node) fails with "No function matches ... (DATE,
/// INTEGER_LITERAL)", while the functionally identical `current_date() - 4` binds fine.
/// Rewrites only that narrow pattern (`CURRENT_DATE` immediately followed by `+`/`-` and a raw
/// numeric literal) to the function-call form, so plain `CURRENT_DATE`, qualified references
/// (`t.current_date`), aliases (`AS current_date`), and already-cast literals are left untouched.
pub fn rewrite_duckdb_current_date_literal_arithmetic(sql: &str) -> std::borrow::Cow<'_, str> {
const KEYWORD: &str = "CURRENT_DATE";
let bytes = sql.as_bytes();
let mut rewritten: Option<String> = None;
let mut copied_to = 0usize;
let mut index = 0usize;
let mut in_single = false;
let mut in_double = false;
while index < bytes.len() {
match bytes[index] {
b'\'' if !in_double => {
if in_single && bytes.get(index + 1) == Some(&b'\'') {
index += 2;
continue;
}
in_single = !in_single;
index += 1;
continue;
}
b'"' if !in_single => {
if in_double && bytes.get(index + 1) == Some(&b'"') {
index += 2;
continue;
}
in_double = !in_double;
index += 1;
continue;
}
b'-' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'-') => {
index += 2;
while index < bytes.len() && bytes[index] != b'\n' {
index += 1;
}
continue;
}
b'/' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'*') => {
index += 2;
while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') {
index += 1;
}
index = (index + 2).min(bytes.len());
continue;
}
b'$' if !in_single && !in_double => {
if let Some(end) = dollar_quote_end(bytes, index) {
index = end;
continue;
}
index += 1;
}
_ if !in_single && !in_double && matches_keyword_at(bytes, index, KEYWORD) => {
let word_end = index + KEYWORD.len();
let preceded_by_dot = index > 0 && bytes[index - 1] == b'.';
let preceded_by_ident = index > 0 && is_ident_byte(bytes[index - 1]);
let followed_by_ident = bytes.get(word_end).copied().is_some_and(is_ident_byte);
if !preceded_by_dot
&& !preceded_by_ident
&& !followed_by_ident
&& has_raw_numeric_arithmetic_after(bytes, word_end)
{
let buf = rewritten.get_or_insert_with(String::new);
buf.push_str(&sql[copied_to..index]);
buf.push_str("current_date()");
copied_to = word_end;
}
index = word_end;
continue;
}
_ => {
index += 1;
}
}
}
match rewritten {
Some(mut buf) => {
buf.push_str(&sql[copied_to..]);
std::borrow::Cow::Owned(buf)
}
None => std::borrow::Cow::Borrowed(sql),
}
}
fn matches_keyword_at(bytes: &[u8], index: usize, keyword: &str) -> bool {
let keyword_bytes = keyword.as_bytes();
bytes.len() >= index + keyword_bytes.len()
&& bytes[index..index + keyword_bytes.len()].eq_ignore_ascii_case(keyword_bytes)
}
fn is_ident_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
/// After the `CURRENT_DATE` token: skip whitespace, require a `+`/`-` that isn't the start of a
/// `--` comment, skip whitespace again, and require a raw numeric literal (not `CAST(`/`::`).
fn has_raw_numeric_arithmetic_after(bytes: &[u8], from: usize) -> bool {
let mut index = from;
while bytes.get(index).copied().is_some_and(|b| b.is_ascii_whitespace()) {
index += 1;
}
let Some(&op) = bytes.get(index) else { return false };
if op != b'+' && op != b'-' {
return false;
}
if op == b'-' && bytes.get(index + 1) == Some(&b'-') {
return false;
}
index += 1;
while bytes.get(index).copied().is_some_and(|b| b.is_ascii_whitespace()) {
index += 1;
}
bytes.get(index).copied().is_some_and(|b| b.is_ascii_digit())
}
pub fn starts_with_duckdb_result_sql_keyword(sql: &str) -> bool {
let Some(token) = first_executable_sql_token(sql) else {
return false;
@@ -329,4 +450,71 @@ mod tests {
assert!(!starts_with_duckdb_result_sql_keyword("INSERT INTO items(note) VALUES ('RETURNING')"));
assert!(!starts_with_duckdb_result_sql_keyword("INSERT INTO items VALUES (1)"));
}
#[test]
fn rewrites_current_date_literal_arithmetic() {
assert_eq!(
rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE - 4"),
"SELECT current_date() - 4"
);
assert_eq!(rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE-4"), "SELECT current_date()-4");
assert_eq!(
rewrite_duckdb_current_date_literal_arithmetic("SELECT current_date + 1.5 AS d"),
"SELECT current_date() + 1.5 AS d"
);
assert_eq!(
rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE - 4, CURRENT_DATE - 1"),
"SELECT current_date() - 4, current_date() - 1"
);
// Already-cast literals still match the narrow "digit right after the operator" check;
// rewriting them too is harmless since current_date() binds fine against any numeric form.
assert_eq!(
rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE - 4::INTEGER"),
"SELECT current_date() - 4::INTEGER"
);
}
#[test]
fn leaves_non_literal_current_date_usage_untouched() {
for sql in [
"SELECT CURRENT_DATE",
"SELECT CURRENT_DATE::VARCHAR",
"SELECT CURRENT_DATE - CAST(4 AS INTEGER)",
"SELECT t.current_date - 4 FROM t",
"SELECT 1 AS current_date",
"CREATE TABLE cd_test (current_date INTEGER)",
"SELECT 4 - CURRENT_DATE",
"SELECT current_date() - 4",
"-- CURRENT_DATE - 4\nSELECT 1",
"SELECT '-- CURRENT_DATE - 4'",
] {
assert_eq!(rewrite_duckdb_current_date_literal_arithmetic(sql), sql, "should not rewrite: {sql}");
}
}
#[test]
fn leaves_dollar_quoted_current_date_arithmetic_untouched() {
assert_eq!(
rewrite_duckdb_current_date_literal_arithmetic(
"SELECT $$CURRENT_DATE - 4$$ AS source, CURRENT_DATE - 1 AS actual"
),
"SELECT $$CURRENT_DATE - 4$$ AS source, current_date() - 1 AS actual"
);
assert_eq!(
rewrite_duckdb_current_date_literal_arithmetic(
"SELECT $body_1$CURRENT_DATE + 2$body_1$ AS source, CURRENT_DATE + 3 AS actual"
),
"SELECT $body_1$CURRENT_DATE + 2$body_1$ AS source, current_date() + 3 AS actual"
);
assert_eq!(
rewrite_duckdb_current_date_literal_arithmetic(
"SELECT $outer$CURRENT_DATE - 4 $inner$ CURRENT_DATE + 2 $inner$$outer$ AS source"
),
"SELECT $outer$CURRENT_DATE - 4 $inner$ CURRENT_DATE + 2 $inner$$outer$ AS source"
);
assert_eq!(
rewrite_duckdb_current_date_literal_arithmetic("SELECT $body$CURRENT_DATE - 4"),
"SELECT $body$CURRENT_DATE - 4"
);
}
}
+2
View File
@@ -60,6 +60,7 @@ pub enum CompletionAssistantObjectKind {
Procedure,
Function,
Column,
Sequence,
}
impl CompletionAssistantObjectKind {
@@ -78,6 +79,7 @@ pub enum CompletionAssistantCandidateKind {
Procedure,
Function,
Column,
Sequence,
Object,
}
@@ -499,6 +499,52 @@ async fn worker_process_retries_connect_after_transient_file_lock() {
assert_eq!(result.rows, vec![vec![serde_json::json!(42)]]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn worker_shutdown_checkpoints_and_removes_wal() {
let _guard = duckdb_worker_process_test_guard().await;
let executable = PathBuf::from(env!("CARGO_BIN_EXE_dbx-duckdb-driver"));
let db_path = temp_duckdb_path();
let wal_path = wal_path_for(&db_path);
let _ = std::fs::remove_file(&db_path);
let _ = std::fs::remove_file(&wal_path);
let client =
DuckDbWorkerClient::open_with_executable(executable.clone(), db_path.to_string_lossy().to_string(), Vec::new(), None)
.await
.expect("worker process connects");
client
.execute(
None,
"CREATE TABLE events (id INTEGER); INSERT INTO events VALUES (7);".to_string(),
Some(10),
None,
Some(Duration::from_secs(5)),
)
.await
.expect("write events");
// Committed writes sit in the WAL while the connection is open.
assert!(wal_path.exists(), "committed writes should leave a WAL file next to the database");
client.shutdown().await;
assert!(!wal_path.exists(), "shutdown should checkpoint and remove the WAL file");
// The checkpointed data survives a fresh open of the database file.
let client =
DuckDbWorkerClient::open_with_executable(executable, db_path.to_string_lossy().to_string(), Vec::new(), None)
.await
.expect("reopen after shutdown");
let result = client
.execute(None, "SELECT id FROM events".to_string(), Some(10), None, Some(Duration::from_secs(5)))
.await
.expect("read events back");
assert_eq!(result.rows, vec![vec![serde_json::json!(7)]]);
client.shutdown().await;
assert!(!wal_path.exists(), "second shutdown should also remove the WAL file");
let _ = std::fs::remove_file(&db_path);
}
async fn write_worker_request(stdin: &mut tokio::process::ChildStdin, request: DuckDbWorkerRequest) {
let line = serde_json::to_string(&request).expect("request json");
stdin.write_all(line.as_bytes()).await.expect("write request");
@@ -517,6 +563,10 @@ async fn read_worker_response(
serde_json::from_str(&line).expect("parse worker response")
}
fn wal_path_for(db_path: &PathBuf) -> PathBuf {
PathBuf::from(format!("{}.wal", db_path.display()))
}
fn temp_duckdb_path() -> PathBuf {
let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let pid = std::process::id();
@@ -3,8 +3,59 @@ package com.dbx.agent.firebird;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.ObjectSource;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public final class FirebirdAgent extends ConfiguredJdbcAgent {
static final String PROCEDURE_SOURCE_SQL = """
SELECT
CASE WHEN METADATA_ROW = 1 THEN PROCEDURE_SOURCE ELSE NULL END AS PROCEDURE_SOURCE,
PARAMETER_NAME,
PARAMETER_TYPE,
PARAMETER_NUMBER,
FIELD_SOURCE,
PARAMETER_DEFAULT,
PARAMETER_NULL_FLAG,
FIELD_TYPE,
FIELD_SUB_TYPE,
FIELD_PRECISION,
FIELD_SCALE,
FIELD_LENGTH,
CHAR_COUNT
FROM (
SELECT
P.RDB$PROCEDURE_SOURCE AS PROCEDURE_SOURCE,
TRIM(PP.RDB$PARAMETER_NAME) AS PARAMETER_NAME,
PP.RDB$PARAMETER_TYPE AS PARAMETER_TYPE,
PP.RDB$PARAMETER_NUMBER AS PARAMETER_NUMBER,
TRIM(PP.RDB$FIELD_SOURCE) AS FIELD_SOURCE,
PP.RDB$DEFAULT_SOURCE AS PARAMETER_DEFAULT,
PP.RDB$NULL_FLAG AS PARAMETER_NULL_FLAG,
F.RDB$FIELD_TYPE AS FIELD_TYPE,
F.RDB$FIELD_SUB_TYPE AS FIELD_SUB_TYPE,
F.RDB$FIELD_PRECISION AS FIELD_PRECISION,
F.RDB$FIELD_SCALE AS FIELD_SCALE,
F.RDB$FIELD_LENGTH AS FIELD_LENGTH,
F.RDB$CHARACTER_LENGTH AS CHAR_COUNT,
ROW_NUMBER() OVER (
ORDER BY PP.RDB$PARAMETER_TYPE, PP.RDB$PARAMETER_NUMBER
) AS METADATA_ROW
FROM RDB$PROCEDURES P
LEFT JOIN RDB$PROCEDURE_PARAMETERS PP
ON PP.RDB$PROCEDURE_NAME = P.RDB$PROCEDURE_NAME
AND PP.RDB$PACKAGE_NAME IS NOT DISTINCT FROM P.RDB$PACKAGE_NAME
LEFT JOIN RDB$FIELDS F
ON F.RDB$FIELD_NAME = PP.RDB$FIELD_SOURCE
WHERE P.RDB$PROCEDURE_NAME = ?
AND P.RDB$PACKAGE_NAME IS NULL
) PROCEDURE_METADATA
ORDER BY PARAMETER_TYPE, PARAMETER_NUMBER
""".stripIndent().trim();
public static final JdbcAgentProfile FIREBIRD_PROFILE = new JdbcAgentProfile(
"org.firebirdsql.jdbc.FBDriver",
"jdbc:firebirdsql://{host}:{port}/{database}",
@@ -16,6 +67,228 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
super(FIREBIRD_PROFILE);
}
@Override
public ObjectSource getObjectSource(String schema, String name, String objectType) {
String normalizedType = normalizeObjectSourceType(objectType);
return unchecked(() -> {
String body = null;
boolean found = false;
List<ProcedureParameter> inputs = new ArrayList<>();
List<ProcedureParameter> outputs = new ArrayList<>();
try (PreparedStatement statement = requireConnection().prepareStatement(PROCEDURE_SOURCE_SQL)) {
statement.setString(1, name);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
found = true;
if (body == null) {
body = resultSet.getString("PROCEDURE_SOURCE");
}
String parameterName = trimToNull(resultSet.getString("PARAMETER_NAME"));
if (parameterName == null) {
continue;
}
ProcedureParameter parameter = new ProcedureParameter(
parameterName,
trimToNull(resultSet.getString("FIELD_SOURCE")),
trimToNull(resultSet.getString("PARAMETER_DEFAULT")),
nullableInt(resultSet, "PARAMETER_NULL_FLAG"),
nullableInt(resultSet, "FIELD_TYPE"),
nullableInt(resultSet, "FIELD_SUB_TYPE"),
nullableInt(resultSet, "FIELD_PRECISION"),
nullableInt(resultSet, "FIELD_SCALE"),
nullableInt(resultSet, "FIELD_LENGTH"),
nullableInt(resultSet, "CHAR_COUNT")
);
int parameterType = resultSet.getInt("PARAMETER_TYPE");
if (parameterType == 0) {
inputs.add(parameter);
} else if (parameterType == 1) {
outputs.add(parameter);
} else {
throw new IllegalStateException("Unsupported Firebird parameter type: " + parameterType);
}
}
}
}
String source = !found || body == null || body.isBlank()
? ""
: buildProcedureDdl(name, inputs, outputs, body);
return new ObjectSource(name, normalizedType, schema, source, false);
});
}
static String normalizeObjectSourceType(String objectType) {
if (objectType == null) {
throw new IllegalArgumentException("Unsupported object type: null");
}
String normalized = objectType.trim().toUpperCase(Locale.ROOT);
if (!"PROCEDURE".equals(normalized)) {
throw new IllegalArgumentException("Unsupported object type: " + objectType);
}
return normalized;
}
private static String buildProcedureDdl(
String name,
List<ProcedureParameter> inputs,
List<ProcedureParameter> outputs,
String body
) {
StringBuilder ddl = new StringBuilder("CREATE OR ALTER PROCEDURE ")
.append(quoteIdentifier(name));
appendParameterBlock(ddl, inputs, " (");
if (inputs.isEmpty()) {
ddl.append('\n');
}
if (!outputs.isEmpty()) {
appendParameterBlock(ddl, outputs, "RETURNS (");
}
ddl.append("AS\n").append(body);
if (!body.endsWith("\n")) {
ddl.append('\n');
}
return ddl.toString();
}
private static void appendParameterBlock(
StringBuilder ddl,
List<ProcedureParameter> parameters,
String prefix
) {
if (parameters.isEmpty()) {
return;
}
ddl.append(prefix).append('\n');
for (int index = 0; index < parameters.size(); index++) {
ddl.append(" ").append(parameterDeclaration(parameters.get(index)));
if (index + 1 < parameters.size()) {
ddl.append(',');
}
ddl.append('\n');
}
ddl.append(")\n");
}
private static String parameterDeclaration(ProcedureParameter parameter) {
StringBuilder declaration = new StringBuilder(quoteIdentifier(parameter.name()))
.append(' ')
.append(parameterType(parameter));
if (Integer.valueOf(1).equals(parameter.notNull())) {
declaration.append(" NOT NULL");
}
if (parameter.defaultSource() != null) {
declaration.append(' ').append(parameter.defaultSource());
}
return declaration.toString();
}
private static String parameterType(ProcedureParameter parameter) {
if (parameter.fieldSource() != null && !parameter.fieldSource().startsWith("RDB$")) {
return quoteIdentifier(parameter.fieldSource());
}
if (parameter.fieldType() == null) {
throw new IllegalStateException("Missing Firebird field type for parameter " + parameter.name());
}
int fieldType = parameter.fieldType();
int subType = valueOrZero(parameter.fieldSubType());
return switch (fieldType) {
case 7 -> numericType("SMALLINT", subType, parameter);
case 8 -> numericType("INTEGER", subType, parameter);
case 10 -> "FLOAT";
case 11, 27 -> "DOUBLE PRECISION";
case 12 -> "DATE";
case 13 -> "TIME";
case 14 -> characterType("CHAR", "BINARY", subType, parameter);
case 16 -> numericType("BIGINT", subType, parameter);
case 23 -> "BOOLEAN";
case 24 -> "DECFLOAT(16)";
case 25 -> "DECFLOAT(34)";
case 26 -> numericType("INT128", subType, parameter);
case 28 -> "TIME WITH TIME ZONE";
case 29 -> "TIMESTAMP WITH TIME ZONE";
case 35 -> "TIMESTAMP";
case 37 -> characterType("VARCHAR", "VARBINARY", subType, parameter);
case 40 -> sizedType("CSTRING", characterLength(parameter));
case 261 -> blobType(subType);
default -> throw new IllegalStateException("Unsupported Firebird field type: " + fieldType);
};
}
private static String numericType(String integerType, int subType, ProcedureParameter parameter) {
int scale = valueOrZero(parameter.scale());
if (subType != 1 && subType != 2 && scale >= 0) {
return integerType;
}
String exactType = subType == 2 ? "DECIMAL" : "NUMERIC";
int precision = valueOrZero(parameter.precision());
if (precision <= 0) {
return exactType;
}
return exactType + "(" + precision + "," + Math.abs(scale) + ")";
}
private static String characterType(
String characterType,
String binaryType,
int subType,
ProcedureParameter parameter
) {
return sizedType(subType == 1 ? binaryType : characterType, characterLength(parameter));
}
private static String sizedType(String type, Integer size) {
return size == null || size <= 0 ? type : type + "(" + size + ")";
}
private static int characterLength(ProcedureParameter parameter) {
Integer charCount = parameter.charCount();
return charCount != null && charCount > 0
? charCount
: valueOrZero(parameter.fieldLength());
}
private static String blobType(int subType) {
if (subType == 0) {
return "BLOB";
}
return subType == 1 ? "BLOB SUB_TYPE TEXT" : "BLOB SUB_TYPE " + subType;
}
private static String quoteIdentifier(String identifier) {
return "\"" + identifier.replace("\"", "\"\"") + "\"";
}
private static String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
private static Integer nullableInt(ResultSet resultSet, String column) throws Exception {
int value = resultSet.getInt(column);
return resultSet.wasNull() ? null : value;
}
private static int valueOrZero(Integer value) {
return value == null ? 0 : value;
}
private record ProcedureParameter(
String name,
String fieldSource,
String defaultSource,
Integer notNull,
Integer fieldType,
Integer fieldSubType,
Integer precision,
Integer scale,
Integer fieldLength,
Integer charCount
) {
}
public static void main(String[] args) {
new MultiSessionJsonRpcServer(FirebirdAgent::new).run();
}
@@ -0,0 +1,442 @@
package com.dbx.agent.firebird;
import com.dbx.agent.ObjectSource;
import com.dbx.agent.test.TestSupport;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class FirebirdAgentTest {
private static final String EXPECTED_PROCEDURE_SOURCE_SQL = """
SELECT
CASE WHEN METADATA_ROW = 1 THEN PROCEDURE_SOURCE ELSE NULL END AS PROCEDURE_SOURCE,
PARAMETER_NAME,
PARAMETER_TYPE,
PARAMETER_NUMBER,
FIELD_SOURCE,
PARAMETER_DEFAULT,
PARAMETER_NULL_FLAG,
FIELD_TYPE,
FIELD_SUB_TYPE,
FIELD_PRECISION,
FIELD_SCALE,
FIELD_LENGTH,
CHAR_COUNT
FROM (
SELECT
P.RDB$PROCEDURE_SOURCE AS PROCEDURE_SOURCE,
TRIM(PP.RDB$PARAMETER_NAME) AS PARAMETER_NAME,
PP.RDB$PARAMETER_TYPE AS PARAMETER_TYPE,
PP.RDB$PARAMETER_NUMBER AS PARAMETER_NUMBER,
TRIM(PP.RDB$FIELD_SOURCE) AS FIELD_SOURCE,
PP.RDB$DEFAULT_SOURCE AS PARAMETER_DEFAULT,
PP.RDB$NULL_FLAG AS PARAMETER_NULL_FLAG,
F.RDB$FIELD_TYPE AS FIELD_TYPE,
F.RDB$FIELD_SUB_TYPE AS FIELD_SUB_TYPE,
F.RDB$FIELD_PRECISION AS FIELD_PRECISION,
F.RDB$FIELD_SCALE AS FIELD_SCALE,
F.RDB$FIELD_LENGTH AS FIELD_LENGTH,
F.RDB$CHARACTER_LENGTH AS CHAR_COUNT,
ROW_NUMBER() OVER (
ORDER BY PP.RDB$PARAMETER_TYPE, PP.RDB$PARAMETER_NUMBER
) AS METADATA_ROW
FROM RDB$PROCEDURES P
LEFT JOIN RDB$PROCEDURE_PARAMETERS PP
ON PP.RDB$PROCEDURE_NAME = P.RDB$PROCEDURE_NAME
AND PP.RDB$PACKAGE_NAME IS NOT DISTINCT FROM P.RDB$PACKAGE_NAME
LEFT JOIN RDB$FIELDS F
ON F.RDB$FIELD_NAME = PP.RDB$FIELD_SOURCE
WHERE P.RDB$PROCEDURE_NAME = ?
AND P.RDB$PACKAGE_NAME IS NULL
) PROCEDURE_METADATA
ORDER BY PARAMETER_TYPE, PARAMETER_NUMBER
""".stripIndent().trim();
@Test
void returnsCompleteProcedureDdlFromOneMetadataQuery() {
List<String> sql = new ArrayList<>();
List<String> parameters = new ArrayList<>();
FirebirdAgent agent = agentWithRows(
sql,
parameters,
row(
"PROCEDURE_SOURCE", procedureBody(),
"PARAMETER_NAME", "P_ID",
"PARAMETER_TYPE", 0,
"PARAMETER_NUMBER", 0,
"FIELD_SOURCE", "RDB$1",
"FIELD_TYPE", 8,
"FIELD_SUB_TYPE", 0,
"FIELD_PRECISION", 0,
"FIELD_SCALE", 0,
"FIELD_LENGTH", 4
),
row(
"PROCEDURE_SOURCE", null,
"PARAMETER_NAME", "P_LABEL",
"PARAMETER_TYPE", 0,
"PARAMETER_NUMBER", 1,
"FIELD_SOURCE", "RDB$2",
"PARAMETER_DEFAULT", "DEFAULT 'fallback'",
"FIELD_TYPE", 37,
"FIELD_SUB_TYPE", 0,
"FIELD_SCALE", 0,
"FIELD_LENGTH", 80,
"CHAR_COUNT", 20
),
row(
"PROCEDURE_SOURCE", null,
"PARAMETER_NAME", "P_DOM",
"PARAMETER_TYPE", 0,
"PARAMETER_NUMBER", 2,
"FIELD_SOURCE", "D_6141",
"PARAMETER_DEFAULT", "DEFAULT 'domain'",
"FIELD_TYPE", 37,
"FIELD_SUB_TYPE", 0,
"FIELD_SCALE", 0,
"FIELD_LENGTH", 48,
"CHAR_COUNT", 12
),
row(
"PROCEDURE_SOURCE", null,
"PARAMETER_NAME", "O_ID",
"PARAMETER_TYPE", 1,
"PARAMETER_NUMBER", 0,
"FIELD_SOURCE", "RDB$3",
"FIELD_TYPE", 16,
"FIELD_SUB_TYPE", 0,
"FIELD_PRECISION", 0,
"FIELD_SCALE", 0,
"FIELD_LENGTH", 8
),
row(
"PROCEDURE_SOURCE", null,
"PARAMETER_NAME", "O_TEXT",
"PARAMETER_TYPE", 1,
"PARAMETER_NUMBER", 1,
"FIELD_SOURCE", "RDB$4",
"FIELD_TYPE", 37,
"FIELD_SUB_TYPE", 0,
"FIELD_SCALE", 0,
"FIELD_LENGTH", 160,
"CHAR_COUNT", 40
)
);
ObjectSource source = agent.getObjectSource(null, "P_6141", " procedure ");
Assertions.assertEquals(List.of(EXPECTED_PROCEDURE_SOURCE_SQL), sql);
Assertions.assertEquals(List.of("1=P_6141"), parameters);
Assertions.assertEquals("P_6141", source.getName());
Assertions.assertEquals("PROCEDURE", source.getObject_type());
Assertions.assertNull(source.getSchema());
Assertions.assertFalse(source.isEditable());
Assertions.assertEquals(
"""
CREATE OR ALTER PROCEDURE "P_6141" (
"P_ID" INTEGER,
"P_LABEL" VARCHAR(20) DEFAULT 'fallback',
"P_DOM" "D_6141" DEFAULT 'domain'
)
RETURNS (
"O_ID" BIGINT,
"O_TEXT" VARCHAR(40)
)
AS
DECLARE VARIABLE V_NOTE VARCHAR(20);
BEGIN
O_ID = P_ID;
O_TEXT = P_LABEL || ':' || P_DOM;
SUSPEND;
END
""",
source.getSource()
);
}
@Test
void quotesMixedCaseIdentifiersAndBindsRoutineName() {
List<String> sql = new ArrayList<>();
List<String> parameters = new ArrayList<>();
FirebirdAgent agent = agentWithRows(
sql,
parameters,
row(
"PROCEDURE_SOURCE", "BEGIN\nEND",
"PARAMETER_NAME", "Arg\"Name",
"PARAMETER_TYPE", 0,
"PARAMETER_NUMBER", 0,
"FIELD_SOURCE", "RDB$10",
"FIELD_TYPE", 7,
"FIELD_SUB_TYPE", 0,
"FIELD_SCALE", 0,
"FIELD_LENGTH", 2
)
);
ObjectSource source = agent.getObjectSource("ignored-schema", "Mix\"Case", "PROCEDURE");
Assertions.assertEquals(List.of(EXPECTED_PROCEDURE_SOURCE_SQL), sql);
Assertions.assertEquals(List.of("1=Mix\"Case"), parameters);
Assertions.assertFalse(EXPECTED_PROCEDURE_SOURCE_SQL.contains("Mix\"Case"));
Assertions.assertEquals(
"CREATE OR ALTER PROCEDURE \"Mix\"\"Case\" (\n"
+ " \"Arg\"\"Name\" SMALLINT\n"
+ ")\n"
+ "AS\n"
+ "BEGIN\nEND\n",
source.getSource()
);
}
@Test
void mapsExactNumericLegacyAndFirebirdFourTypes() {
FirebirdAgent agent = agentWithRows(
new ArrayList<>(),
new ArrayList<>(),
row(
"PROCEDURE_SOURCE", "BEGIN\nEND",
"PARAMETER_NAME", "N_SMALL",
"PARAMETER_TYPE", 0,
"FIELD_SOURCE", "RDB$11",
"FIELD_TYPE", 7,
"FIELD_SUB_TYPE", 1,
"FIELD_PRECISION", 4,
"FIELD_SCALE", -2,
"FIELD_LENGTH", 2
),
row(
"PARAMETER_NAME", "N_LEGACY",
"PARAMETER_TYPE", 0,
"FIELD_SOURCE", "RDB$12",
"FIELD_TYPE", 8,
"FIELD_SUB_TYPE", 0,
"FIELD_PRECISION", 9,
"FIELD_SCALE", -2,
"FIELD_LENGTH", 4
),
row(
"PARAMETER_NAME", "D_FLOAT_VALUE",
"PARAMETER_TYPE", 0,
"FIELD_SOURCE", "RDB$13",
"FIELD_TYPE", 11,
"FIELD_SUB_TYPE", 0,
"FIELD_LENGTH", 8
),
row(
"PARAMETER_NAME", "DEC16_VALUE",
"PARAMETER_TYPE", 0,
"FIELD_SOURCE", "RDB$14",
"FIELD_TYPE", 24,
"FIELD_SUB_TYPE", 0,
"FIELD_LENGTH", 8
),
row(
"PARAMETER_NAME", "DEC34_VALUE",
"PARAMETER_TYPE", 0,
"FIELD_SOURCE", "RDB$15",
"FIELD_TYPE", 25,
"FIELD_SUB_TYPE", 0,
"FIELD_LENGTH", 16
),
row(
"PARAMETER_NAME", "BINARY_VALUE",
"PARAMETER_TYPE", 0,
"FIELD_SOURCE", "RDB$16",
"FIELD_TYPE", 14,
"FIELD_SUB_TYPE", 1,
"FIELD_LENGTH", 4
),
row(
"PARAMETER_NAME", "VARBINARY_VALUE",
"PARAMETER_TYPE", 0,
"FIELD_SOURCE", "RDB$17",
"FIELD_TYPE", 37,
"FIELD_SUB_TYPE", 1,
"FIELD_LENGTH", 8
)
);
ObjectSource source = agent.getObjectSource(null, "P_TYPES", "PROCEDURE");
Assertions.assertEquals(
"CREATE OR ALTER PROCEDURE \"P_TYPES\" (\n"
+ " \"N_SMALL\" NUMERIC(4,2),\n"
+ " \"N_LEGACY\" NUMERIC(9,2),\n"
+ " \"D_FLOAT_VALUE\" DOUBLE PRECISION,\n"
+ " \"DEC16_VALUE\" DECFLOAT(16),\n"
+ " \"DEC34_VALUE\" DECFLOAT(34),\n"
+ " \"BINARY_VALUE\" BINARY(4),\n"
+ " \"VARBINARY_VALUE\" VARBINARY(8)\n"
+ ")\n"
+ "AS\n"
+ "BEGIN\nEND\n",
source.getSource()
);
}
@Test
void returnsEmptySourceWhenRoutineIsMissing() {
FirebirdAgent agent = agentWithRows(new ArrayList<>(), new ArrayList<>());
ObjectSource source = agent.getObjectSource(null, "MISSING_PROCEDURE", "PROCEDURE");
Assertions.assertEquals("", source.getSource());
Assertions.assertFalse(source.isEditable());
}
@Test
void returnsEmptySourceWhenStoredBodyIsNull() {
FirebirdAgent agent = agentWithRows(
new ArrayList<>(),
new ArrayList<>(),
row("PROCEDURE_SOURCE", null, "PARAMETER_NAME", null)
);
ObjectSource source = agent.getObjectSource(null, "EXTERNAL_PROCEDURE", "PROCEDURE");
Assertions.assertEquals("", source.getSource());
}
@Test
void propagatesMetadataQueryErrors() {
FirebirdAgent agent = new FirebirdAgent();
SQLException failure = new SQLException("metadata unavailable", "HY000");
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
throw failure;
}
return defaultValue(method.getReturnType());
}));
RuntimeException error = Assertions.assertThrows(
RuntimeException.class,
() -> agent.getObjectSource(null, "P_6141", "PROCEDURE")
);
Assertions.assertSame(failure, error.getCause());
}
@Test
void rejectsUnsupportedObjectTypesBeforeQuerying() {
FirebirdAgent agent = new FirebirdAgent();
IllegalArgumentException error = Assertions.assertThrows(
IllegalArgumentException.class,
() -> agent.getObjectSource(null, "P_6141", "VIEW")
);
Assertions.assertEquals("Unsupported object type: VIEW", error.getMessage());
}
@SafeVarargs
private static FirebirdAgent agentWithRows(
List<String> sql,
List<String> parameters,
Map<String, Object>... rows
) {
ResultSet resultSet = resultSet(List.of(rows));
PreparedStatement statement = proxy(PreparedStatement.class, (method, args) -> {
if ("setString".equals(method.getName())) {
parameters.add(args[0] + "=" + args[1]);
return null;
}
if ("executeQuery".equals(method.getName())) {
return resultSet;
}
return defaultValue(method.getReturnType());
});
Connection connection = proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
sql.add(String.valueOf(args[0]));
return statement;
}
if ("isClosed".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
FirebirdAgent agent = new FirebirdAgent();
TestSupport.setPrivateConnection(agent, connection);
return agent;
}
private static ResultSet resultSet(List<Map<String, Object>> rows) {
int[] index = {-1};
boolean[] wasNull = {false};
return proxy(ResultSet.class, (method, args) -> {
if ("next".equals(method.getName())) {
index[0] += 1;
return index[0] < rows.size();
}
if ("getString".equals(method.getName())) {
Object value = rows.get(index[0]).get(String.valueOf(args[0]));
wasNull[0] = value == null;
return value == null ? null : String.valueOf(value);
}
if ("getInt".equals(method.getName())) {
Object value = rows.get(index[0]).get(String.valueOf(args[0]));
wasNull[0] = value == null;
return value == null ? 0 : ((Number) value).intValue();
}
if ("wasNull".equals(method.getName())) {
return wasNull[0];
}
return defaultValue(method.getReturnType());
});
}
private static Map<String, Object> row(Object... values) {
Map<String, Object> row = new HashMap<>();
for (int index = 0; index < values.length; index += 2) {
row.put((String) values[index], values[index + 1]);
}
return row;
}
private static String procedureBody() {
return """
DECLARE VARIABLE V_NOTE VARCHAR(20);
BEGIN
O_ID = P_ID;
O_TEXT = P_LABEL || ':' || P_DOM;
SUSPEND;
END""";
}
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 type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, invocationHandler));
}
private static Object defaultValue(Class<?> type) {
if (type == Boolean.TYPE) return false;
if (type == Byte.TYPE) return (byte) 0;
if (type == Short.TYPE) return (short) 0;
if (type == Integer.TYPE) return 0;
if (type == Long.TYPE) return 0L;
if (type == Float.TYPE) return 0f;
if (type == Double.TYPE) return 0d;
if (type == Character.TYPE) return (char) 0;
return null;
}
private interface MethodHandler {
Object handle(Method method, Object[] args) throws Throwable;
}
}
+24 -1
View File
@@ -2,8 +2,31 @@ sourceSets {
main.java.srcDirs = ['src/main/java', '../h2/src/main/java']
}
configurations {
h2V1
h2V2
h2V3
}
dependencies {
implementation 'com.h2database:h2:2.1.214'
h2V1 'com.h2database:h2:1.4.200'
h2V2 'com.h2database:h2:2.1.214'
h2V3 'com.h2database:h2:2.4.240'
}
tasks.named('processResources') {
from(configurations.h2V1) {
into 'drivers'
rename { 'h2-1.4.200.jar' }
}
from(configurations.h2V2) {
into 'drivers'
rename { 'h2-2.1.214.jar' }
}
from(configurations.h2V3) {
into 'drivers'
rename { 'h2-2.4.240.jar' }
}
}
tasks.named('shadowJar') {
+32 -2
View File
@@ -1,6 +1,29 @@
configurations {
h2V1
h2V2
h2V3
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.h2database:h2:2.3.232'
h2V1 'com.h2database:h2:1.4.200'
h2V2 'com.h2database:h2:2.1.214'
h2V3 'com.h2database:h2:2.4.240'
testImplementation 'com.google.code.gson:gson:2.12.1'
}
tasks.named('processResources') {
from(configurations.h2V1) {
into 'drivers'
rename { 'h2-1.4.200.jar' }
}
from(configurations.h2V2) {
into 'drivers'
rename { 'h2-2.1.214.jar' }
}
from(configurations.h2V3) {
into 'drivers'
rename { 'h2-2.4.240.jar' }
}
}
tasks.named('shadowJar') {
@@ -8,3 +31,10 @@ tasks.named('shadowJar') {
attributes('Agent-Label': 'H2', 'Main-Class': 'com.dbx.agent.h2.H2Agent')
}
}
tasks.named('test') {
dependsOn tasks.named('shadowJar')
doFirst {
systemProperty 'dbx.h2.agent.jar', tasks.named('shadowJar').get().archiveFile.get().asFile.absolutePath
}
}
@@ -19,6 +19,7 @@ import com.dbx.agent.TriggerInfo;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
@@ -30,6 +31,9 @@ import java.util.Set;
public class H2Agent extends AbstractJdbcAgent {
private String databaseName = "";
private H2DriverLoader.LoadedDriver loadedDriver;
private H2DriverVersion driverVersion = H2DriverVersion.V3;
private int databaseMajorVersion = 2;
@Override
protected String driverClass() {
@@ -41,9 +45,62 @@ public class H2Agent extends AbstractJdbcAgent {
return buildUrl(params);
}
@Override
protected void loadDriver(ConnectParams params) throws Exception {
H2DriverVersion selected = H2DriverVersion.select(params);
if (selected == H2DriverVersion.CUSTOM) {
H2DriverLoader.LoadedDriver external = H2DriverLoader.loadExternal(
params.getJdbc_driver_paths(),
params.getJdbc_driver_class()
);
if (loadedDriver == null || !loadedDriver.identity().equals(external.identity())) {
replaceLoadedDriver(external);
} else {
external.classLoader().close();
}
} else if (loadedDriver == null || loadedDriver.version() != selected) {
replaceLoadedDriver(H2DriverLoader.load(selected));
}
driverVersion = selected;
}
private void replaceLoadedDriver(H2DriverLoader.LoadedDriver replacement) throws Exception {
H2DriverLoader.LoadedDriver previous = loadedDriver;
if (previous != null) {
try {
previous.classLoader().close();
} catch (Exception error) {
replacement.classLoader().close();
throw error;
}
}
loadedDriver = replacement;
}
@Override
protected Connection openConnection(ConnectParams params) throws Exception {
if (loadedDriver == null) {
throw new IllegalStateException("H2 JDBC driver was not loaded");
}
Connection opened = loadedDriver.driver().connect(buildJdbcUrl(params), buildConnectionProperties(params));
if (opened == null) {
throw new SQLException("H2 JDBC driver rejected URL: " + buildJdbcUrl(params));
}
return opened;
}
@Override
protected void afterConnect(ConnectParams params, Connection connection) {
databaseName = params.getDatabase();
databaseMajorVersion = unchecked(() -> connection.getMetaData().getDatabaseMajorVersion());
}
H2DriverVersion driverVersion() {
return driverVersion;
}
boolean isVersion2OrLater() {
return databaseMajorVersion >= 2;
}
@Override
@@ -118,7 +175,7 @@ public class H2Agent extends AbstractJdbcAgent {
List<Object> args = new ArrayList<>();
StringBuilder sql = new StringBuilder("SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?");
args.add(effectiveSchema);
appendH2TableTypePredicate(sql, args, constraints);
appendH2TableTypePredicate(sql, args, constraints, isVersion2OrLater());
MetadataSqlSupport.appendNameFilter(sql, args, "TABLE_NAME", constraints);
sql.append(" ORDER BY TABLE_NAME");
MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints);
@@ -143,9 +200,10 @@ public class H2Agent extends AbstractJdbcAgent {
result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment()));
}
try (var stmt = requireConnected().prepareStatement(
"SELECT ROUTINE_NAME, ROUTINE_TYPE FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME"
)) {
String sql = isVersion2OrLater()
? "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME"
: "SELECT ALIAS_NAME, CASE WHEN RETURNS_RESULT = 2 THEN 'FUNCTION' ELSE 'PROCEDURE' END FROM INFORMATION_SCHEMA.FUNCTION_ALIASES WHERE ALIAS_SCHEMA = ? ORDER BY ALIAS_NAME";
try (var stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, effectiveSchema);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
@@ -174,6 +232,9 @@ public class H2Agent extends AbstractJdbcAgent {
}
private List<ObjectInfo> queryConstrainedObjects(String schema, MetadataListConstraints constraints) {
if (!isVersion2OrLater()) {
return constraints.filterObjects(listObjects(schema));
}
return unchecked(() -> {
String effectiveSchema = resolveSchema(schema);
List<ObjectInfo> result = new ArrayList<>();
@@ -182,7 +243,7 @@ public class H2Agent extends AbstractJdbcAgent {
if (constraints.includesTableLikeTypes()) {
StringBuilder tableSql = new StringBuilder("SELECT TABLE_NAME AS OBJECT_NAME, TABLE_TYPE AS OBJECT_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?");
args.add(effectiveSchema);
appendH2TableTypePredicate(tableSql, args, constraints);
appendH2TableTypePredicate(tableSql, args, constraints, true);
MetadataSqlSupport.appendNameFilter(tableSql, args, "TABLE_NAME", constraints);
branches.add(tableSql.toString());
}
@@ -218,7 +279,9 @@ public class H2Agent extends AbstractJdbcAgent {
String effectiveSchema = resolveSchema(schema);
String sql = switch (objectType.toUpperCase(Locale.ROOT)) {
case "VIEW" -> "SELECT VIEW_DEFINITION FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?";
case "FUNCTION", "PROCEDURE" -> "SELECT ROUTINE_DEFINITION FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? AND ROUTINE_NAME = ?";
case "FUNCTION", "PROCEDURE" -> isVersion2OrLater()
? "SELECT ROUTINE_DEFINITION FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? AND ROUTINE_NAME = ?"
: "SELECT SOURCE FROM INFORMATION_SCHEMA.FUNCTION_ALIASES WHERE ALIAS_SCHEMA = ? AND ALIAS_NAME = ?";
default -> throw new IllegalArgumentException("Unsupported object type: " + objectType);
};
@@ -242,16 +305,17 @@ public class H2Agent extends AbstractJdbcAgent {
return unchecked(() -> {
String effectiveSchema = resolveSchema(schema);
Set<String> primaryKeys = new HashSet<>();
try (var stmt = requireConnected().prepareStatement(
"""
SELECT ic.COLUMN_NAME
FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic
JOIN INFORMATION_SCHEMA.INDEXES i
ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME
WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ?
AND i.INDEX_TYPE_NAME = 'PRIMARY KEY'
"""
)) {
String primaryKeySql = isVersion2OrLater()
? """
SELECT ic.COLUMN_NAME
FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic
JOIN INFORMATION_SCHEMA.INDEXES i
ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME
WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ?
AND i.INDEX_TYPE_NAME = 'PRIMARY KEY'
"""
: "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.INDEXES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND PRIMARY_KEY = TRUE";
try (var stmt = requireConnected().prepareStatement(primaryKeySql)) {
stmt.setString(1, effectiveSchema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
@@ -262,15 +326,11 @@ public class H2Agent extends AbstractJdbcAgent {
}
List<ColumnInfo> result = new ArrayList<>();
try (var stmt = requireConnected().prepareStatement(
"""
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT,
NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION
"""
)) {
String typeColumn = isVersion2OrLater() ? "DATA_TYPE" : "TYPE_NAME";
String columnSql = "SELECT COLUMN_NAME, " + typeColumn + " AS DBX_DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, "
+ "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)) {
stmt.setString(1, effectiveSchema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
@@ -278,11 +338,11 @@ public class H2Agent extends AbstractJdbcAgent {
String columnName = rs.getString("COLUMN_NAME");
result.add(new ColumnInfo(
columnName,
rs.getString("DATA_TYPE"),
rs.getString("DBX_DATA_TYPE"),
"YES".equals(rs.getString("IS_NULLABLE")),
rs.getString("COLUMN_DEFAULT"),
primaryKeys.contains(columnName),
null,
rs.getString("REMARKS"),
null,
intOrNull(rs, "NUMERIC_PRECISION"),
intOrNull(rs, "NUMERIC_SCALE"),
@@ -304,16 +364,24 @@ public class H2Agent extends AbstractJdbcAgent {
Map<String, Boolean> primaryMap = new HashMap<>();
Map<String, String> typeMap = new HashMap<>();
try (var stmt = requireConnected().prepareStatement(
"""
SELECT i.INDEX_NAME, ic.COLUMN_NAME, ic.IS_UNIQUE, i.INDEX_TYPE_NAME
FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic
JOIN INFORMATION_SCHEMA.INDEXES i
ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME
WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ?
ORDER BY i.INDEX_NAME, ic.ORDINAL_POSITION
"""
)) {
String indexSql = isVersion2OrLater()
? """
SELECT i.INDEX_NAME, ic.COLUMN_NAME, ic.IS_UNIQUE, i.INDEX_TYPE_NAME,
CASE WHEN i.INDEX_TYPE_NAME = 'PRIMARY KEY' THEN TRUE ELSE FALSE END AS IS_PRIMARY
FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic
JOIN INFORMATION_SCHEMA.INDEXES i
ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME
WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ?
ORDER BY i.INDEX_NAME, ic.ORDINAL_POSITION
"""
: """
SELECT INDEX_NAME, COLUMN_NAME, CASE WHEN NON_UNIQUE THEN FALSE ELSE TRUE END AS IS_UNIQUE,
INDEX_TYPE_NAME, PRIMARY_KEY AS IS_PRIMARY
FROM INFORMATION_SCHEMA.INDEXES
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY INDEX_NAME, ORDINAL_POSITION
""";
try (var stmt = requireConnected().prepareStatement(indexSql)) {
stmt.setString(1, effectiveSchema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
@@ -324,7 +392,7 @@ public class H2Agent extends AbstractJdbcAgent {
indexMap.computeIfAbsent(indexName, ignored -> new ArrayList<>()).add(columnName);
uniqueMap.put(indexName, rs.getBoolean("IS_UNIQUE"));
primaryMap.put(indexName, "PRIMARY KEY".equals(indexType));
primaryMap.put(indexName, rs.getBoolean("IS_PRIMARY"));
typeMap.put(indexName, indexType == null ? "" : indexType);
}
}
@@ -353,27 +421,17 @@ public class H2Agent extends AbstractJdbcAgent {
return unchecked(() -> {
String effectiveSchema = resolveSchema(schema);
List<ForeignKeyInfo> result = new ArrayList<>();
try (var stmt = requireConnected().prepareStatement(
"""
SELECT FK_NAME, FKCOLUMN_NAME, PKTABLE_NAME, PKCOLUMN_NAME
FROM INFORMATION_SCHEMA.CROSS_REFERENCES
WHERE FKTABLE_SCHEMA = ? AND FKTABLE_NAME = ?
ORDER BY FK_NAME
"""
)) {
stmt.setString(1, effectiveSchema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new ForeignKeyInfo(
rs.getString("FK_NAME"),
rs.getString("FKCOLUMN_NAME"),
rs.getString("PKTABLE_NAME"),
rs.getString("PKCOLUMN_NAME")
));
}
try (ResultSet rs = requireConnected().getMetaData().getImportedKeys(null, effectiveSchema, table)) {
while (rs.next()) {
result.add(new ForeignKeyInfo(
rs.getString("FK_NAME"),
rs.getString("FKCOLUMN_NAME"),
rs.getString("PKTABLE_NAME"),
rs.getString("PKCOLUMN_NAME")
));
}
}
result.sort(java.util.Comparator.comparing(ForeignKeyInfo::getName, java.util.Comparator.nullsLast(String::compareTo)));
return result;
});
}
@@ -383,14 +441,21 @@ public class H2Agent extends AbstractJdbcAgent {
return unchecked(() -> {
String effectiveSchema = resolveSchema(schema);
List<TriggerInfo> result = new ArrayList<>();
try (var stmt = requireConnected().prepareStatement(
"""
SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING
FROM INFORMATION_SCHEMA.TRIGGERS
WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ?
ORDER BY TRIGGER_NAME
"""
)) {
String triggerSql = isVersion2OrLater()
? """
SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING
FROM INFORMATION_SCHEMA.TRIGGERS
WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ?
ORDER BY TRIGGER_NAME
"""
: """
SELECT TRIGGER_NAME, TRIGGER_TYPE AS EVENT_MANIPULATION,
CASE WHEN BEFORE THEN 'BEFORE' ELSE 'AFTER' END AS ACTION_TIMING
FROM INFORMATION_SCHEMA.TRIGGERS
WHERE TRIGGER_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY TRIGGER_NAME
""";
try (var stmt = requireConnected().prepareStatement(triggerSql)) {
stmt.setString(1, effectiveSchema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
@@ -452,13 +517,18 @@ public class H2Agent extends AbstractJdbcAgent {
|| constraints.objectTypeAllowed("FUNCTION");
}
private static void appendH2TableTypePredicate(StringBuilder sql, List<Object> args, MetadataListConstraints constraints) {
private static void appendH2TableTypePredicate(
StringBuilder sql,
List<Object> args,
MetadataListConstraints constraints,
boolean version2OrLater
) {
if (!constraints.hasObjectTypes()) {
return;
}
List<String> types = new ArrayList<>();
if (constraints.tableTypeAllowed("TABLE")) {
types.add("BASE TABLE");
types.add(version2OrLater ? "BASE TABLE" : "TABLE");
}
if (constraints.tableTypeAllowed("VIEW")) {
types.add("VIEW");
@@ -0,0 +1,127 @@
package com.dbx.agent.h2;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.sql.Driver;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
final class H2DriverLoader {
private static final Object EXTRACTION_LOCK = new Object();
private static final Path CACHE_ROOT = Path.of(System.getProperty("java.io.tmpdir"), "dbx-h2-drivers");
private H2DriverLoader() {
}
static LoadedDriver load(H2DriverVersion version) throws Exception {
Path jar = extract(version);
URLClassLoader classLoader = new URLClassLoader(
new URL[]{jar.toUri().toURL()},
H2DriverLoader.class.getClassLoader()
);
try {
Driver driver = (Driver) Class.forName("org.h2.Driver", true, classLoader).getDeclaredConstructor().newInstance();
return new LoadedDriver(version, version.version(), driver, classLoader);
} catch (Exception error) {
closeAfterFailure(classLoader, error);
throw error;
} catch (LinkageError error) {
closeAfterFailure(classLoader, error);
throw error;
}
}
static LoadedDriver loadExternal(List<String> driverPaths, String driverClass) throws Exception {
if (driverPaths == null || driverPaths.isEmpty()) {
throw new IllegalArgumentException("Custom H2 driver profile requires at least one JDBC JAR path");
}
List<URL> urls = new ArrayList<>();
List<String> identities = new ArrayList<>();
for (String driverPath : driverPaths) {
Path path = Path.of(driverPath).toAbsolutePath().normalize();
if (!Files.isRegularFile(path)) {
throw new IOException("Custom H2 JDBC JAR does not exist: " + path);
}
urls.add(path.toUri().toURL());
identities.add(path + ":" + sha256(path));
}
String effectiveDriverClass = driverClass == null || driverClass.isBlank() ? "org.h2.Driver" : driverClass.trim();
URLClassLoader classLoader = new URLClassLoader(
urls.toArray(new URL[0]),
H2DriverLoader.class.getClassLoader()
);
try {
Driver driver = (Driver) Class.forName(effectiveDriverClass, true, classLoader).getDeclaredConstructor().newInstance();
return new LoadedDriver(H2DriverVersion.CUSTOM, effectiveDriverClass + "|" + String.join("|", identities), driver, classLoader);
} catch (Exception error) {
closeAfterFailure(classLoader, error);
throw error;
} catch (LinkageError error) {
closeAfterFailure(classLoader, error);
throw error;
}
}
private static Path extract(H2DriverVersion version) throws Exception {
byte[] bytes;
try (InputStream input = H2DriverLoader.class.getResourceAsStream(version.resourcePath())) {
if (input == null) {
throw new IOException("Bundled H2 driver is missing: " + version.resourcePath());
}
bytes = input.readAllBytes();
}
String digest = sha256(bytes);
Path directory = CACHE_ROOT.resolve(version.version() + "-" + digest.substring(0, 16));
Path target = directory.resolve("h2.jar");
synchronized (EXTRACTION_LOCK) {
Files.createDirectories(directory);
if (!Files.isRegularFile(target) || Files.size(target) != bytes.length || !digest.equals(sha256(target))) {
Path temporary = directory.resolve("h2.jar.tmp-" + UUID.randomUUID());
Files.write(temporary, bytes);
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (java.nio.file.AtomicMoveNotSupportedException ignored) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
return target;
}
private static String sha256(Path path) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(path)) {
byte[] buffer = new byte[8192];
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
}
}
}
return HexFormat.of().formatHex(digest.digest());
}
private static String sha256(byte[] bytes) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
}
private static void closeAfterFailure(URLClassLoader classLoader, Throwable error) {
try {
classLoader.close();
} catch (IOException closeError) {
error.addSuppressed(closeError);
}
}
record LoadedDriver(H2DriverVersion version, String identity, Driver driver, URLClassLoader classLoader) {
}
}
@@ -0,0 +1,65 @@
package com.dbx.agent.h2;
import com.dbx.agent.ConnectParams;
import java.util.Locale;
enum H2DriverVersion {
V1("h2-v1", "1.4.200", "/drivers/h2-1.4.200.jar", 1),
V2("h2-v2", "2.1.214", "/drivers/h2-2.1.214.jar", 2),
V3("h2-v3", "2.4.240", "/drivers/h2-2.4.240.jar", 3),
CUSTOM("h2-custom", "custom", "", 0);
private final String profile;
private final String version;
private final String resourcePath;
private final int storageFormat;
H2DriverVersion(String profile, String version, String resourcePath, int storageFormat) {
this.profile = profile;
this.version = version;
this.resourcePath = resourcePath;
this.storageFormat = storageFormat;
}
String profile() {
return profile;
}
String version() {
return version;
}
String resourcePath() {
return resourcePath;
}
int storageFormat() {
return storageFormat;
}
static H2DriverVersion select(ConnectParams params) throws Exception {
String profile = params.getDriver_profile();
String normalized = profile == null ? "" : profile.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "h2-v1" -> V1;
case "h2-v2", "h2-legacy" -> V2;
case "h2-v3" -> V3;
case "h2-custom" -> CUSTOM;
case "", "h2", "h2-auto", "h2_embedded", "h2_server" -> {
java.util.OptionalInt detected = H2FileFormatDetector.detect(H2Agent.buildUrl(params));
yield detected.isPresent() ? fromStorageFormat(detected.getAsInt()) : V3;
}
default -> throw new IllegalArgumentException("Unsupported H2 driver profile: " + profile);
};
}
private static H2DriverVersion fromStorageFormat(int format) {
return switch (format) {
case 1 -> V1;
case 2 -> V2;
case 3 -> V3;
default -> throw new IllegalArgumentException("Unsupported H2 database file format: " + format);
};
}
}
@@ -0,0 +1,187 @@
package com.dbx.agent.h2;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.OptionalInt;
final class H2FileFormatDetector {
private static final int BLOCK_SIZE = 4096;
private H2FileFormatDetector() {
}
static OptionalInt detect(String jdbcUrl) throws IOException {
Path base = localDatabaseBasePath(jdbcUrl);
if (base == null) {
return OptionalInt.empty();
}
Path pageStore = withSuffix(base, ".h2.db");
Path mvStore = withSuffix(base, ".mv.db");
boolean pageStoreExists = Files.isRegularFile(pageStore);
boolean mvStoreExists = Files.isRegularFile(mvStore);
if (pageStoreExists && mvStoreExists) {
throw new IOException("Both H2 PageStore and MVStore files exist for " + base + "; choose an explicit H2 driver profile");
}
if (pageStoreExists) {
return OptionalInt.of(1);
}
if (!mvStoreExists) {
return OptionalInt.empty();
}
return OptionalInt.of(readMvStoreFormat(mvStore));
}
private static int readMvStoreFormat(Path file) throws IOException {
byte[] bytes;
try (InputStream input = Files.newInputStream(file)) {
bytes = input.readNBytes(BLOCK_SIZE * 2);
}
if (bytes.length < BLOCK_SIZE * 2) {
throw new IOException("H2 MVStore file is too small to contain valid headers: " + file);
}
List<StoreHeader> headers = new ArrayList<>();
for (int block = 0; block < 2; block++) {
StoreHeader header = parseHeader(bytes, block * BLOCK_SIZE);
if (header != null) {
headers.add(header);
}
}
return headers.stream()
.max(Comparator.comparingLong(StoreHeader::version))
.orElseThrow(() -> new IOException("Cannot determine the H2 MVStore format for " + file + "; choose an explicit H2 driver profile"))
.format();
}
private static StoreHeader parseHeader(byte[] fileBytes, int offset) {
byte[] block = java.util.Arrays.copyOfRange(fileBytes, offset, offset + BLOCK_SIZE);
int start = 0;
int end = block.length;
while (start < end && block[start] <= ' ') {
start++;
}
while (start < end && block[end - 1] <= ' ') {
end--;
}
if (start >= end) {
return null;
}
String text = new String(block, start, end - start, StandardCharsets.ISO_8859_1);
int fletcherIndex = text.indexOf("fletcher:");
if (fletcherIndex <= 0 || text.charAt(fletcherIndex - 1) != ',') {
return null;
}
int checksumEnd = text.indexOf(',', fletcherIndex);
if (checksumEnd < 0) {
checksumEnd = text.length();
}
try {
long expected = Long.parseUnsignedLong(text.substring(fletcherIndex + "fletcher:".length(), checksumEnd), 16);
int actual = fletcher32(block, start, fletcherIndex - 1);
if (Integer.toUnsignedLong(actual) != expected) {
return null;
}
long version = readHexValue(text, "version", 0);
int format = Math.toIntExact(readHexValue(text, "format", 1));
return new StoreHeader(version, format);
} catch (RuntimeException ignored) {
return null;
}
}
private static long readHexValue(String text, String key, long defaultValue) {
String marker = key + ':';
int index = text.indexOf(marker);
if (index < 0 || (index > 0 && text.charAt(index - 1) != ',')) {
return defaultValue;
}
int start = index + marker.length();
int end = text.indexOf(',', start);
if (end < 0) {
end = text.length();
}
return Long.parseUnsignedLong(text.substring(start, end), 16);
}
private static int fletcher32(byte[] bytes, int offset, int length) {
int s1 = 0xffff;
int s2 = 0xffff;
int index = offset;
int evenEnd = offset + (length & ~1);
while (index < evenEnd) {
int batchEnd = Math.min(index + 720, evenEnd);
while (index < batchEnd) {
int value = ((bytes[index++] & 0xff) << 8) | (bytes[index++] & 0xff);
s2 += s1 += value;
}
s1 = (s1 & 0xffff) + (s1 >>> 16);
s2 = (s2 & 0xffff) + (s2 >>> 16);
}
if ((length & 1) != 0) {
int value = (bytes[index] & 0xff) << 8;
s2 += s1 += value;
}
s1 = (s1 & 0xffff) + (s1 >>> 16);
s2 = (s2 & 0xffff) + (s2 >>> 16);
return (s2 << 16) | s1;
}
private static Path localDatabaseBasePath(String jdbcUrl) {
String prefix = "jdbc:h2:";
if (jdbcUrl == null || !jdbcUrl.regionMatches(true, 0, prefix, 0, prefix.length())) {
return null;
}
String target = jdbcUrl.substring(prefix.length());
String lower = target.toLowerCase(java.util.Locale.ROOT);
if (lower.startsWith("tcp:") || lower.startsWith("ssl:") || lower.startsWith("mem:")
|| lower.startsWith("zip:") || lower.startsWith("split:")) {
return null;
}
if (lower.startsWith("file:")) {
target = target.substring("file:".length());
}
target = beforeOptions(target);
if (target.startsWith("~" + java.io.File.separator) || target.startsWith("~/") || target.startsWith("~\\")) {
target = System.getProperty("user.home") + target.substring(1);
}
if (target.isBlank()) {
return null;
}
Path path = Path.of(target).toAbsolutePath().normalize();
String value = path.toString().toLowerCase(java.util.Locale.ROOT);
if (value.endsWith(".mv.db")) {
return Path.of(path.toString().substring(0, path.toString().length() - ".mv.db".length()));
}
if (value.endsWith(".h2.db")) {
return Path.of(path.toString().substring(0, path.toString().length() - ".h2.db".length()));
}
return path;
}
private static String beforeOptions(String value) {
boolean escaped = false;
for (int index = 0; index < value.length(); index++) {
char current = value.charAt(index);
if (current == ';' && !escaped) {
return value.substring(0, index);
}
escaped = current == '\\' && !escaped;
if (current != '\\') {
escaped = false;
}
}
return value;
}
private static Path withSuffix(Path base, String suffix) {
return Path.of(base.toString() + suffix);
}
private record StoreHeader(long version, int format) {
}
}
@@ -0,0 +1,262 @@
package com.dbx.agent.h2;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
class H2AgentProcessTest {
@Test
@Timeout(45)
void shadedAgentSupportsIsolatedMultiVersionSessions() throws Exception {
Path agentJar = Path.of(System.getProperty("dbx.h2.agent.jar"));
Assertions.assertTrue(Files.isRegularFile(agentJar), () -> "Missing shaded H2 Agent: " + agentJar);
try (RpcProcess rpc = new RpcProcess(agentJar)) {
JsonObject handshake = rpc.result(rpc.request("handshake", new JsonObject()));
Assertions.assertEquals(2, handshake.get("protocolVersion").getAsInt());
Map<String, String> versions = new LinkedHashMap<>();
versions.put("h2-v1", "1.4.200");
versions.put("h2-v2", "2.1.214");
versions.put("h2-v3", "2.4.240");
int index = 0;
for (Map.Entry<String, String> entry : versions.entrySet()) {
String sessionId = "session-" + entry.getKey();
JsonObject connected = rpc.result(rpc.request("open_session", connectParams(sessionId, entry.getKey())));
Assertions.assertTrue(connected.get("ok").getAsBoolean(), entry.getKey());
JsonObject info = rpc.result(rpc.request("connection_info", sessionParams(sessionId)));
String driverVersion = info.getAsJsonObject("databaseInfo").get("driverVersion").getAsString();
Assertions.assertTrue(driverVersion.startsWith(entry.getValue()), entry.getKey() + ": " + driverVersion);
String table = "RPC_TABLE_" + (++index);
rpc.result(rpc.request("execute_query", queryParams(sessionId, "CREATE TABLE " + table + " (ID INT PRIMARY KEY, TEXT_VALUE VARCHAR(32))")));
rpc.result(rpc.request("execute_query", queryParams(sessionId, "INSERT INTO " + table + " VALUES (1, '" + entry.getKey() + "')")));
JsonArray tables = rpc.resultArray(rpc.request("list_tables", metadataParams(sessionId, "PUBLIC")));
Assertions.assertTrue(tables.asList().stream().anyMatch(value -> table.equals(value.getAsJsonObject().get("name").getAsString())), entry.getKey());
Assertions.assertEquals(entry.getKey(), firstCell(rpc.result(rpc.request("execute_query", queryParams(sessionId, "SELECT TEXT_VALUE FROM " + table)))));
}
JsonObject transaction = sessionParams("session-h2-v2");
JsonArray statements = new JsonArray();
statements.add("CREATE TABLE TX_PROBE (ID INT PRIMARY KEY)");
statements.add("INSERT INTO TX_PROBE VALUES (1)");
statements.add("INSERT INTO TX_PROBE VALUES (2)");
transaction.add("statements", statements);
Assertions.assertEquals(2, rpc.result(rpc.request("execute_transaction", transaction)).get("affected_rows").getAsLong());
Assertions.assertEquals("2", firstCell(rpc.result(rpc.request("execute_query", queryParams("session-h2-v2", "SELECT COUNT(*) FROM TX_PROBE")))));
JsonObject firstPageParams = queryParams("session-h2-v3", "SELECT X FROM SYSTEM_RANGE(1, 5)");
firstPageParams.addProperty("pageSize", 2);
JsonObject firstPage = rpc.result(rpc.request("execute_query_page", firstPageParams));
Assertions.assertEquals(2, firstPage.getAsJsonArray("rows").size());
Assertions.assertTrue(firstPage.get("has_more").getAsBoolean());
String querySessionId = firstPage.get("session_id").getAsString();
JsonObject fetchParams = sessionParams("session-h2-v3");
fetchParams.addProperty("sessionId", querySessionId);
fetchParams.addProperty("pageSize", 2);
JsonObject secondPage = rpc.result(rpc.request("fetch_query_page", fetchParams));
Assertions.assertEquals(2, secondPage.getAsJsonArray("rows").size());
Assertions.assertTrue(secondPage.get("has_more").getAsBoolean());
JsonObject finalPage = rpc.result(rpc.request("fetch_query_page", fetchParams));
Assertions.assertEquals(1, finalPage.getAsJsonArray("rows").size());
Assertions.assertFalse(finalPage.get("has_more").getAsBoolean());
int slowQueryId = rpc.send("execute_query", queryParams("session-h2-v3", "SELECT SUM(SIN(X)) FROM SYSTEM_RANGE(1, 1000000000)"));
Thread.sleep(100);
JsonObject cancelled = rpc.result(rpc.request("cancel_session", sessionParams("session-h2-v3")));
Assertions.assertTrue(cancelled.get("ok").getAsBoolean());
JsonObject slowResponse = rpc.await(slowQueryId, Duration.ofSeconds(15));
Assertions.assertTrue(slowResponse.has("error"), () -> "Expected cancelled query error, got " + slowResponse);
Assertions.assertTrue(rpc.result(rpc.request("validate_session", sessionParams("session-h2-v3"))).get("ok").getAsBoolean());
for (String profile : versions.keySet()) {
String sessionId = "session-" + profile;
Assertions.assertTrue(rpc.result(rpc.request("close_session", sessionParams(sessionId))).get("ok").getAsBoolean());
Assertions.assertTrue(rpc.request("validate_session", sessionParams(sessionId)).has("error"));
}
}
}
private static JsonObject connectParams(String sessionId, String profile) {
JsonObject params = sessionParams(sessionId);
params.addProperty("host", "");
params.addProperty("port", 0);
params.addProperty("database", "mem:rpc-" + profile + ";DB_CLOSE_DELAY=-1");
params.addProperty("username", "sa");
params.addProperty("password", "");
params.addProperty("connection_string", "");
params.addProperty("driver_profile", profile);
params.addProperty("ssl", false);
return params;
}
private static JsonObject sessionParams(String sessionId) {
JsonObject params = new JsonObject();
params.addProperty("agentSessionId", sessionId);
return params;
}
private static JsonObject queryParams(String sessionId, String sql) {
JsonObject params = sessionParams(sessionId);
params.addProperty("sql", sql);
return params;
}
private static JsonObject metadataParams(String sessionId, String schema) {
JsonObject params = sessionParams(sessionId);
params.addProperty("schema", schema);
return params;
}
private static String firstCell(JsonObject result) {
return result.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsString();
}
private static final class RpcProcess implements AutoCloseable {
private final Process process;
private final BufferedWriter input;
private final BlockingQueue<JsonObject> responses = new LinkedBlockingQueue<>();
private final Map<Integer, JsonObject> pending = new ConcurrentHashMap<>();
private final CompletableFuture<Void> ready = new CompletableFuture<>();
private final AtomicInteger requestIds = new AtomicInteger();
private final StringBuilder stderr = new StringBuilder();
private RpcProcess(Path agentJar) throws Exception {
Path java = Path.of(System.getProperty("java.home"), "bin", "java");
process = new ProcessBuilder(java.toString(), "-jar", agentJar.toString()).start();
input = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
startStdoutReader();
startStderrReader();
ready.get(10, TimeUnit.SECONDS);
}
private void startStdoutReader() {
Thread thread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
JsonObject message = JsonParser.parseString(line).getAsJsonObject();
if (message.has("ready")) {
ready.complete(null);
} else {
responses.add(message);
}
}
} catch (Throwable error) {
ready.completeExceptionally(error);
}
}, "h2-agent-rpc-stdout");
thread.setDaemon(true);
thread.start();
}
private void startStderrReader() {
Thread thread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
synchronized (stderr) {
stderr.append(line).append('\n');
}
}
} catch (Exception ignored) {
}
}, "h2-agent-rpc-stderr");
thread.setDaemon(true);
thread.start();
}
private JsonObject request(String method, JsonObject params) throws Exception {
return await(send(method, params), Duration.ofSeconds(15));
}
private int send(String method, JsonObject params) throws Exception {
int id = requestIds.incrementAndGet();
JsonObject request = new JsonObject();
request.addProperty("jsonrpc", "2.0");
request.addProperty("id", id);
request.addProperty("method", method);
request.add("params", params);
synchronized (input) {
input.write(request.toString());
input.newLine();
input.flush();
}
return id;
}
private JsonObject await(int id, Duration timeout) throws Exception {
JsonObject buffered = pending.remove(id);
if (buffered != null) {
return buffered;
}
long deadline = System.nanoTime() + timeout.toNanos();
while (System.nanoTime() < deadline) {
long remaining = deadline - System.nanoTime();
JsonObject response = responses.poll(Math.max(1, TimeUnit.NANOSECONDS.toMillis(remaining)), TimeUnit.MILLISECONDS);
if (response == null) {
break;
}
int responseId = response.get("id").getAsInt();
if (responseId == id) {
return response;
}
pending.put(responseId, response);
}
throw new AssertionError("Timed out waiting for H2 Agent response " + id + "; stderr=" + stderrText());
}
private JsonObject result(JsonObject response) {
Assertions.assertFalse(response.has("error"), () -> response + "\nstderr=" + stderrText());
return response.getAsJsonObject("result");
}
private JsonArray resultArray(JsonObject response) {
Assertions.assertFalse(response.has("error"), () -> response + "\nstderr=" + stderrText());
return response.getAsJsonArray("result");
}
private String stderrText() {
synchronized (stderr) {
return stderr.toString();
}
}
@Override
public void close() throws Exception {
if (process.isAlive()) {
try {
result(request("shutdown", new JsonObject()));
} catch (Throwable ignored) {
}
}
input.close();
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
process.waitFor(5, TimeUnit.SECONDS);
}
}
}
}
@@ -1,20 +1,34 @@
package com.dbx.agent.h2;
import com.dbx.agent.BaseDatabaseAgent;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.DatabaseAgent;
import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.QueryResult;
import com.dbx.agent.TableInfo;
import com.dbx.agent.test.JdbcExecutionBehaviorTest;
import com.dbx.agent.test.JdbcMetadataBehaviorTest;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.FileTime;
import java.util.List;
import java.util.Map;
import java.util.jar.JarOutputStream;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class H2AgentMigrationTest {
@TempDir
Path tempDirectory;
@Test
void agentExtendsBaseDatabaseAgent() {
Assertions.assertTrue(BaseDatabaseAgent.class.isAssignableFrom(H2Agent.class));
@@ -62,6 +76,273 @@ class H2AgentMigrationTest {
}
}
@Test
void loadsEveryBundledH2Driver() {
assertBundledDriver("h2-v1", H2DriverVersion.V1, "1.4.200");
assertBundledDriver("h2-v2", H2DriverVersion.V2, "2.1.214");
assertBundledDriver("h2-v3", H2DriverVersion.V3, "2.4.240");
assertBundledDriver("h2-legacy", H2DriverVersion.V2, "2.1.214");
}
@Test
void autoSelectsBundledDriverFromMvStoreFormat() {
assertAutoDetectedFileDriver(H2DriverVersion.V1);
assertAutoDetectedFileDriver(H2DriverVersion.V2);
assertAutoDetectedFileDriver(H2DriverVersion.V3);
}
@Test
void autoSelectsH2V1ForPageStoreDatabase() {
Path base = tempDirectory.resolve("pagestore");
H2Agent creator = new H2Agent();
creator.connect(profileParams("h2-v1", "file:" + base + ";MV_STORE=FALSE"));
try {
creator.executeQuery("CREATE TABLE PAGESTORE_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
} finally {
creator.disconnect();
}
Assertions.assertTrue(Files.isRegularFile(Path.of(base + ".h2.db")));
H2Agent detected = new H2Agent();
detected.connect(profileParams("h2", "file:" + base + ";MV_STORE=FALSE;IFEXISTS=TRUE"));
try {
Assertions.assertEquals(H2DriverVersion.V1, detected.driverVersion());
Assertions.assertEquals(List.of("PAGESTORE_PROBE"), detected.listTables("PUBLIC").stream().map(TableInfo::getName).toList());
} finally {
detected.disconnect();
}
}
@Test
void autoUsesLatestDriverForNewLocalDatabase() throws Exception {
Path base = tempDirectory.resolve("new-auto");
H2Agent agent = new H2Agent();
agent.connect(profileParams("h2", "file:" + base));
try {
Assertions.assertEquals(H2DriverVersion.V3, agent.driverVersion());
agent.executeQuery("CREATE TABLE LATEST_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
} finally {
agent.disconnect();
}
Assertions.assertEquals(3, H2FileFormatDetector.detect("jdbc:h2:file:" + base).orElseThrow());
}
@Test
void autoRejectsCorruptMvStoreWithoutModifyingIt() throws Exception {
Path base = tempDirectory.resolve("corrupt");
Path file = Path.of(base + ".mv.db");
byte[] corrupt = new byte[8192];
java.util.Arrays.fill(corrupt, (byte) 0x5a);
Files.write(file, corrupt);
FileTime modified = Files.getLastModifiedTime(file);
RuntimeException error = Assertions.assertThrows(
RuntimeException.class,
() -> new H2Agent().connect(profileParams("h2", "file:" + base + ";IFEXISTS=TRUE"))
);
Assertions.assertTrue(hasCauseMessage(error, "Cannot determine the H2 MVStore format"));
Assertions.assertArrayEquals(corrupt, Files.readAllBytes(file));
Assertions.assertEquals(modified, Files.getLastModifiedTime(file));
}
@Test
void autoRejectsAmbiguousPageStoreAndMvStoreFiles() throws Exception {
Path base = tempDirectory.resolve("ambiguous");
Files.write(Path.of(base + ".h2.db"), new byte[]{1});
Files.write(Path.of(base + ".mv.db"), new byte[8192]);
RuntimeException error = Assertions.assertThrows(
RuntimeException.class,
() -> new H2Agent().connect(profileParams("h2", "file:" + base + ";IFEXISTS=TRUE"))
);
Assertions.assertTrue(hasCauseMessage(error, "Both H2 PageStore and MVStore files exist"));
}
@Test
void autoHonorsIfExistsWithoutCreatingMissingDatabase() {
Path base = tempDirectory.resolve("missing-ifexists");
Assertions.assertThrows(
RuntimeException.class,
() -> new H2Agent().connect(profileParams("h2", "file:" + base + ";IFEXISTS=TRUE"))
);
Assertions.assertFalse(Files.exists(Path.of(base + ".mv.db")));
Assertions.assertFalse(Files.exists(Path.of(base + ".h2.db")));
}
@Test
void autoOpensExistingMvStoreReadOnlyWithoutChangingFile() throws Exception {
Path base = tempDirectory.resolve("readonly");
H2Agent creator = new H2Agent();
creator.connect(profileParams("h2-v2", "file:" + base));
try {
creator.executeQuery("CREATE TABLE READONLY_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
} finally {
creator.disconnect();
}
Path file = Path.of(base + ".mv.db");
byte[] before = Files.readAllBytes(file);
H2Agent reader = new H2Agent();
reader.connect(profileParams("h2", "file:" + base + ";ACCESS_MODE_DATA=r;IFEXISTS=TRUE"));
try {
Assertions.assertEquals(H2DriverVersion.V2, reader.driverVersion());
Assertions.assertEquals(0, reader.executeQuery("SELECT * FROM READONLY_PROBE", null, new ExecuteQueryOptions()).getRows().size());
Assertions.assertThrows(
RuntimeException.class,
() -> reader.executeQuery("INSERT INTO READONLY_PROBE VALUES (1)", null, new ExecuteQueryOptions())
);
} finally {
reader.disconnect();
}
Assertions.assertArrayEquals(before, Files.readAllBytes(file));
}
@Test
void encryptedMvStoreRequiresExplicitDriverProfile() {
Path base = tempDirectory.resolve("encrypted");
ConnectParams createParams = profileParams("h2-v3", "file:" + base + ";CIPHER=AES");
createParams.setPassword("file-secret user-secret");
H2Agent creator = new H2Agent();
creator.connect(createParams);
try {
creator.executeQuery("CREATE TABLE ENCRYPTED_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
} finally {
creator.disconnect();
}
ConnectParams autoParams = profileParams("h2", "file:" + base + ";CIPHER=AES;IFEXISTS=TRUE");
autoParams.setPassword("file-secret user-secret");
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(autoParams));
Assertions.assertTrue(hasCauseMessage(error, "choose an explicit H2 driver profile"));
ConnectParams explicitParams = profileParams("h2-v3", "file:" + base + ";CIPHER=AES;IFEXISTS=TRUE");
explicitParams.setPassword("file-secret user-secret");
H2Agent detected = new H2Agent();
detected.connect(explicitParams);
try {
Assertions.assertEquals(H2DriverVersion.V3, detected.driverVersion());
Assertions.assertEquals(List.of("ENCRYPTED_PROBE"), detected.listTables("PUBLIC").stream().map(TableInfo::getName).toList());
} finally {
detected.disconnect();
}
}
@Test
void loadsCustomH2DriverFromExternalClasspath() throws Exception {
Path helperJar = tempDirectory.resolve("helper.jar");
try (JarOutputStream ignored = new JarOutputStream(Files.newOutputStream(helperJar))) {
}
Path driverJar = copyBundledDriver("h2-2.1.214.jar");
ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom;DB_CLOSE_DELAY=-1");
params.setJdbc_driver_paths(List.of(helperJar.toString(), driverJar.toString()));
params.setJdbc_driver_class("org.h2.Driver");
H2Agent agent = new H2Agent();
agent.connect(params);
try {
Assertions.assertEquals(H2DriverVersion.CUSTOM, agent.driverVersion());
Assertions.assertTrue(agent.getDatabaseInfo().get("driverVersion").startsWith("2.1.214"));
agent.executeQuery("CREATE TABLE CUSTOM_DRIVER_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
Assertions.assertEquals(List.of("CUSTOM_DRIVER_PROBE"), agent.listTables("PUBLIC").stream().map(TableInfo::getName).toList());
} finally {
agent.disconnect();
}
}
@Test
void rejectsCustomH2DriverWithoutClasspath() {
ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom-missing;DB_CLOSE_DELAY=-1");
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(params));
Assertions.assertTrue(hasCauseMessage(error, "requires at least one JDBC JAR path"));
}
@Test
void rejectsMissingCustomH2DriverJar() {
ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom-missing-file;DB_CLOSE_DELAY=-1");
params.setJdbc_driver_paths(List.of(tempDirectory.resolve("missing-h2.jar").toString()));
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(params));
Assertions.assertTrue(hasCauseMessage(error, "Custom H2 JDBC JAR does not exist"));
}
@Test
void rejectsInvalidCustomH2DriverClass() throws Exception {
ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom-invalid-class;DB_CLOSE_DELAY=-1");
params.setJdbc_driver_paths(List.of(copyBundledDriver("h2-2.4.240.jar").toString()));
params.setJdbc_driver_class("example.MissingH2Driver");
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(params));
Assertions.assertTrue(hasCauseMessage(error, "example.MissingH2Driver"));
}
private void assertBundledDriver(String profile, H2DriverVersion expected, String expectedVersion) {
ConnectParams params = profileParams(profile, "mem:dbx-" + profile + ";DB_CLOSE_DELAY=-1");
H2Agent agent = new H2Agent();
agent.connect(params);
try {
Assertions.assertEquals(expected, agent.driverVersion());
Assertions.assertTrue(agent.getDatabaseInfo().get("driverVersion").startsWith(expectedVersion));
} finally {
agent.disconnect();
}
}
private void assertAutoDetectedFileDriver(H2DriverVersion expected) {
Path base = tempDirectory.resolve("format-" + expected.storageFormat());
ConnectParams createParams = profileParams(expected.profile(), "file:" + base);
H2Agent creator = new H2Agent();
creator.connect(createParams);
try {
creator.executeQuery("CREATE TABLE VERSION_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
} finally {
creator.disconnect();
}
ConnectParams autoParams = profileParams("h2", "file:" + base + ";IFEXISTS=TRUE");
H2Agent detected = new H2Agent();
detected.connect(autoParams);
try {
Assertions.assertEquals(expected, detected.driverVersion());
Assertions.assertEquals(List.of("VERSION_PROBE"), detected.listTables("PUBLIC").stream().map(TableInfo::getName).toList());
} finally {
detected.disconnect();
}
}
private static ConnectParams profileParams(String profile, String database) {
ConnectParams params = new ConnectParams("", 0, database, "sa", "", "", "", false);
params.setDriver_profile(profile);
return params;
}
private Path copyBundledDriver(String name) throws Exception {
Path target = tempDirectory.resolve(name);
try (InputStream input = H2AgentMigrationTest.class.getResourceAsStream("/drivers/" + name)) {
Assertions.assertNotNull(input, "Missing bundled test driver " + name);
Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING);
}
return target;
}
private static boolean hasCauseMessage(Throwable error, String expected) {
Throwable current = error;
while (current != null) {
if (current.getMessage() != null && current.getMessage().contains(expected)) {
return true;
}
current = current.getCause();
}
return false;
}
private static void assertH2DatabaseInfo(Map<String, String> info) {
Assertions.assertEquals("H2", info.get("productName"));
Assertions.assertFalse(info.get("productVersion").isEmpty());
@@ -73,6 +354,60 @@ class H2AgentMigrationTest {
}
}
class H2VersionMatrixTest {
@Test
void metadataAndExecutionWorkAcrossBundledVersions() {
for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) {
H2Agent agent = connect(profile);
try {
agent.executeQuery("CREATE TABLE PARENT_TABLE (ID INT PRIMARY KEY, NAME VARCHAR(64))", null, new ExecuteQueryOptions());
agent.executeQuery(
"CREATE TABLE CHILD_TABLE (ID INT PRIMARY KEY, PARENT_ID INT, AMOUNT DECIMAL(12, 2), "
+ "CONSTRAINT FK_CHILD_PARENT FOREIGN KEY (PARENT_ID) REFERENCES PARENT_TABLE(ID))",
null,
new ExecuteQueryOptions()
);
agent.executeQuery("CREATE INDEX IDX_CHILD_AMOUNT ON CHILD_TABLE(AMOUNT)", null, new ExecuteQueryOptions());
agent.executeQuery("CREATE VIEW CHILD_VIEW AS SELECT ID, AMOUNT FROM CHILD_TABLE", null, new ExecuteQueryOptions());
agent.executeQuery("CREATE ALIAS DBX_REVERSE FOR \"java.lang.Integer.reverse\"", null, new ExecuteQueryOptions());
agent.executeQuery("INSERT INTO PARENT_TABLE VALUES (1, 'parent')", null, new ExecuteQueryOptions());
agent.executeQuery("INSERT INTO CHILD_TABLE VALUES (1, 1, 12.34)", null, new ExecuteQueryOptions());
Assertions.assertTrue(agent.listSchemas().contains("PUBLIC"), profile);
Assertions.assertTrue(agent.listTables("PUBLIC").stream().map(TableInfo::getName).toList().containsAll(List.of("PARENT_TABLE", "CHILD_TABLE", "CHILD_VIEW")), profile);
List<ObjectInfo> objects = agent.listObjects("PUBLIC");
Assertions.assertTrue(objects.stream().anyMatch(object -> object.getName().equals("DBX_REVERSE")), profile);
Assertions.assertTrue(agent.getObjectSource("PUBLIC", "CHILD_VIEW", "VIEW").getSource().contains("CHILD_TABLE"), profile);
List<ColumnInfo> columns = agent.getColumns("PUBLIC", "CHILD_TABLE");
Assertions.assertEquals(List.of("ID", "PARENT_ID", "AMOUNT"), columns.stream().map(ColumnInfo::getName).toList(), profile);
Assertions.assertTrue(columns.stream().filter(column -> column.getName().equals("ID")).findFirst().orElseThrow().getIs_primary_key(), profile);
List<IndexInfo> indexes = agent.listIndexes("PUBLIC", "CHILD_TABLE");
Assertions.assertTrue(indexes.stream().anyMatch(index -> index.getName().equals("IDX_CHILD_AMOUNT")), profile);
Assertions.assertTrue(indexes.stream().anyMatch(IndexInfo::getIs_primary), profile);
List<ForeignKeyInfo> foreignKeys = agent.listForeignKeys("PUBLIC", "CHILD_TABLE");
Assertions.assertTrue(foreignKeys.stream().anyMatch(key -> key.getName().equals("FK_CHILD_PARENT")), profile);
QueryResult result = agent.executeQuery("SELECT DBX_REVERSE(1), AMOUNT FROM CHILD_TABLE", null, new ExecuteQueryOptions());
Assertions.assertEquals(1, result.getRows().size(), profile);
} finally {
agent.disconnect();
}
}
}
private static H2Agent connect(String profile) {
ConnectParams params = new ConnectParams("", 0, "mem:matrix-" + profile + ";DB_CLOSE_DELAY=-1", "sa", "", "", "", false);
params.setDriver_profile(profile);
H2Agent agent = new H2Agent();
agent.connect(params);
return agent;
}
}
class H2ExecutionBehaviorTest extends JdbcExecutionBehaviorTest {
@Override
protected DatabaseAgent createConnectedAgent(String databaseName) {
+5 -3
View File
@@ -1,6 +1,6 @@
# Hive JDBC to Go migration parity
Status date: 2026-08-11.
Status date: 2026-08-12.
The migration is not complete until a capability is implemented, covered by
automated tests, validated against a real compatible server, and included in
@@ -14,8 +14,10 @@ production parity.
JDBC profiles. The Go migration must not infer Hive 2 support from Hive 3/4.
- DBX protocol baseline: the Go Agent implements the same stdin/stdout JSON-RPC
methods used by the Java Agent.
- HS2 client protocol: Go requests `HIVE_CLI_SERVICE_PROTOCOL_V10`; Hive 3.1.3
and Hive 4.2.0 accepted it in live validation.
- HS2 client protocol: Go requests `HIVE_CLI_SERVICE_PROTOCOL_V6`, matching the
upstream GoHive compatibility baseline. Hive 3.1.3 and Hive 4.2.0 accepted
the newer V10 request in earlier live validation, but older Hive-compatible
servers can reject that unknown enum value during `OpenSession` decoding.
- The current completion pass validates the Go Agent only. JDBC is retained as
a historical behavior reference, not as a candidate in the secure discovery
or Kerberos validation path.
+102 -46
View File
@@ -20,15 +20,17 @@ import (
)
const (
defaultHivePort = 10000
defaultHiveDatabase = "default"
defaultHiveHTTPPath = "cliservice"
defaultHiveService = "hive"
defaultZooKeeperNamespace = "hiveserver2"
defaultConnectTimeout = 15 * time.Second
defaultRetryInterval = time.Second
defaultBrowserSSOTimeout = 120 * time.Second
defaultCookieName = "hive.server2.auth"
defaultHivePort = 10000
defaultHiveDatabase = "default"
defaultHiveHTTPPath = "cliservice"
defaultHiveService = "hive"
defaultImpalaService = "impala"
defaultZooKeeperNamespace = "hiveserver2"
resultSetUniqueColumnNames = "hive.resultset.use.unique.column.names"
defaultConnectTimeout = 15 * time.Second
defaultRetryInterval = time.Second
defaultBrowserSSOTimeout = 120 * time.Second
defaultCookieName = "hive.server2.auth"
)
type connectParams struct {
@@ -46,6 +48,7 @@ type connectParams struct {
ConnectTimeout int `json:"connect_timeout_secs"`
AgentJavaOptions []string `json:"agent_java_options"`
SessionRole string `json:"sessionRole"`
DatabaseType string `json:"database_type"`
}
type endpoint struct {
@@ -94,6 +97,7 @@ type zooKeeperKerberosConfig struct {
}
type connectionConfig struct {
DatabaseType string
Endpoints []endpoint
Database string
Username string
@@ -136,7 +140,9 @@ type connectionConfig struct {
}
func parseConnectionConfig(params connectParams) (connectionConfig, error) {
hasStructuredEndpoint := strings.TrimSpace(params.Host) != ""
config := connectionConfig{
DatabaseType: strings.ToLower(strings.TrimSpace(params.DatabaseType)),
Database: strings.TrimSpace(params.Database),
Username: params.Username,
Password: params.Password,
@@ -164,8 +170,9 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) {
CanonicalHostname: true,
},
}
if config.Database == "" {
config.Database = defaultHiveDatabase
if strings.EqualFold(params.DatabaseType, "impala") {
config.Auth = "NOSASL"
config.Kerberos.Service = defaultImpalaService
}
if params.ConnectTimeout > 0 {
config.ConnectTimeout = time.Duration(params.ConnectTimeout) * time.Second
@@ -175,45 +182,60 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) {
if err != nil {
return connectionConfig{}, err
}
if len(parsed.endpoints) > 0 {
config.Endpoints = parsed.endpoints
if !hasStructuredEndpoint {
if config.Database == "" && parsed.database != "" {
config.Database = parsed.database
}
if config.Username == "" && parsed.username != "" {
config.Username = parsed.username
}
if config.Password == "" && parsed.password != "" {
config.Password = parsed.password
}
}
if config.Database == "" {
config.Database = defaultHiveDatabase
}
urlSections := parseHiveParameterSections(params.URLParams)
values := urlSections.session
hiveConfs := urlSections.hiveConfs
hiveVars := urlSections.hiveVars
if hasStructuredEndpoint {
deleteHiveParameters(values, "user", "username", "password", "ssl")
} else {
values = mergeHiveParameters(parsed.parameters, values)
hiveConfs = mergeHiveConfAssignments(parsed.hiveConfs, hiveConfs)
hiveVars = mergeHiveAssignments(parsed.hiveVars, hiveVars)
if value, exists := firstParameter(values, "user", "username"); exists {
config.Username = value
}
if value, exists := firstParameter(values, "password"); exists {
config.Password = value
}
}
if err := applyHiveParameters(&config, values, hiveConfs); err != nil {
return connectionConfig{}, err
}
if isZooKeeperDiscovery(config.ServiceDiscoveryMode) && len(parsed.endpoints) > 0 {
// ZooKeeper discovery needs the complete endpoint list from the JDBC URL.
config.Endpoints = parsed.endpoints
} else if host := strings.TrimSpace(params.Host); host != "" {
// DBX resolves edits and transport layers before invoking the Agent. For
// direct connections that resolved endpoint must win over the persisted URL.
port := params.Port
if port <= 0 {
port = defaultHivePort
}
if host := strings.TrimSpace(params.Host); host != "" {
for _, value := range splitEndpoints(host) {
parsedEndpoint, endpointErr := parseEndpoint(value, port)
if endpointErr != nil {
return connectionConfig{}, endpointErr
}
config.Endpoints = append(config.Endpoints, parsedEndpoint)
for _, value := range splitEndpoints(host) {
parsedEndpoint, endpointErr := parseEndpoint(value, port)
if endpointErr != nil {
return connectionConfig{}, endpointErr
}
config.Endpoints = append(config.Endpoints, parsedEndpoint)
}
}
if parsed.database != "" {
config.Database = parsed.database
}
if parsed.username != "" {
config.Username = parsed.username
}
if parsed.password != "" {
config.Password = parsed.password
}
urlSections := parseHiveParameterSections(params.URLParams)
values := mergeHiveParameters(parsed.parameters, urlSections.session)
hiveConfs := mergeHiveAssignments(parsed.hiveConfs, urlSections.hiveConfs)
hiveVars := mergeHiveAssignments(parsed.hiveVars, urlSections.hiveVars)
if value, exists := firstParameter(values, "user", "username"); exists {
config.Username = value
}
if value, exists := firstParameter(values, "password"); exists {
config.Password = value
}
if err := applyHiveParameters(&config, values, hiveConfs); err != nil {
return connectionConfig{}, err
} else {
config.Endpoints = parsed.endpoints
}
applyOpenSessionVariables(&config, values, hiveConfs, hiveVars)
if err := applyDelegationToken(&config, values); err != nil {
@@ -242,6 +264,10 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) {
return config, nil
}
func isZooKeeperDiscovery(mode string) bool {
return strings.EqualFold(mode, "zookeeper") || strings.EqualFold(mode, "zookeeperha")
}
type parsedHiveConnection struct {
endpoints []endpoint
database string
@@ -465,6 +491,17 @@ func setCaseInsensitive(values map[string]string, key, value string) {
values[key] = value
}
func deleteHiveParameters(values map[string]string, keys ...string) {
for existing := range values {
for _, key := range keys {
if strings.EqualFold(existing, key) {
delete(values, existing)
break
}
}
}
}
func mergeHiveAssignments(first, second map[string]string) map[string]string {
result := make(map[string]string, len(first)+len(second))
for key, value := range first {
@@ -476,6 +513,17 @@ func mergeHiveAssignments(first, second map[string]string) map[string]string {
return result
}
func mergeHiveConfAssignments(first, second map[string]string) map[string]string {
result := make(map[string]string, len(first)+len(second))
for key, value := range first {
result[canonicalHiveConfKey(key)] = value
}
for key, value := range second {
result[canonicalHiveConfKey(key)] = value
}
return result
}
func applyHiveParameters(config *connectionConfig, values, hiveConfs map[string]string) error {
if value := parameter(values, "auth"); value != "" {
config.Auth = strings.ToUpper(value)
@@ -584,7 +632,7 @@ func applyHiveParameters(config *connectionConfig, values, hiveConfs map[string]
parameter(values, "clientprincipal"),
parameter(values, "userprincipal"),
)
kerberos.Service = firstNonEmpty(parameter(values, "service"), serviceFromPrincipal(kerberos.ServerPrincipal), defaultHiveService)
kerberos.Service = firstNonEmpty(parameter(values, "service"), serviceFromPrincipal(kerberos.ServerPrincipal), kerberos.Service)
kerberos.ServerName = parameter(values, "servername")
kerberos.Realm = firstNonEmpty(parameter(values, "realm"), realmFromPrincipal(kerberos.ClientPrincipal))
kerberos.ConfigPath = firstNonEmpty(parameter(values, "krb5conf"), parameter(values, "kerberosconfig"))
@@ -635,11 +683,12 @@ func applyHiveParameters(config *connectionConfig, values, hiveConfs map[string]
}
func applyOpenSessionVariables(config *connectionConfig, values, hiveConfs, hiveVars map[string]string) {
config.HiveConfiguration["set:hiveconf:"+resultSetUniqueColumnNames] = "false"
for key, value := range values {
lowerKey := strings.ToLower(key)
switch {
case strings.HasPrefix(lowerKey, "hiveconf:"):
config.HiveConfiguration["set:hiveconf:"+key[len("hiveconf:"):]] = value
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(key[len("hiveconf:"):])] = value
case strings.HasPrefix(lowerKey, "hivevar:"):
config.HiveConfiguration["set:hivevar:"+key[len("hivevar:"):]] = value
}
@@ -648,7 +697,7 @@ func applyOpenSessionVariables(config *connectionConfig, values, hiveConfs, hive
if strings.EqualFold(key, "hive.server2.transport.mode") || strings.EqualFold(key, "hive.server2.thrift.http.path") {
continue
}
config.HiveConfiguration["set:hiveconf:"+key] = value
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(key)] = value
}
for key, value := range hiveVars {
config.HiveConfiguration["set:hivevar:"+key] = value
@@ -667,6 +716,13 @@ func applyOpenSessionVariables(config *connectionConfig, values, hiveConfs, hive
}
}
func canonicalHiveConfKey(key string) string {
if strings.EqualFold(key, resultSetUniqueColumnNames) {
return resultSetUniqueColumnNames
}
return key
}
func hiveAssignmentValue(values map[string]string, key string) string {
for candidate, value := range values {
if strings.EqualFold(strings.TrimSpace(candidate), key) {
+246 -7
View File
@@ -176,6 +176,166 @@ func TestURLParamsOverrideConnectionString(t *testing.T) {
if config.TransportMode != "http" || config.HTTPPath != "proxy" {
t.Fatalf("URL params did not override connection string: %#v", config)
}
if len(config.Endpoints) != 1 || config.Endpoints[0] != (endpoint{Host: "hive.example.com", Port: 10000}) {
t.Fatalf("resolved form endpoint did not override connection string: %#v", config.Endpoints)
}
}
func TestResolvedDirectEndpointAndDatabasePreserveJDBCParameterSections(t *testing.T) {
config, err := parseConnectionConfig(connectParams{
Host: "127.0.0.1",
Port: 18080,
Database: "analytics",
ConnectionString: "jdbc:hive2://old.example.com:10000/default;transportMode=http;httpPath=gateway?hive.exec.dynamic.partition=true#SourceTable=events",
URLParams: "transportMode=http;httpPath=gateway?hive.exec.dynamic.partition=true#SourceTable=events",
})
if err != nil {
t.Fatal(err)
}
if len(config.Endpoints) != 1 || config.Endpoints[0] != (endpoint{Host: "127.0.0.1", Port: 18080}) {
t.Fatalf("unexpected resolved endpoint: %#v", config.Endpoints)
}
if config.Database != "analytics" {
t.Fatalf("resolved database = %q, want analytics", config.Database)
}
if config.TransportMode != "http" || config.HTTPPath != "gateway" {
t.Fatalf("JDBC session parameters were not preserved: %#v", config)
}
if config.HiveConfiguration["set:hiveconf:hive.exec.dynamic.partition"] != "true" {
t.Fatalf("JDBC hiveConfs were not preserved: %#v", config.HiveConfiguration)
}
if config.HiveConfiguration["set:hivevar:SourceTable"] != "events" {
t.Fatalf("JDBC hiveVars were not preserved: %#v", config.HiveConfiguration)
}
}
func TestStructuredFieldsOverridePersistedJDBCValues(t *testing.T) {
config, err := parseConnectionConfig(connectParams{
Host: "127.0.0.1",
Port: 18080,
Username: "edited-user",
Password: "edited-password",
ConnectionString: "jdbc:hive2://old-user:old-password@old.example.com:10000/old_database;transportMode=http;auth=LDAP;ssl=true;serviceDiscoveryMode=zooKeeper?hive.exec.dynamic.partition=true#SourceTable=events",
URLParams: "user=url-user;password=url-password;ssl=true?hive.exec.dynamic.partition=true#SourceTable=events",
})
if err != nil {
t.Fatal(err)
}
if len(config.Endpoints) != 1 || config.Endpoints[0] != (endpoint{Host: "127.0.0.1", Port: 18080}) {
t.Fatalf("unexpected resolved endpoint: %#v", config.Endpoints)
}
if config.Database != defaultHiveDatabase {
t.Fatalf("database = %q, want %q", config.Database, defaultHiveDatabase)
}
if config.Username != "edited-user" || config.Password != "edited-password" {
t.Fatalf("structured credentials were overwritten: username=%q password=%q", config.Username, config.Password)
}
if config.TransportMode != "binary" || config.Auth != "NONE" || config.ServiceDiscoveryMode != "" {
t.Fatalf("removed JDBC parameters remained active: %#v", config)
}
if config.TLSConfig != nil {
t.Fatal("disabled structured SSL was re-enabled by persisted JDBC parameters")
}
if config.HiveConfiguration["set:hiveconf:hive.exec.dynamic.partition"] != "true" {
t.Fatalf("current hiveConfs were not preserved: %#v", config.HiveConfiguration)
}
if config.HiveConfiguration["set:hivevar:SourceTable"] != "events" {
t.Fatalf("current hiveVars were not preserved: %#v", config.HiveConfiguration)
}
}
func TestConnectionStringOnlyKeepsJDBCValues(t *testing.T) {
config, err := parseConnectionConfig(connectParams{
ConnectionString: "jdbc:hive2://raw-user:raw-password@hive.example.com:10001/analytics;transportMode=http;ssl=true",
})
if err != nil {
t.Fatal(err)
}
if config.Database != "analytics" || config.Username != "raw-user" || config.Password != "raw-password" {
t.Fatalf("connection-string-only fields were not preserved: %#v", config)
}
if config.TransportMode != "http" || config.TLSConfig == nil {
t.Fatalf("connection-string-only parameters were not preserved: %#v", config)
}
}
func TestZooKeeperDiscoveryKeepsAllJDBCEndpoints(t *testing.T) {
config, err := parseConnectionConfig(connectParams{
Host: "127.0.0.1",
Port: 12181,
Database: "analytics",
ConnectionString: "jdbc:hive2://zk1.example.com:2181,zk2.example.com:2181/default;serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2",
URLParams: "serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2",
})
if err != nil {
t.Fatal(err)
}
if len(config.Endpoints) != 2 || config.Endpoints[0].Host != "zk1.example.com" || config.Endpoints[1].Host != "zk2.example.com" {
t.Fatalf("ZooKeeper discovery endpoints were not preserved: %#v", config.Endpoints)
}
if config.Database != "analytics" {
t.Fatalf("resolved database = %q, want analytics", config.Database)
}
}
func TestImpalaDefaultsToNoSASL(t *testing.T) {
config, err := parseConnectionConfig(connectParams{
DatabaseType: "impala",
Host: "impala.example.com",
Port: 21050,
Database: "analytics",
})
if err != nil {
t.Fatal(err)
}
if config.Auth != "NOSASL" {
t.Fatalf("unexpected Impala auth mode: %q", config.Auth)
}
if config.Kerberos.Service != "impala" {
t.Fatalf("unexpected Impala Kerberos service: %q", config.Kerberos.Service)
}
}
func TestImpalaExplicitAuthenticationOverridesDefaults(t *testing.T) {
config, err := parseConnectionConfig(connectParams{
DatabaseType: "impala",
Host: "impala.example.com",
Port: 21050,
URLParams: "auth=NONE;service=custom",
})
if err != nil {
t.Fatal(err)
}
if config.Auth != "NONE" || config.Kerberos.Service != "custom" {
t.Fatalf("explicit Impala authentication was not preserved: %#v", config.Kerberos)
}
}
func TestImpalaLDAPHTTPSSLConfiguration(t *testing.T) {
config, err := parseConnectionConfig(connectParams{
DatabaseType: "impala",
Host: "impala.example.com",
Port: 21050,
Username: "alice",
Password: "secret",
URLParams: "auth=LDAP;transportMode=http;httpPath=cliservice",
SSL: true,
})
if err != nil {
t.Fatal(err)
}
if config.Auth != "LDAP" || config.TransportMode != "http" || config.HTTPPath != "cliservice" {
t.Fatalf("unexpected Impala LDAP transport config: %#v", config)
}
if config.Username != "alice" || config.Password != "secret" {
t.Fatalf("Impala LDAP credentials were not preserved: %q / %q", config.Username, config.Password)
}
if config.TLSConfig == nil || config.TLSConfig.ServerName != "impala.example.com" {
t.Fatalf("unexpected Impala LDAP TLS config: %#v", config.TLSConfig)
}
if config.Kerberos.Service != defaultImpalaService {
t.Fatalf("unexpected Impala service default: %q", config.Kerberos.Service)
}
}
func TestParseStandardJDBCURLSectionsAndCredentials(t *testing.T) {
@@ -193,8 +353,9 @@ func TestParseStandardJDBCURLSectionsAndCredentials(t *testing.T) {
t.Fatalf("deprecated Hive conf transport settings were not applied: %#v", config)
}
want := map[string]string{
"set:hiveconf:hive.exec.dynamic.partition": "false",
"set:hivevar:SourceTable": "override",
"set:hiveconf:hive.resultset.use.unique.column.names": "false",
"set:hiveconf:hive.exec.dynamic.partition": "false",
"set:hivevar:SourceTable": "override",
}
if !reflect.DeepEqual(config.HiveConfiguration, want) {
t.Fatalf("unexpected OpenSession configuration: %#v", config.HiveConfiguration)
@@ -210,17 +371,95 @@ func TestOpenSessionCompatibilityVariablesFromSessionParams(t *testing.T) {
t.Fatal(err)
}
want := map[string]string{
"hive.server2.proxy.user": "alice",
"set:hiveconf:hive.create.as.external.legacy": "true",
"set:hiveconf:hive.exec.compress.output": "true",
"set:hivevar:source": "events",
"set:hivevar:wmpool": "etl",
"hive.server2.proxy.user": "alice",
"set:hiveconf:hive.create.as.external.legacy": "true",
"set:hiveconf:hive.exec.compress.output": "true",
"set:hiveconf:hive.resultset.use.unique.column.names": "false",
"set:hivevar:source": "events",
"set:hivevar:wmpool": "etl",
}
if !reflect.DeepEqual(config.HiveConfiguration, want) {
t.Fatalf("unexpected OpenSession compatibility variables: %#v", config.HiveConfiguration)
}
}
func TestOpenSessionUsesLeafResultLabelsUnlessExplicitlyOverridden(t *testing.T) {
tests := []struct {
name string
connection string
urlParams string
want string
}{
{name: "default", want: "false"},
{
name: "JDBC hiveconf override",
connection: "jdbc:hive2://hs2.example.com:10000/default?hive.resultset.use.unique.column.names=true",
want: "true",
},
{
name: "URL hiveconf override",
urlParams: "?HIVE.RESULTSET.USE.UNIQUE.COLUMN.NAMES=true",
want: "true",
},
{
name: "URL hiveconf wins over JDBC hiveconf",
connection: "jdbc:hive2://hs2.example.com:10000/default?hive.resultset.use.unique.column.names=true",
urlParams: "?hive.resultset.use.unique.column.names=false",
want: "false",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
params := connectParams{Host: "hs2.example.com", ConnectionString: test.connection, URLParams: test.urlParams}
if test.connection != "" {
params.Host = ""
}
config, err := parseConnectionConfig(params)
if err != nil {
t.Fatal(err)
}
const key = "set:hiveconf:hive.resultset.use.unique.column.names"
if got := config.HiveConfiguration[key]; got != test.want {
t.Fatalf("%s = %q, want %q; config=%#v", key, got, test.want, config.HiveConfiguration)
}
matches := 0
for candidate := range config.HiveConfiguration {
if strings.EqualFold(candidate, key) {
matches++
}
}
if matches != 1 {
t.Fatalf("result label hiveconf must appear exactly once: %#v", config.HiveConfiguration)
}
})
}
}
func TestHiveAssignmentMergeCanonicalizesOnlyResultLabelSetting(t *testing.T) {
first := map[string]string{
"CaseSensitive": "first",
"HIVE.RESULTSET.USE.UNIQUE.COLUMN.NAMES": "true",
}
second := map[string]string{
"casesensitive": "second",
"hive.resultset.use.unique.column.names": "false",
}
hiveConfs := mergeHiveConfAssignments(first, second)
if got := hiveConfs[resultSetUniqueColumnNames]; got != "false" {
t.Fatalf("result-label setting override = %q, want false; values=%#v", got, hiveConfs)
}
if got := len(hiveConfs); got != 3 {
t.Fatalf("unrelated case-distinct Hive confs were collapsed: %#v", hiveConfs)
}
hiveVars := mergeHiveAssignments(first, second)
if got := len(hiveVars); got != 4 {
t.Fatalf("case-distinct Hive variables were collapsed: %#v", hiveVars)
}
}
func TestParseHiveJDBCClientCompatibilityOptions(t *testing.T) {
directory := t.TempDir()
initPath := filepath.Join(directory, "hive-init.sql")
+5
View File
@@ -89,6 +89,7 @@ func newDiscoveryConnector(config connectionConfig) *discoveryConnector {
BrowserResponsePort: config.BrowserResponsePort,
BrowserResponseTimeout: config.BrowserResponseTimeout,
BrowserDisableSSLCheck: config.BrowserDisableSSLCheck,
WaitForNonQueryCompletion: waitsForNonQueryCompletion(config.DatabaseType),
})
},
driver: &gohive.Driver{},
@@ -97,6 +98,10 @@ func newDiscoveryConnector(config connectionConfig) *discoveryConnector {
}
}
func waitsForNonQueryCompletion(databaseType string) bool {
return strings.EqualFold(databaseType, "impala") || strings.EqualFold(databaseType, "kyuubi")
}
func gssapiOptionsFromKerberos(config kerberosConfig) gosasl.GSSAPIOptions {
return gosasl.GSSAPIOptions{
ConfigPath: config.ConfigPath,
+9
View File
@@ -114,6 +114,15 @@ func TestNormalizeHiveAuth(t *testing.T) {
}
}
func TestWaitsForKyuubiAndImpalaNonQueryCompletion(t *testing.T) {
if !waitsForNonQueryCompletion("kyuubi") || !waitsForNonQueryCompletion("KYUUBI") || !waitsForNonQueryCompletion("impala") {
t.Fatal("Kyuubi and Impala must wait for asynchronous non-query operations")
}
if waitsForNonQueryCompletion("hive") {
t.Fatal("Hive must retain its existing non-query behavior")
}
}
func TestKerberosServiceForEndpoint(t *testing.T) {
target := endpoint{Host: "hs2.example.com", Port: 10000, Principal: "hive/hs2.example.com@EXAMPLE.COM"}
discovered := connectionConfig{Kerberos: kerberosConfig{Service: "hive"}}
+5 -1
View File
@@ -431,7 +431,11 @@ func (server *server) dispatch(method string, params map[string]json.RawMessage)
case "list_constraints", "list_partitions", "list_subpartitions":
return []any{}, false, nil
case "get_object_source":
result, err := server.getTableDDL(stringParam(params, "schema"), firstNonEmpty(stringParam(params, "name"), stringParam(params, "table")))
result, err := server.getObjectSource(
stringParam(params, "schema"),
firstNonEmpty(stringParam(params, "name"), stringParam(params, "table")),
stringParam(params, "object_type"),
)
return result, false, err
case "get_table_ddl":
result, err := server.getTableDDL(stringParam(params, "schema"), stringParam(params, "table"))
+140 -18
View File
@@ -40,6 +40,13 @@ type objectInfo struct {
Valid *bool `json:"valid,omitempty"`
}
type objectSource struct {
Name string `json:"name"`
ObjectType string `json:"object_type"`
Schema *string `json:"schema"`
Source string `json:"source"`
}
type columnInfo struct {
Name string `json:"name"`
DataType string `json:"data_type"`
@@ -197,6 +204,18 @@ func (server *server) connectionInfo() (map[string]any, error) {
username = current
}
}
productName := "Apache Hive"
compatibilityMode := "hive"
driverName := "DBX Hive Go Agent"
if strings.EqualFold(server.params.DatabaseType, "kyuubi") {
productName = "Apache Kyuubi"
compatibilityMode = "kyuubi"
driverName = "DBX Kyuubi Go Agent"
} else if strings.EqualFold(server.params.DatabaseType, "impala") || strings.Contains(strings.ToLower(version), "impalad version") {
productName = "Apache Impala"
compatibilityMode = "impala"
driverName = "DBX Impala Go Agent"
}
return map[string]any{
"database": server.config.Database,
"schema": server.config.Database,
@@ -204,13 +223,13 @@ func (server *server) connectionInfo() (map[string]any, error) {
"version": version,
"sqlDialect": "HIVE",
"identifierQuote": "`",
"compatibilityMode": "hive",
"compatibilityMode": compatibilityMode,
"databaseInfo": map[string]string{
"productName": "Apache Hive",
"productName": productName,
"productVersion": version,
"unquotedIdentifierCase": "mixed",
"quotedIdentifierCase": "mixed",
"driverName": "DBX Hive Go Agent",
"driverName": driverName,
"driverVersion": "gohive-v2.1.0",
},
}, nil
@@ -305,29 +324,119 @@ func (server *server) listTables(schema string, constraints metadataListConstrai
sort.Slice(values, func(first, second int) bool { return values[first].Name < values[second].Name })
return applyMetadataWindow(values, constraints.Offset, constraints.Limit), nil
}
fallbackStatement := "SHOW TABLES IN " + quoteHiveIdentifier(schema)
if len(requestedTypes) > 0 && !containsString(requestedTypes, "TABLE") {
fallbackStatement = "SHOW VIEWS IN " + quoteHiveIdentifier(schema)
type fallbackQuery struct {
operation string
statement string
objectType string
}
result, err := server.executeQuery(queryOptions{
SQL: fallbackStatement,
MaxRows: metadataQueryLimit,
})
if err != nil {
return nil, fmt.Errorf("HiveServer2 metadata failed (%v); SHOW TABLES fallback failed: %w", metadataErr, err)
fallbackQueries := make([]fallbackQuery, 0, 2)
if containsString(requestedTypes, "TABLE") {
fallbackQueries = append(fallbackQueries, fallbackQuery{
operation: "SHOW TABLES",
statement: "SHOW TABLES IN " + quoteHiveIdentifier(schema),
objectType: "TABLE",
})
}
values := make([]tableInfo, 0, len(result.Rows))
for _, row := range result.Rows {
name := showTablesRowName(result.Columns, row)
if name == "" || !metadataNameMatches(name, constraints.Filter) {
continue
if containsString(requestedTypes, "VIEW") || containsString(requestedTypes, "MATERIALIZED VIEW") {
fallbackQueries = append(fallbackQueries, fallbackQuery{
operation: "SHOW VIEWS",
statement: "SHOW VIEWS IN " + quoteHiveIdentifier(schema),
objectType: "VIEW",
})
}
objectsByName := make(map[string]tableInfo)
tableFallbackSucceeded := false
for _, fallback := range fallbackQueries {
result, err := server.executeQuery(queryOptions{SQL: fallback.statement, MaxRows: metadataQueryLimit})
if err != nil {
// Older Hive and Impala versions can list tables but do not support SHOW VIEWS.
// Keep the usable table result for mixed requests; explicit view requests still fail.
if fallback.objectType == "VIEW" && tableFallbackSucceeded && showViewsUnsupported(err) {
continue
}
return nil, fmt.Errorf("HiveServer2 metadata failed (%v); %s fallback failed: %w", metadataErr, fallback.operation, err)
}
values = append(values, tableInfo{Name: name, TableType: "TABLE", Comment: nil})
if fallback.objectType == "TABLE" {
tableFallbackSucceeded = true
}
for _, row := range result.Rows {
name := showTablesRowName(result.Columns, row)
if name == "" || !metadataNameMatches(name, constraints.Filter) {
continue
}
candidate := tableInfo{Name: name, TableType: fallback.objectType, Comment: nil}
if existing, ok := objectsByName[name]; ok && existing.TableType == "VIEW" && candidate.TableType != "VIEW" {
continue
}
objectsByName[name] = candidate
}
}
values := make([]tableInfo, 0, len(objectsByName))
for _, value := range objectsByName {
values = append(values, value)
}
sort.Slice(values, func(first, second int) bool { return values[first].Name < values[second].Name })
return applyMetadataWindow(values, constraints.Offset, constraints.Limit), nil
}
func showViewsUnsupported(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
message := strings.ToLower(err.Error())
operationalMarkers := []string{
"permission",
"access denied",
"not authorized",
"unauthorized",
"authentication",
"authorization",
"timeout",
"timed out",
"deadline exceeded",
"cancelled",
"canceled",
"transport",
"connection",
"broken pipe",
"network",
}
for _, marker := range operationalMarkers {
if strings.Contains(message, marker) {
return false
}
}
explicitMarkers := []string{
"unsupported",
"not supported",
"not implemented",
"unknown statement",
"unrecognized statement",
}
for _, marker := range explicitMarkers {
if strings.Contains(message, marker) {
return true
}
}
parseMarkers := []string{
"parseexception",
"parse error",
"syntax error",
"mismatched input",
"cannot recognize input",
"no viable alternative",
}
for _, marker := range parseMarkers {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func (server *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) {
if !acceptsHiveTable(constraints.ObjectTypes) {
return []objectInfo{}, nil
@@ -475,6 +584,19 @@ func (server *server) getTableDDL(schema, table string) (string, error) {
return strings.Join(lines, "\n") + "\n", nil
}
func (server *server) getObjectSource(schema, name, objectType string) (objectSource, error) {
source, err := server.getTableDDL(schema, name)
if err != nil {
return objectSource{}, err
}
return objectSource{
Name: name,
ObjectType: strings.ToUpper(objectType),
Schema: optionalString(schema),
Source: source,
}, nil
}
func (server *server) getExplainInfo(sqlText string) (string, error) {
sqlText = trimStatementSQL(sqlText)
if sqlText == "" {
+273 -4
View File
@@ -3,8 +3,10 @@ package main
import (
"context"
"database/sql/driver"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/t8y2/dbx/agents/go-common/gohive"
@@ -19,6 +21,77 @@ func TestShowTablesRowName(t *testing.T) {
}
}
func TestKyuubiConnectionInfoReportsNativeIdentity(t *testing.T) {
behavior := &scriptedBehavior{
query: func(ctx context.Context, query string) (driver.Rows, error) {
switch query {
case "SELECT VERSION()":
return newScriptedRows(ctx, []string{"version"}, []string{"STRING"}, [][]driver.Value{{"3.5.8"}}), nil
case "SELECT CURRENT_USER()":
return newScriptedRows(ctx, []string{"current_user"}, []string{"STRING"}, [][]driver.Value{{"dbx"}}), nil
default:
return nil, errors.New("unexpected query: " + query)
}
},
}
server := newScriptedServer(t, behavior)
server.params.DatabaseType = "kyuubi"
server.config.Username = "fallback"
info, err := server.connectionInfo()
if err != nil {
t.Fatal(err)
}
if info["compatibilityMode"] != "kyuubi" || info["username"] != "dbx" || info["version"] != "3.5.8" {
t.Fatalf("unexpected Kyuubi connection info: %#v", info)
}
databaseInfo, ok := info["databaseInfo"].(map[string]string)
if !ok || databaseInfo["productName"] != "Apache Kyuubi" || databaseInfo["driverName"] != "DBX Kyuubi Go Agent" {
t.Fatalf("unexpected Kyuubi database identity: %#v", info["databaseInfo"])
}
}
func TestGetObjectSourceReturnsProtocolObject(t *testing.T) {
behavior := &scriptedBehavior{
query: func(ctx context.Context, query string) (driver.Rows, error) {
if query != "SHOW CREATE TABLE `dbx_kyuubi_demo`.`high_value_orders`" {
t.Fatalf("unexpected query: %q", query)
}
return newScriptedRows(
ctx,
[]string{"createtab_stmt"},
[]string{"STRING"},
[][]driver.Value{
{"CREATE VIEW dbx_kyuubi_demo.high_value_orders"},
{"AS SELECT id, customer, amount FROM dbx_kyuubi_demo.orders WHERE amount >= 50"},
},
), nil
},
}
server := newScriptedServer(t, behavior)
defer server.disconnect()
result, _, err := server.dispatch("get_object_source", map[string]json.RawMessage{
"schema": json.RawMessage(`"dbx_kyuubi_demo"`),
"name": json.RawMessage(`"high_value_orders"`),
"object_type": json.RawMessage(`"VIEW"`),
})
if err != nil {
t.Fatal(err)
}
source, ok := result.(objectSource)
if !ok {
t.Fatalf("get_object_source returned %T instead of objectSource", result)
}
if source.Name != "high_value_orders" || source.ObjectType != "VIEW" || source.Schema == nil || *source.Schema != "dbx_kyuubi_demo" {
t.Fatalf("unexpected object source metadata: %#v", source)
}
expected := "CREATE VIEW dbx_kyuubi_demo.high_value_orders\nAS SELECT id, customer, amount FROM dbx_kyuubi_demo.orders WHERE amount >= 50\n"
if source.Source != expected {
t.Fatalf("unexpected object source DDL: %q", source.Source)
}
}
func TestListDatabasesUsesShowDatabasesBeforeHiveServerMetadata(t *testing.T) {
behavior := &scriptedBehavior{
query: func(ctx context.Context, sql string) (driver.Rows, error) {
@@ -135,7 +208,7 @@ func TestGetColumnsUsesHiveServerMetadataFields(t *testing.T) {
}
return metadataResult(
[]string{"COLUMN_NAME", "TYPE_NAME", "COLUMN_SIZE", "DECIMAL_DIGITS", "NULLABLE", "REMARKS", "COLUMN_DEF"},
[]driver.Value{"name", "string", int64(255), nil, int64(1), "display name", "unknown"},
[]driver.Value{"name", "string", int64(255), nil, int64(1), "显示名称", "unknown"},
[]driver.Value{"amount", "decimal(18,2)", int64(18), int64(2), int64(0), nil, nil},
), nil
},
@@ -149,7 +222,7 @@ func TestGetColumnsUsesHiveServerMetadataFields(t *testing.T) {
if len(values) != 2 {
t.Fatalf("unexpected columns: %#v", values)
}
if values[0].Name != "name" || !values[0].IsNullable || values[0].CharacterMaximumLength == nil || *values[0].CharacterMaximumLength != 255 || values[0].ColumnDefault == nil || *values[0].ColumnDefault != "unknown" {
if values[0].Name != "name" || !values[0].IsNullable || values[0].CharacterMaximumLength == nil || *values[0].CharacterMaximumLength != 255 || values[0].ColumnDefault == nil || *values[0].ColumnDefault != "unknown" || values[0].Comment == nil || *values[0].Comment != "显示名称" {
t.Fatalf("unexpected string column: %#v", values[0])
}
if values[1].Name != "amount" || values[1].IsNullable || values[1].NumericPrecision == nil || *values[1].NumericPrecision != 18 || values[1].NumericScale == nil || *values[1].NumericScale != 2 || values[1].CharacterMaximumLength != nil {
@@ -157,6 +230,33 @@ func TestGetColumnsUsesHiveServerMetadataFields(t *testing.T) {
}
}
func TestGetColumnsPreservesChineseDescribeFallbackComments(t *testing.T) {
behavior := &scriptedBehavior{
getColumns: func(context.Context, string, string, string) (gohive.MetadataResult, error) {
return gohive.MetadataResult{}, errors.New("metadata unavailable")
},
query: func(ctx context.Context, query string) (driver.Rows, error) {
if query != "DESCRIBE `analytics`.`events`" {
t.Fatalf("unexpected DESCRIBE query: %q", query)
}
return newScriptedRows(
ctx,
[]string{"col_name", "data_type", "comment"},
[]string{"STRING", "STRING", "STRING"},
[][]driver.Value{{"name", "string", "显示名称"}, {"amount", "decimal(18,2)", "含税金额"}},
), nil
},
}
server := newScriptedServer(t, behavior)
values, err := server.getColumns("analytics", "events")
if err != nil {
t.Fatal(err)
}
if len(values) != 2 || values[0].Comment == nil || *values[0].Comment != "显示名称" || values[1].Comment == nil || *values[1].Comment != "含税金额" {
t.Fatalf("DESCRIBE comments changed: %#v", values)
}
}
func TestTableCommentAndTypeInfoUseHiveServerMetadata(t *testing.T) {
behavior := &scriptedBehavior{
getTables: func(_ context.Context, schema, table string, tableTypes []string) (gohive.MetadataResult, error) {
@@ -184,7 +284,131 @@ func TestTableCommentAndTypeInfoUseHiveServerMetadata(t *testing.T) {
}
}
func TestListTablesFallsBackToShowTables(t *testing.T) {
func TestListTablesFallsBackToShowTablesAndViews(t *testing.T) {
behavior := &scriptedBehavior{
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
return gohive.MetadataResult{}, errors.New("metadata unsupported")
},
query: func(ctx context.Context, query string) (driver.Rows, error) {
switch query {
case "SHOW TABLES IN `analytics`":
return newScriptedRows(
ctx,
[]string{"tab_name"},
[]string{"STRING"},
[][]driver.Value{{"events"}, {"shared_name"}},
), nil
case "SHOW VIEWS IN `analytics`":
return newScriptedRows(
ctx,
[]string{"view_name"},
[]string{"STRING"},
[][]driver.Value{{"events_view"}, {"shared_name"}},
), nil
default:
t.Fatalf("unexpected fallback query: %q", query)
return nil, errors.New("unexpected fallback query")
}
},
}
server := newScriptedServer(t, behavior)
defer server.disconnect()
values, err := server.listTables("analytics", metadataListConstraints{})
if err != nil {
t.Fatal(err)
}
expected := []tableInfo{
{Name: "events", TableType: "TABLE"},
{Name: "events_view", TableType: "VIEW"},
{Name: "shared_name", TableType: "VIEW"},
}
if !reflect.DeepEqual(values, expected) {
t.Fatalf("unexpected fallback tables: %#v", values)
}
}
func TestListTablesKeepsShowTablesResultsWhenShowViewsIsUnsupported(t *testing.T) {
behavior := &scriptedBehavior{
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
return gohive.MetadataResult{}, errors.New("metadata unsupported")
},
query: func(ctx context.Context, query string) (driver.Rows, error) {
switch query {
case "SHOW TABLES IN `analytics`":
return newScriptedRows(ctx, []string{"tab_name"}, []string{"STRING"}, [][]driver.Value{{"events"}}), nil
case "SHOW VIEWS IN `analytics`":
return nil, errors.New("SHOW VIEWS is unsupported")
default:
t.Fatalf("unexpected fallback query: %q", query)
return nil, errors.New("unexpected fallback query")
}
},
}
server := newScriptedServer(t, behavior)
defer server.disconnect()
values, err := server.listTables("analytics", metadataListConstraints{})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(values, []tableInfo{{Name: "events", TableType: "TABLE"}}) {
t.Fatalf("unexpected fallback tables: %#v", values)
}
}
func TestListTablesReturnsNonCapabilityShowViewsError(t *testing.T) {
behavior := &scriptedBehavior{
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
return gohive.MetadataResult{}, errors.New("metadata unsupported")
},
query: func(ctx context.Context, query string) (driver.Rows, error) {
switch query {
case "SHOW TABLES IN `analytics`":
return newScriptedRows(ctx, []string{"tab_name"}, []string{"STRING"}, [][]driver.Value{{"events"}}), nil
case "SHOW VIEWS IN `analytics`":
return nil, errors.New("permission denied for SHOW VIEWS")
default:
t.Fatalf("unexpected fallback query: %q", query)
return nil, errors.New("unexpected fallback query")
}
},
}
server := newScriptedServer(t, behavior)
defer server.disconnect()
_, err := server.listTables("analytics", metadataListConstraints{})
if err == nil || !strings.Contains(err.Error(), "SHOW VIEWS fallback failed: permission denied") {
t.Fatalf("unexpected mixed fallback error: %v", err)
}
}
func TestShowViewsUnsupported(t *testing.T) {
tests := []struct {
name string
err error
unsupported bool
}{
{name: "explicit unsupported", err: errors.New("SHOW VIEWS is unsupported"), unsupported: true},
{name: "not supported", err: errors.New("SHOW VIEWS is not supported before Hive 2.2"), unsupported: true},
{name: "old parser", err: errors.New("ParseException: syntax error at or near VIEWS"), unsupported: true},
{name: "permission", err: errors.New("permission denied for SHOW VIEWS")},
{name: "timeout", err: context.DeadlineExceeded},
{name: "cancel", err: context.Canceled},
{name: "authentication", err: errors.New("authentication failed")},
{name: "unsupported authentication", err: errors.New("unsupported authentication mechanism")},
{name: "transport", err: errors.New("transport is closed")},
{name: "unsupported transport", err: errors.New("transport does not support SASL")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if actual := showViewsUnsupported(test.err); actual != test.unsupported {
t.Fatalf("showViewsUnsupported(%v) = %v, want %v", test.err, actual, test.unsupported)
}
})
}
}
func TestListTablesFallbackHonorsExplicitTableType(t *testing.T) {
behavior := &scriptedBehavior{
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
return gohive.MetadataResult{}, errors.New("metadata unsupported")
@@ -198,7 +422,8 @@ func TestListTablesFallsBackToShowTables(t *testing.T) {
}
server := newScriptedServer(t, behavior)
defer server.disconnect()
values, err := server.listTables("analytics", metadataListConstraints{})
values, err := server.listTables("analytics", metadataListConstraints{ObjectTypes: []string{"TABLE"}})
if err != nil {
t.Fatal(err)
}
@@ -207,6 +432,50 @@ func TestListTablesFallsBackToShowTables(t *testing.T) {
}
}
func TestListViewsFallsBackToShowViews(t *testing.T) {
behavior := &scriptedBehavior{
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
return gohive.MetadataResult{}, errors.New("metadata unsupported")
},
query: func(ctx context.Context, query string) (driver.Rows, error) {
if query != "SHOW VIEWS IN `analytics`" {
t.Fatalf("unexpected fallback query: %q", query)
}
return newScriptedRows(ctx, []string{"view_name"}, []string{"STRING"}, [][]driver.Value{{"events_view"}}), nil
},
}
server := newScriptedServer(t, behavior)
defer server.disconnect()
values, err := server.listTables("analytics", metadataListConstraints{ObjectTypes: []string{"VIEW"}})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(values, []tableInfo{{Name: "events_view", TableType: "VIEW"}}) {
t.Fatalf("unexpected fallback views: %#v", values)
}
}
func TestListViewsReturnsFallbackErrorWhenShowViewsIsUnsupported(t *testing.T) {
behavior := &scriptedBehavior{
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
return gohive.MetadataResult{}, errors.New("metadata unsupported")
},
query: func(_ context.Context, query string) (driver.Rows, error) {
if query != "SHOW VIEWS IN `analytics`" {
t.Fatalf("unexpected fallback query: %q", query)
}
return nil, errors.New("SHOW VIEWS is unsupported")
},
}
server := newScriptedServer(t, behavior)
defer server.disconnect()
_, err := server.listTables("analytics", metadataListConstraints{ObjectTypes: []string{"VIEW"}})
if err == nil || !strings.Contains(err.Error(), "SHOW VIEWS fallback failed") {
t.Fatalf("unexpected explicit view fallback error: %v", err)
}
}
func metadataResult(columns []string, rows ...[]driver.Value) gohive.MetadataResult {
return gohive.MetadataResult{Columns: columns, Rows: rows}
}
+66
View File
@@ -302,6 +302,72 @@ func TestExecuteQueryUsesHiveServerResultSetSignal(t *testing.T) {
}
}
func TestQueryResultsPreserveHiveServerLabelsWithoutDotSplitting(t *testing.T) {
columns := []string{"id", "customer.id", "total + 1", "id"}
columnTypes := []string{"BIGINT", "BIGINT", "DOUBLE", "BIGINT"}
behavior := &scriptedBehavior{}
behavior.query = func(ctx context.Context, _ string) (driver.Rows, error) {
return newScriptedRows(ctx, columns, columnTypes, [][]driver.Value{{int64(1), int64(2), float64(3), int64(4)}, {int64(5), int64(6), float64(7), int64(8)}}), nil
}
server := newScriptedServer(t, behavior)
ordinary, err := server.executeQuery(queryOptions{SQL: "SELECT * FROM labels"})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(ordinary.Columns, columns) || !reflect.DeepEqual(ordinary.ColumnTypes, []string{"bigint", "bigint", "double", "bigint"}) {
t.Fatalf("ordinary metadata changed: %#v", ordinary)
}
if len(ordinary.Rows) != 2 || ordinary.Rows[0][0] != "1" || ordinary.Rows[0][3] != "4" {
t.Fatalf("ordinary values changed: %#v", ordinary.Rows)
}
first, err := server.executeQueryPage(queryOptions{SQL: "SELECT * FROM labels", MaxRows: 2}, 1)
if err != nil {
t.Fatal(err)
}
if first.SessionID == nil || !first.HasMore || !reflect.DeepEqual(first.Columns, columns) {
t.Fatalf("first page metadata changed: %#v", first)
}
second, err := server.fetchQueryPage(*first.SessionID, 1)
if err != nil {
t.Fatal(err)
}
if second.HasMore || second.SessionID != nil || !reflect.DeepEqual(second.Columns, columns) {
t.Fatalf("cached page metadata changed: %#v", second)
}
}
func TestPagedQueryPreservesDuplicateLeadingValuesAcrossPages(t *testing.T) {
behavior := &scriptedBehavior{}
behavior.query = func(ctx context.Context, _ string) (driver.Rows, error) {
return newScriptedRows(
ctx,
[]string{"group_id", "row_id"},
[]string{"BIGINT", "BIGINT"},
[][]driver.Value{{int64(1), int64(101)}, {int64(1), int64(102)}, {int64(1), int64(103)}},
), nil
}
server := newScriptedServer(t, behavior)
first, err := server.executeQueryPage(queryOptions{SQL: "SELECT group_id, row_id FROM repeated_values", MaxRows: 3}, 2)
if err != nil {
t.Fatal(err)
}
if first.SessionID == nil || !first.HasMore {
t.Fatalf("expected an open cursor after the first page: %#v", first)
}
second, err := server.fetchQueryPage(*first.SessionID, 2)
if err != nil {
t.Fatal(err)
}
allRows := append(append([][]any{}, first.Rows...), second.Rows...)
expected := [][]any{{"1", "101"}, {"1", "102"}, {"1", "103"}}
if !reflect.DeepEqual(allRows, expected) || second.HasMore {
t.Fatalf("duplicate leading values changed across cursor pages: %#v", allRows)
}
}
func TestPagedQueryTruncatesAndPreservesLegacyJDBCValueSemantics(t *testing.T) {
largeValue := strings.Repeat("x", 256*1024)
createdAt := time.Date(2026, time.August, 11, 10, 11, 12, 345000000, time.UTC)
-11
View File
@@ -1,11 +0,0 @@
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.apache.hive:hive-jdbc:4.0.1:standalone'
implementation 'org.slf4j:slf4j-nop:1.7.30'
}
tasks.named('shadowJar') {
manifest {
attributes('Agent-Label': 'Apache Hive', 'Main-Class': 'com.dbx.agent.hive.HiveAgent')
}
}
@@ -1,293 +0,0 @@
package com.dbx.agent.hive;
import com.dbx.agent.AbstractJdbcAgent;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.QueryPageOptions;
import com.dbx.agent.QueryPageResult;
import com.dbx.agent.QueryResult;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
public final class HiveAgent extends AbstractJdbcAgent {
@Override
protected String driverClass() {
return "org.apache.hive.jdbc.HiveDriver";
}
@Override
protected String buildJdbcUrl(ConnectParams params) {
return buildUrl(params);
}
@Override
public List<DatabaseInfo> listDatabases() {
return unchecked(() -> {
List<DatabaseInfo> result = new ArrayList<>();
try (java.sql.Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery("SHOW DATABASES")) {
while (rs.next()) {
result.add(new DatabaseInfo(rs.getString(1)));
}
}
result.sort(Comparator.comparing(DatabaseInfo::getName));
return result;
});
}
@Override
public List<String> listSchemas() {
List<String> result = new ArrayList<>();
for (DatabaseInfo database : listDatabases()) {
result.add(database.getName());
}
return result;
}
@Override
public List<TableInfo> listTables(String schema) {
return unchecked(() -> {
List<TableInfo> result = new ArrayList<>();
useSchema(schema);
try (java.sql.Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery("SHOW TABLES")) {
while (rs.next()) {
result.add(new TableInfo(rs.getString(1), "TABLE", null));
}
}
result.sort(Comparator.comparing(TableInfo::getName));
return result;
});
}
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
return unchecked(() -> {
try {
return getColumnsFromDescribe(schema, table);
} catch (Exception ignored) {
return getColumnsFromMetadata(requireConnected(), schema, table);
}
});
}
@Override
public String getTableDdl(String schema, String table) {
return unchecked(() -> {
String qualifiedName = schema == null || schema.trim().isEmpty()
? JdbcIdentifiers.INSTANCE.backtick(table)
: JdbcIdentifiers.INSTANCE.backtick(schema) + "." + JdbcIdentifiers.INSTANCE.backtick(table);
try (Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery("SHOW CREATE TABLE " + qualifiedName)) {
StringBuilder ddl = new StringBuilder();
while (rs.next()) {
String line = rs.getString(1);
if (line != null) {
ddl.append(line).append('\n');
}
}
return ddl.toString();
}
});
}
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
return Collections.emptyList();
}
@Override
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
return Collections.emptyList();
}
@Override
public List<TriggerInfo> listTriggers(String schema, String table) {
return Collections.emptyList();
}
@Override
public String setSchemaSQL(String schema) {
return "USE " + JdbcIdentifiers.INSTANCE.backtick(schema);
}
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
// Hive JDBC fetches rows via Thrift FetchResults in batches controlled
// by fetchSize. A large fetchSize can fail with "Error retrieving next
// row" when the result contains large binary/struct columns. Use a
// small fetchSize (50) so each Thrift batch stays within limits.
return super.executeQuery(sql, schema, new ExecuteQueryOptions(
options.getMaxRows(),
50,
options.getTimeoutSecs()
));
}
@Override
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
return super.executeQueryPage(sql, schema, withSmallFetchSize(options));
}
@Override
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
return super.startTableRead(sql, schema, withSmallFetchSize(options));
}
private static QueryPageOptions withSmallFetchSize(QueryPageOptions options) {
return new QueryPageOptions(
options.getPageSize(), 50, options.getMaxRows(), options.getTimeoutSecs()
);
}
@Override
protected Object resultValue(ResultSet rs, int index, int sqlType) {
return unchecked(() -> {
Object value = rs.getObject(index);
return rs.wasNull() ? null : value == null ? null : value.toString();
});
}
private void useSchema(String schema) throws Exception {
applySchemaContext(requireConnected(), schema);
}
private List<ColumnInfo> getColumnsFromDescribe(String schema, String table) throws Exception {
List<ColumnInfo> result = new ArrayList<>();
useSchema(schema);
try (java.sql.Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery("DESCRIBE " + JdbcIdentifiers.INSTANCE.backtick(table))) {
while (rs.next()) {
String colName = trimToNull(rs.getString(1));
if (colName == null || colName.startsWith("#")) {
continue;
}
result.add(new ColumnInfo(
colName,
trimToEmpty(rs.getString(2)),
true,
null,
false,
null,
optionalComment(rs),
null,
null,
null
));
}
}
return result;
}
private static List<ColumnInfo> getColumnsFromMetadata(Connection conn, String schema, String table) throws Exception {
List<ColumnInfo> result = new ArrayList<>();
DatabaseMetaData meta = conn.getMetaData();
try (ResultSet rs = meta.getColumns(null, trimToNull(schema), table, "%")) {
while (rs.next()) {
String name = rs.getString("COLUMN_NAME");
if (trimToNull(name) == null) {
continue;
}
result.add(new ColumnInfo(
name,
trimToEmpty(rs.getString("TYPE_NAME")),
rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls,
rs.getString("COLUMN_DEF"),
false,
null,
rs.getString("REMARKS"),
intOrNull(rs, "COLUMN_SIZE"),
intOrNull(rs, "DECIMAL_DIGITS"),
characterLength(rs)
));
}
}
return result;
}
static String buildUrl(ConnectParams params) {
String connectionString = trimToEmpty(params.getConnection_string());
if (!connectionString.isEmpty()) {
return connectionString;
}
String url = "jdbc:hive2://" + params.getHost() + ":" + params.getPort() + "/" + params.getDatabase();
return appendHiveUrlParams(url, params.getUrl_params());
}
private static String appendHiveUrlParams(String url, String urlParams) {
String params = trimHiveUrlParams(urlParams);
if (params.isEmpty()) {
return url;
}
// Hive JDBC authentication options use semicolon-separated URL properties.
return url + (url.endsWith(";") ? "" : ";") + params;
}
private static String trimHiveUrlParams(String urlParams) {
String value = trimToEmpty(urlParams);
while (value.startsWith("?") || value.startsWith("&") || value.startsWith(";")) {
value = value.substring(1);
}
return value;
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private static String trimToEmpty(String value) {
return value == null ? "" : value.trim();
}
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 Integer characterLength(ResultSet rs) throws Exception {
String typeName = rs.getString("TYPE_NAME");
if (typeName == null) {
return null;
}
String normalized = typeName.toLowerCase(Locale.ROOT);
if (!normalized.contains("char") && !normalized.contains("text") && !normalized.contains("string")) {
return null;
}
return intOrNull(rs, "COLUMN_SIZE");
}
private static String optionalComment(ResultSet rs) {
return unchecked(() -> {
try {
String comment = trimToNull(rs.getString(3));
return comment;
} catch (Exception e) {
return null;
}
});
}
public static void main(String[] args) {
new MultiSessionJsonRpcServer(HiveAgent::new).run();
}
}
@@ -1,77 +0,0 @@
package com.dbx.agent.hive;
import com.dbx.agent.DatabaseAgent;
import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.QueryPageOptions;
import com.dbx.agent.QueryResult;
import com.dbx.agent.test.JdbcAgentFake;
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
import com.dbx.agent.test.TestSupport;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
class HiveAgentExecutionTest extends JdbcFakeExecutionBehaviorTest {
@Override
protected DatabaseAgent createAgent() {
return new HiveAgent();
}
@Override
protected String resultSetSql() {
return "MSCK REPAIR TABLE sample";
}
// Override: HiveAgent.executeQuery forces fetchSize=50 to keep Thrift
// FetchResults batches small (prevents "Error retrieving next row" on
// tables with large binary/struct columns).
@Override
@Test
protected void executesNonSelectStatementsThatReturnResultSets() {
DatabaseAgent agent = createAgent();
TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection());
QueryResult result = agent.executeQuery(resultSetSql(), null, new ExecuteQueryOptions());
assertEquals(Collections.singletonList("VALUE"), result.getColumns());
assertEquals(Collections.singletonList(Collections.singletonList("row-value")), result.getRows());
assertEquals(Arrays.asList("setMaxRows:10001", "setFetchSize:50", "execute"), JdbcAgentFake.calls);
}
@Override
@Test
protected void appliesExecutionRowAndFetchLimitsToJdbcStatements() {
DatabaseAgent agent = createAgent();
TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection());
QueryResult result = agent.executeQuery(resultSetSql(), null, new ExecuteQueryOptions(1, 25));
assertEquals(Collections.singletonList(Collections.singletonList("row-value")), result.getRows());
assertEquals(Arrays.asList("setMaxRows:2", "setFetchSize:50", "execute"), JdbcAgentFake.calls);
}
@Override
@Test
protected void pagedQueryExecutionPassesCallerFetchSizeToStatement() {
DatabaseAgent agent = createAgent();
TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection());
agent.executeQueryPage(resultSetSql(), null, new QueryPageOptions(100, 500, 1000));
assertEquals(Arrays.asList("setFetchSize:50", "execute"), JdbcAgentFake.calls);
}
@Override
@Test
protected void tableReadPassesCallerFetchSizeToStatement() {
DatabaseAgent agent = createAgent();
TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection());
agent.startTableRead(resultSetSql(), null, new QueryPageOptions(100, 500, 1000));
assertEquals(Arrays.asList("setFetchSize:50", "execute"), JdbcAgentFake.calls);
}
}
@@ -1,186 +0,0 @@
package com.dbx.agent.hive;
import com.dbx.agent.test.TestSupport;
import com.dbx.agent.test.JdbcMetadataSqlFake;
import com.dbx.agent.ColumnInfo;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.util.List;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class HiveAgentMetadataTest {
@Test
void quotesSchemaAndTableIdentifiersInMetadataSql() {
HiveAgent agent = new HiveAgent();
Connection fake = JdbcMetadataSqlFake.connection();
TestSupport.setPrivateConnection(agent, fake);
agent.listTables("bad`schema");
agent.getColumns("bad`schema", "bad`table");
Assertions.assertEquals(
List.of(
"USE `bad``schema`",
"SHOW TABLES",
"USE `bad``schema`",
"DESCRIBE `bad``table`"
),
JdbcMetadataSqlFake.statements
);
}
@Test
void fallsBackToJdbcMetadataWhenDescribeCannotCreateResultSet() {
HiveAgent agent = new HiveAgent();
Connection fake = describeFailingMetadataConnection();
TestSupport.setPrivateConnection(agent, fake);
List<ColumnInfo> columns = agent.getColumns("default", "events");
Assertions.assertEquals(2, columns.size());
Assertions.assertEquals("id", columns.get(0).getName());
Assertions.assertEquals("bigint", columns.get(0).getData_type());
Assertions.assertFalse(columns.get(0).getIs_nullable());
Assertions.assertEquals("name", columns.get(1).getName());
Assertions.assertEquals("string", columns.get(1).getData_type());
Assertions.assertEquals(Integer.valueOf(255), columns.get(1).getCharacter_maximum_length());
}
private static Connection describeFailingMetadataConnection() {
DatabaseMetaData meta = proxy(DatabaseMetaData.class, (method, args) -> {
if ("getColumns".equals(method.getName())) {
return rows(
row(
"COLUMN_NAME", "id",
"TYPE_NAME", "bigint",
"NULLABLE", DatabaseMetaData.columnNoNulls,
"COLUMN_DEF", null,
"REMARKS", "identifier",
"COLUMN_SIZE", 19,
"DECIMAL_DIGITS", 0
),
row(
"COLUMN_NAME", "name",
"TYPE_NAME", "string",
"NULLABLE", DatabaseMetaData.columnNullable,
"COLUMN_DEF", null,
"REMARKS", null,
"COLUMN_SIZE", 255,
"DECIMAL_DIGITS", 0
)
);
}
return defaultValue(method.getReturnType());
});
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
if ("createStatement".equals(name)) {
return describeFailingStatement();
}
if ("getMetaData".equals(name)) {
return meta;
}
if ("isClosed".equals(name)) {
return false;
}
return defaultValue(method.getReturnType());
});
}
private static Statement describeFailingStatement() {
return proxy(Statement.class, (method, args) -> {
String name = method.getName();
if ("execute".equals(name)) {
return false;
}
if ("executeQuery".equals(name)) {
throw new SQLException("Could not create ResultSet: Required field 'type' is unset! Struct:TPrimitiveTypeEntry(type:null)");
}
return defaultValue(method.getReturnType());
});
}
@SafeVarargs
private static ResultSet rows(Map<String, Object>... rows) {
return proxy(ResultSet.class, new MethodHandler() {
private int index = -1;
@Override
public Object handle(Method method, Object[] args) {
String name = method.getName();
if ("next".equals(name)) {
index += 1;
return index < rows.length;
}
if ("close".equals(name)) {
return null;
}
Object value = rows[index].get(args[0]);
if ("getString".equals(name)) {
return value == null ? null : String.valueOf(value);
}
if ("getObject".equals(name)) {
return value;
}
if ("getInt".equals(name)) {
return value instanceof Number ? ((Number) value).intValue() : 0;
}
return defaultValue(method.getReturnType());
}
});
}
private static Map<String, Object> row(Object... values) {
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 0; i < values.length; i += 2) {
row.put(String.valueOf(values[i]), values[i + 1]);
}
return row;
}
private static <T> T proxy(Class<T> type, MethodHandler handler) {
InvocationHandler invocationHandler = (proxy, method, args) -> handler.handle(method, args);
return type.cast(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;
}
}
@@ -1,127 +0,0 @@
package com.dbx.agent.hive;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.test.TestSupport;
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.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class HiveAgentTest {
@Test
void hiveJdbcStandaloneRuntimeClassesAreAvailable() throws ClassNotFoundException {
Class.forName("org.apache.hive.jdbc.HiveDriver");
Class.forName("org.apache.hive.org.apache.thrift.protocol.TProtocol");
}
@Test
void buildUrlAppendsKerberosUrlParams() {
ConnectParams params = new ConnectParams();
params.setHost("hive.example.com");
params.setPort(10000);
params.setDatabase("default");
params.setUrl_params(";principal=hive/hive.example.com@EXAMPLE.COM;auth=kerberos");
assertEquals(
"jdbc:hive2://hive.example.com:10000/default;principal=hive/hive.example.com@EXAMPLE.COM;auth=kerberos",
HiveAgent.buildUrl(params)
);
}
@Test
void buildUrlUsesCustomJdbcUrlAsIs() {
ConnectParams params = new ConnectParams();
params.setConnection_string(
"jdbc:hive2://zk1.example.com,zk2.example.com/default;serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2;principal=hive/_HOST@EXAMPLE.COM"
);
params.setUrl_params("principal=hive/ignored@EXAMPLE.COM");
assertEquals(
"jdbc:hive2://zk1.example.com,zk2.example.com/default;serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2;principal=hive/_HOST@EXAMPLE.COM",
HiveAgent.buildUrl(params)
);
}
@Test
void getTableDdlUsesHiveShowCreateTable() {
HiveAgent agent = new HiveAgent();
List<String> queries = new ArrayList<>();
String expectedDdl = "CREATE TABLE `hive_test`.`cleaned_data_table`(col string)\n"
+ "ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'\n"
+ "LOCATION 'hdfs://warehouse/cleaned_data_table'";
ResultSet resultSet = proxy(ResultSet.class, new InvocationHandler() {
private int row = -1;
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
if ("next".equals(method.getName())) {
return ++row == 0;
}
if ("getString".equals(method.getName())) {
return expectedDdl;
}
return defaultValue(method.getReturnType());
}
});
Statement statement = proxy(Statement.class, (proxy, method, args) -> {
if ("executeQuery".equals(method.getName())) {
queries.add((String) args[0]);
return resultSet;
}
return defaultValue(method.getReturnType());
});
Connection connection = proxy(Connection.class, (proxy, method, args) -> {
if ("createStatement".equals(method.getName())) {
return statement;
}
if ("isClosed".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
TestSupport.setPrivateConnection(agent, connection);
assertEquals(expectedDdl + "\n", agent.getTableDdl("hive_test", "cleaned_data_table"));
assertEquals(List.of("SHOW CREATE TABLE `hive_test`.`cleaned_data_table`"), queries);
}
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
}
private static Object defaultValue(Class<?> type) {
if (type == boolean.class) {
return false;
}
if (type == int.class) {
return 0;
}
if (type == long.class) {
return 0L;
}
if (type == float.class) {
return 0F;
}
if (type == double.class) {
return 0D;
}
if (type == byte.class) {
return (byte) 0;
}
if (type == short.class) {
return (short) 0;
}
if (type == char.class) {
return '\0';
}
return null;
}
}
+10
View File
@@ -0,0 +1,10 @@
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.apache.ignite:ignite-core:2.18.0'
}
tasks.named('shadowJar') {
manifest {
attributes('Agent-Label': 'Apache Ignite', 'Main-Class': 'com.dbx.agent.ignite.IgniteAgent')
}
}
@@ -0,0 +1,297 @@
package com.dbx.agent.ignite;
import com.dbx.agent.AbstractJdbcAgent;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class IgniteAgent extends AbstractJdbcAgent {
private String databaseName = "";
@Override
protected String driverClass() {
return "org.apache.ignite.IgniteJdbcThinDriver";
}
@Override
protected String buildJdbcUrl(ConnectParams params) {
return buildUrl(params);
}
@Override
protected void afterConnect(ConnectParams params, Connection connection) {
// Ignite has no catalogs; name the virtual database after the connection's
// real default schema (e.g. PUBLIC) so any database-as-schema context that
// leaks into setSchema targets a schema that actually exists.
try {
String schema = connection.getSchema();
databaseName = schema == null || schema.isBlank() ? params.getDatabase() : schema;
} catch (Exception e) {
databaseName = params.getDatabase();
}
}
@Override
public List<DatabaseInfo> listDatabases() {
// Ignite has no catalogs; expose the connected schema group as a single
// virtual database so the object browser stays schema-oriented like H2.
return List.of(new DatabaseInfo(databaseName.isBlank() ? "default" : databaseName));
}
@Override
public List<String> listSchemas() {
return unchecked(() -> {
List<String> result = new ArrayList<>();
try (ResultSet rs = requireConnected().getMetaData().getSchemas()) {
while (rs.next()) {
result.add(rs.getString("TABLE_SCHEM"));
}
}
result.sort(Comparator.naturalOrder());
return result;
});
}
@Override
public List<TableInfo> listTables(String schema) {
return unchecked(() -> {
List<TableInfo> result = new ArrayList<>();
try (ResultSet rs = requireConnected().getMetaData().getTables(null, schema, null, null)) {
while (rs.next()) {
result.add(new TableInfo(
rs.getString("TABLE_NAME"),
normalizeTableType(rs.getString("TABLE_TYPE")),
null
));
}
}
result.sort(Comparator.comparing(TableInfo::getName));
return result;
});
}
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
return unchecked(() -> {
Set<String> primaryKeys = new LinkedHashSet<>();
try (ResultSet rs = requireConnected().getMetaData().getPrimaryKeys(null, schema, table)) {
while (rs.next()) {
primaryKeys.add(rs.getString("COLUMN_NAME"));
}
}
List<ColumnInfo> result = new ArrayList<>();
try (ResultSet rs = requireConnected().getMetaData().getColumns(null, schema, table, null)) {
while (rs.next()) {
String colName = rs.getString("COLUMN_NAME");
result.add(new ColumnInfo(
colName,
rs.getString("TYPE_NAME"),
"YES".equals(rs.getString("IS_NULLABLE")),
rs.getString("COLUMN_DEF"),
primaryKeys.contains(colName),
null,
emptyToNull(rs.getString("REMARKS")),
intOrNull(rs, "COLUMN_SIZE"),
intOrNull(rs, "DECIMAL_DIGITS"),
null
));
}
}
return result;
});
}
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
return unchecked(() -> {
DatabaseMetaData metadata = requireConnected().getMetaData();
Set<String> primaryIndexNames = new LinkedHashSet<>();
List<IndexColumn> primaryColumns = new ArrayList<>();
try (ResultSet rs = metadata.getPrimaryKeys(null, schema, table)) {
while (rs.next()) {
String name = rs.getString("PK_NAME");
if (name != null && !name.isBlank()) {
primaryIndexNames.add(name);
}
String column = rs.getString("COLUMN_NAME");
if (column != null && !column.isBlank()) {
primaryColumns.add(new IndexColumn(rs.getShort("KEY_SEQ"), column));
}
}
}
List<IndexRow> rows = new ArrayList<>();
try (ResultSet rs = metadata.getIndexInfo(null, schema, table, false, false)) {
while (rs.next()) {
if (rs.getShort("TYPE") == DatabaseMetaData.tableIndexStatistic) {
continue;
}
String name = rs.getString("INDEX_NAME");
String column = rs.getString("COLUMN_NAME");
if (name == null || name.isBlank() || column == null || column.isBlank()) {
continue;
}
rows.add(new IndexRow(
name,
column,
rs.getShort("ORDINAL_POSITION"),
!rs.getBoolean("NON_UNIQUE"),
indexType(rs.getShort("TYPE"))
));
}
}
return assembleIndexes(rows, primaryIndexNames, primaryColumns);
});
}
static List<IndexInfo> assembleIndexes(
List<IndexRow> rows,
Set<String> primaryIndexNames,
List<IndexColumn> primaryColumns
) {
Map<String, List<IndexRow>> grouped = new LinkedHashMap<>();
for (IndexRow row : rows) {
grouped.computeIfAbsent(row.name, ignored -> new ArrayList<>()).add(row);
}
List<IndexColumn> orderedPrimary = new ArrayList<>(primaryColumns);
orderedPrimary.sort(Comparator.comparingInt(column -> column.ordinal));
List<String> orderedPrimaryColumns = new ArrayList<>();
for (IndexColumn column : orderedPrimary) {
orderedPrimaryColumns.add(column.name);
}
List<IndexInfo> result = new ArrayList<>();
for (Map.Entry<String, List<IndexRow>> entry : grouped.entrySet()) {
List<IndexRow> indexRows = entry.getValue();
indexRows.sort(Comparator.comparingInt(row -> row.ordinal));
List<String> columns = new ArrayList<>();
for (IndexRow row : indexRows) {
columns.add(row.column);
}
IndexRow first = indexRows.get(0);
boolean primary = primaryIndexNames.stream().anyMatch(name -> name.equalsIgnoreCase(entry.getKey()))
|| sameColumns(columns, orderedPrimaryColumns);
result.add(new IndexInfo(
entry.getKey(),
columns,
first.unique,
primary,
null,
first.indexType,
null,
null
));
}
result.sort(Comparator.comparing(IndexInfo::getName));
return result;
}
private static boolean sameColumns(List<String> left, List<String> right) {
if (left.size() != right.size() || left.isEmpty()) {
return false;
}
for (int index = 0; index < left.size(); index++) {
if (!left.get(index).equalsIgnoreCase(right.get(index))) {
return false;
}
}
return true;
}
private static String indexType(short type) {
if (type == DatabaseMetaData.tableIndexClustered) return "CLUSTERED";
if (type == DatabaseMetaData.tableIndexHashed) return "HASHED";
if (type == DatabaseMetaData.tableIndexOther) return "OTHER";
return null;
}
static final class IndexRow {
final String name;
final String column;
final int ordinal;
final boolean unique;
final String indexType;
IndexRow(String name, String column, int ordinal, boolean unique, String indexType) {
this.name = name;
this.column = column;
this.ordinal = ordinal;
this.unique = unique;
this.indexType = indexType;
}
}
static final class IndexColumn {
final int ordinal;
final String name;
IndexColumn(int ordinal, String name) {
this.ordinal = ordinal;
this.name = name;
}
}
@Override
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
return Collections.emptyList();
}
@Override
public List<TriggerInfo> listTriggers(String schema, String table) {
return Collections.emptyList();
}
@Override
public String setSchemaSQL(String schema) {
// JdbcThinConnection implements Connection.setSchema, so no SQL switch is needed.
return "";
}
private static String buildUrl(ConnectParams params) {
String connectionString = params.getConnection_string();
if (connectionString != null && !connectionString.trim().isEmpty()) {
return connectionString.trim();
}
String base = "jdbc:ignite:thin://" + params.getHost() + ":" + params.getPort();
String database = params.getDatabase();
return database == null || database.isBlank() ? base : base + "/" + database;
}
private static String normalizeTableType(String type) {
if ("BASE TABLE".equals(type)) {
return "TABLE";
}
return type;
}
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 String emptyToNull(String value) {
return value == null || value.isEmpty() ? null : value;
}
public static void main(String[] args) {
new MultiSessionJsonRpcServer(IgniteAgent::new).run();
}
}
@@ -0,0 +1,56 @@
package com.dbx.agent.ignite;
import com.dbx.agent.DatabaseAgent;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
import java.sql.DatabaseMetaData;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class IgniteAgentTest extends JdbcFakeExecutionBehaviorTest {
@Override
protected DatabaseAgent createAgent() {
return new IgniteAgent();
}
@Override
protected String resultSetSql() {
return "SELECT 1";
}
@Test
void aggregatesOrdinalColumnsAndPrimaryStatus() {
List<IndexInfo> indexes = IgniteAgent.assembleIndexes(
List.of(
new IgniteAgent.IndexRow("IDX_NAME", "last_name", 2, false, "OTHER"),
new IgniteAgent.IndexRow("PK_PERSON", "tenant_id", 1, true, "HASHED"),
new IgniteAgent.IndexRow("IDX_NAME", "first_name", 1, false, "OTHER"),
new IgniteAgent.IndexRow("PK_PERSON", "person_id", 2, true, "HASHED")
),
Set.of("PK_PERSON"),
List.of(
new IgniteAgent.IndexColumn(2, "person_id"),
new IgniteAgent.IndexColumn(1, "tenant_id")
)
);
assertEquals(2, indexes.size());
IndexInfo secondary = indexes.get(0);
assertEquals("IDX_NAME", secondary.getName());
assertEquals(List.of("first_name", "last_name"), secondary.getColumns());
assertFalse(secondary.getIs_unique());
assertFalse(secondary.getIs_primary());
IndexInfo primary = indexes.get(1);
assertEquals("PK_PERSON", primary.getName());
assertEquals(List.of("tenant_id", "person_id"), primary.getColumns());
assertTrue(primary.getIs_unique());
assertTrue(primary.getIs_primary());
assertEquals("HASHED", primary.getIndex_type());
}
}
+9
View File
@@ -0,0 +1,9 @@
dependencies {
implementation 'org.apache.ignite:ignite-jdbc:3.1.0'
}
tasks.named('shadowJar') {
manifest {
attributes('Agent-Label': 'Apache Ignite 3', 'Main-Class': 'com.dbx.agent.ignite3.Ignite3Agent')
}
}
@@ -0,0 +1,189 @@
package com.dbx.agent.ignite3;
import com.dbx.agent.AbstractJdbcAgent;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
import java.sql.Connection;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Properties;
import java.util.Set;
public final class Ignite3Agent extends AbstractJdbcAgent {
private static final String JDBC_USERNAME_PROPERTY = "ignite.jdbc.username";
private static final String JDBC_PASSWORD_PROPERTY = "ignite.jdbc.password";
private String databaseName = "";
@Override
protected String driverClass() {
return "org.apache.ignite.jdbc.IgniteJdbcDriver";
}
@Override
protected String buildJdbcUrl(ConnectParams params) {
return buildUrl(params);
}
@Override
protected Properties buildConnectionProperties(ConnectParams params) {
Properties properties = new Properties();
String username = params.getUsername();
String password = params.getPassword();
if ((username != null && !username.isEmpty()) || (password != null && !password.isEmpty())) {
properties.setProperty(JDBC_USERNAME_PROPERTY, username == null ? "" : username);
properties.setProperty(JDBC_PASSWORD_PROPERTY, password == null ? "" : password);
}
return properties;
}
@Override
protected void afterConnect(ConnectParams params, Connection connection) {
// Ignite has no catalogs; name the virtual database after the connection's
// real default schema (e.g. PUBLIC) so any database-as-schema context that
// leaks into setSchema targets a schema that actually exists.
try {
String schema = connection.getSchema();
databaseName = schema == null || schema.isBlank() ? params.getDatabase() : schema;
} catch (Exception e) {
databaseName = params.getDatabase();
}
}
@Override
public List<DatabaseInfo> listDatabases() {
// Ignite has no catalogs; expose the connected schema group as a single
// virtual database so the object browser stays schema-oriented like H2.
return List.of(new DatabaseInfo(databaseName.isBlank() ? "default" : databaseName));
}
@Override
public List<String> listSchemas() {
return unchecked(() -> {
List<String> result = new ArrayList<>();
try (ResultSet rs = requireConnected().getMetaData().getSchemas()) {
while (rs.next()) {
result.add(rs.getString("TABLE_SCHEM"));
}
}
result.sort(Comparator.naturalOrder());
return result;
});
}
@Override
public List<TableInfo> listTables(String schema) {
return unchecked(() -> {
List<TableInfo> result = new ArrayList<>();
try (ResultSet rs = requireConnected().getMetaData().getTables(null, schema, null, null)) {
while (rs.next()) {
result.add(new TableInfo(
rs.getString("TABLE_NAME"),
normalizeTableType(rs.getString("TABLE_TYPE")),
null
));
}
}
result.sort(Comparator.comparing(TableInfo::getName));
return result;
});
}
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
return unchecked(() -> {
Set<String> primaryKeys = new LinkedHashSet<>();
try (ResultSet rs = requireConnected().getMetaData().getPrimaryKeys(null, schema, table)) {
while (rs.next()) {
primaryKeys.add(rs.getString("COLUMN_NAME"));
}
}
List<ColumnInfo> result = new ArrayList<>();
try (ResultSet rs = requireConnected().getMetaData().getColumns(null, schema, table, null)) {
while (rs.next()) {
String colName = rs.getString("COLUMN_NAME");
result.add(new ColumnInfo(
colName,
rs.getString("TYPE_NAME"),
"YES".equals(rs.getString("IS_NULLABLE")),
rs.getString("COLUMN_DEF"),
primaryKeys.contains(colName),
null,
emptyToNull(rs.getString("REMARKS")),
intOrNull(rs, "COLUMN_SIZE"),
intOrNull(rs, "DECIMAL_DIGITS"),
null
));
}
}
return result;
});
}
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
return Collections.emptyList();
}
@Override
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
return Collections.emptyList();
}
@Override
public List<TriggerInfo> listTriggers(String schema, String table) {
return Collections.emptyList();
}
@Override
public String setSchemaSQL(String schema) {
// JdbcConnection implements Connection.setSchema, so no SQL switch is needed.
return "";
}
static String buildUrl(ConnectParams params) {
String connectionString = params.getConnection_string();
if (connectionString != null && !connectionString.trim().isEmpty()) {
return connectionString.trim();
}
String base = "jdbc:ignite:thin://" + params.getHost() + ":" + params.getPort();
String database = params.getDatabase();
String url = database == null || database.isBlank() ? base : base + "/" + database;
// Reuse the shared JDBC URL parameter mapping so advanced options
// (timeouts, SSL, authentication) configured in the UI reach the driver.
return JdbcAgentProfile.appendUrlParams(url, params.getUrl_params());
}
private static String normalizeTableType(String type) {
if ("BASE TABLE".equals(type)) {
return "TABLE";
}
return type;
}
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 String emptyToNull(String value) {
return value == null || value.isEmpty() ? null : value;
}
public static void main(String[] args) {
new MultiSessionJsonRpcServer(Ignite3Agent::new).run();
}
}
@@ -0,0 +1,16 @@
package com.dbx.agent.ignite3;
import com.dbx.agent.DatabaseAgent;
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
class Ignite3AgentTest extends JdbcFakeExecutionBehaviorTest {
@Override
protected DatabaseAgent createAgent() {
return new Ignite3Agent();
}
@Override
protected String resultSetSql() {
return "SELECT 1";
}
}
@@ -0,0 +1,245 @@
package com.dbx.agent.ignite3;
import com.dbx.agent.ConnectParams;
import java.lang.reflect.Proxy;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.DriverPropertyInfo;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import java.util.logging.Logger;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class Ignite3AgentUrlTest {
@Test
void buildUrlUsesExplicitConnectionString() {
ConnectParams params = new ConnectParams(
"127.0.0.1", 10800, "PUBLIC", "", "", "",
"jdbc:ignite:thin://10.0.0.1:10800/PUBLIC?connectionTimeout=5000", false
);
Assertions.assertEquals(
"jdbc:ignite:thin://10.0.0.1:10800/PUBLIC?connectionTimeout=5000",
Ignite3Agent.buildUrl(params)
);
}
@Test
void buildUrlWithoutDatabaseOmitsSchemaSegment() {
ConnectParams params = new ConnectParams("127.0.0.1", 10800, "", "", "", "", "", false);
Assertions.assertEquals("jdbc:ignite:thin://127.0.0.1:10800", Ignite3Agent.buildUrl(params));
}
@Test
void buildUrlWithoutUrlParamsStaysUnchanged() {
ConnectParams params = new ConnectParams("127.0.0.1", 10800, "PUBLIC", "", "", null, "", false);
Assertions.assertEquals("jdbc:ignite:thin://127.0.0.1:10800/PUBLIC", Ignite3Agent.buildUrl(params));
}
@Test
void buildUrlAppendsAdvancedUrlParamsAfterSchema() {
ConnectParams params = new ConnectParams(
"127.0.0.1", 10800, "PUBLIC", "", "",
"connectionTimeout=5000&queryTimeout=30", "", false
);
Assertions.assertEquals(
"jdbc:ignite:thin://127.0.0.1:10800/PUBLIC?connectionTimeout=5000&queryTimeout=30",
Ignite3Agent.buildUrl(params)
);
}
@Test
void buildUrlPassesThroughUserParamStringVerbatim() {
ConnectParams params = new ConnectParams(
"127.0.0.1", 10800, "PUBLIC", "", "",
"sslEnabled=true;trustStorePath=/etc/ignite/trust.jks", "", false
);
Assertions.assertEquals(
"jdbc:ignite:thin://127.0.0.1:10800/PUBLIC?sslEnabled=true;trustStorePath=/etc/ignite/trust.jks",
Ignite3Agent.buildUrl(params)
);
}
@Test
void buildUrlStripsLeadingParamSeparators() {
ConnectParams params = new ConnectParams("127.0.0.1", 10800, "", "", "", "?sslEnabled=true", "", false);
Assertions.assertEquals("jdbc:ignite:thin://127.0.0.1:10800?sslEnabled=true", Ignite3Agent.buildUrl(params));
}
@Test
void usesThePinnedIgniteJdbcDependency() throws Exception {
Path driverJar = Path.of(
org.apache.ignite.jdbc.IgniteJdbcDriver.class.getProtectionDomain().getCodeSource().getLocation().toURI()
);
Assertions.assertEquals("ignite-jdbc-3.1.0.jar", driverJar.getFileName().toString());
}
@Test
void separateCredentialsRoundTripThroughJdbcPropertiesAndIgniteParser() throws Exception {
String username = "dbx-user";
String password = "dbx-secret";
String urlParams = "sslEnabled=true&connectionTimeout=5000&queryTimeout=30&connectionTimeZone=UTC";
CapturingDriver capturingDriver = new CapturingDriver();
DriverManager.registerDriver(capturingDriver);
try {
ConnectParams captureParams = new ConnectParams(
"127.0.0.1", 10800, "PUBLIC", username, password, urlParams,
CapturingDriver.URL, false
);
Properties connectionProperties = new Ignite3Agent().buildConnectionProperties(captureParams);
try (Connection ignored = DriverManager.getConnection(CapturingDriver.URL, connectionProperties)) {
Assertions.assertEquals(CapturingDriver.URL, capturingDriver.url);
}
Assertions.assertEquals(username, capturingDriver.properties.getProperty("ignite.jdbc.username"));
Assertions.assertEquals(password, capturingDriver.properties.getProperty("ignite.jdbc.password"));
Assertions.assertNull(capturingDriver.properties.getProperty("user"));
Assertions.assertNull(capturingDriver.properties.getProperty("password"));
ConnectParams igniteParams = new ConnectParams(
"127.0.0.1", 10800, "PUBLIC", username, password, urlParams, "", false
);
String igniteUrl = Ignite3Agent.buildUrl(igniteParams);
Assertions.assertFalse(igniteUrl.contains(username));
Assertions.assertFalse(igniteUrl.contains(password));
Map<String, String> parsed = parsedProperties(
new org.apache.ignite.jdbc.IgniteJdbcDriver(),
igniteUrl,
capturingDriver.properties
);
Assertions.assertEquals(username, parsed.get("ignite.jdbc.username"));
Assertions.assertEquals(password, parsed.get("ignite.jdbc.password"));
Assertions.assertEquals("true", parsed.get("ignite.jdbc.sslEnabled"));
Assertions.assertEquals("5000", parsed.get("ignite.jdbc.connectionTimeout"));
Assertions.assertEquals("30", parsed.get("ignite.jdbc.queryTimeout"));
Assertions.assertEquals("UTC", parsed.get("ignite.jdbc.connectionTimeZone"));
} finally {
DriverManager.deregisterDriver(capturingDriver);
}
}
@Test
void blankCredentialsDoNotEnableIgniteAuthentication() throws Exception {
CapturingDriver capturingDriver = new CapturingDriver();
DriverManager.registerDriver(capturingDriver);
try {
ConnectParams params = new ConnectParams(
"127.0.0.1", 10800, "PUBLIC", "", "", "", CapturingDriver.URL, false
);
Properties connectionProperties = new Ignite3Agent().buildConnectionProperties(params);
try (Connection ignored = DriverManager.getConnection(CapturingDriver.URL, connectionProperties)) {
Assertions.assertTrue(capturingDriver.properties.isEmpty());
}
} finally {
DriverManager.deregisterDriver(capturingDriver);
}
}
@Test
void semicolonAdvancedParamsStayVerbatimAndAreNotSplitInQueryMode() throws Exception {
String url = Ignite3Agent.buildUrl(new ConnectParams(
"127.0.0.1", 10800, "PUBLIC", "", "",
"sslEnabled=true;trustStorePath=/etc/ignite/trust.jks", "", false
));
Assertions.assertEquals(
"jdbc:ignite:thin://127.0.0.1:10800/PUBLIC?sslEnabled=true;trustStorePath=/etc/ignite/trust.jks",
url
);
Map<String, String> parsed = parsedProperties(
new org.apache.ignite.jdbc.IgniteJdbcDriver(),
url,
new Properties()
);
Assertions.assertEquals("false", parsed.get("ignite.jdbc.sslEnabled"));
Assertions.assertNull(parsed.get("ignite.jdbc.trustStorePath"));
}
private static Map<String, String> parsedProperties(
Driver driver,
String url,
Properties properties
) throws SQLException {
Map<String, String> parsed = new LinkedHashMap<>();
for (DriverPropertyInfo property : driver.getPropertyInfo(url, properties)) {
parsed.put(property.name, property.value);
}
return parsed;
}
private static final class CapturingDriver implements Driver {
private static final String URL = "jdbc:dbx-ignite3-test:";
private String url;
private Properties properties;
@Override
public Connection connect(String url, Properties info) {
if (!acceptsURL(url)) {
return null;
}
this.url = url;
properties = new Properties();
properties.putAll(info);
return (Connection) Proxy.newProxyInstance(
Connection.class.getClassLoader(),
new Class<?>[]{Connection.class},
(proxy, method, args) -> {
if ("close".equals(method.getName())) {
return null;
}
if ("isClosed".equals(method.getName())) {
return false;
}
throw new UnsupportedOperationException(method.getName());
}
);
}
@Override
public boolean acceptsURL(String url) {
return URL.equals(url);
}
@Override
public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) {
return new DriverPropertyInfo[0];
}
@Override
public int getMajorVersion() {
return 1;
}
@Override
public int getMinorVersion() {
return 0;
}
@Override
public boolean jdbcCompliant() {
return false;
}
@Override
public Logger getParentLogger() {
return Logger.getGlobal();
}
}
}
+3
View File
@@ -48,6 +48,9 @@ for TLS or mTLS connections.
paging, and sequential batch/transaction execution.
- Table sessions expose databases, tables, TIME/TAG/FIELD column categories,
comments, queries, DDL, paging, and database switching.
- Query results annotate timestamp columns as `TIMESTAMP(ms|us|ns)` using the
server-reported `TimestampPrecision`. Raw timestamp integers are transported
as decimal strings so nanosecond values are not rounded by JavaScript.
- The Agent keeps one physical IoTDB session per logical DBX session and
invalidates that session after cancellation, timeout, or connection failure.
+61 -8
View File
@@ -78,8 +78,9 @@ type connectionConfig struct {
}
type sessionClient struct {
session iotdbSession
dialect string
session iotdbSession
dialect string
timestampPrecision string
}
func parseConnectionConfig(params connectParams) (connectionConfig, error) {
@@ -215,10 +216,9 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) {
func newSessionClient(config connectionConfig) (*sessionClient, error) {
var session client.Session
var err error
database := config.Database
if config.Dialect == client.TableSqlDialect {
database = ""
}
// DBX applies a table database with USE after switching dialects. Do not
// include a tree database in openSession: IoTDB 2.x rejects it there, while
// DBX still retains it for metadata and path qualification.
if len(config.NodeURLs) > 1 {
session, err = client.NewClusterSession(&client.ClusterConfig{
NodeUrls: config.NodeURLs,
@@ -227,7 +227,6 @@ func newSessionClient(config connectionConfig) (*sessionClient, error) {
FetchSize: config.FetchSize,
TimeZone: config.TimeZone,
ConnectRetryMax: config.ConnectRetryMax,
Database: database,
TLSConfig: config.TLSConfig,
})
if err == nil {
@@ -242,7 +241,6 @@ func newSessionClient(config connectionConfig) (*sessionClient, error) {
FetchSize: config.FetchSize,
TimeZone: config.TimeZone,
ConnectRetryMax: config.ConnectRetryMax,
Database: database,
TLSConfig: config.TLSConfig,
})
err = session.Open(config.EnableCompression, config.ConnectTimeoutMS)
@@ -266,9 +264,64 @@ func newSessionClient(config connectionConfig) (*sessionClient, error) {
}
}
}
precisionTimeout := min(time.Duration(config.ConnectTimeoutMS)*time.Millisecond, 5*time.Second)
precisionCtx, precisionCancel := context.WithTimeout(context.Background(), precisionTimeout)
connected.timestampPrecision, _ = queryTimestampPrecision(precisionCtx, wrapped)
precisionCancel()
return connected, nil
}
func queryTimestampPrecision(ctx context.Context, session iotdbSession) (string, error) {
dataset, err := session.ExecuteQueryStatement(ctx, "SHOW VARIABLES", nil)
if err != nil {
return "", err
}
defer dataset.Close()
columns := dataset.GetColumnNames()
variableIndex, valueIndex := int32(1), int32(2)
for index, column := range columns {
switch strings.ToLower(strings.TrimSpace(column)) {
case "variable":
variableIndex = int32(index + 1)
case "value":
valueIndex = int32(index + 1)
}
}
for {
hasNext, err := dataset.Next()
if err != nil {
return "", err
}
if !hasNext {
return "", errors.New("IoTDB SHOW VARIABLES did not return TimestampPrecision")
}
variable, err := dataset.GetStringByIndex(variableIndex)
if err != nil {
return "", err
}
if !strings.EqualFold(strings.ReplaceAll(strings.TrimSpace(variable), "_", ""), "TimestampPrecision") {
continue
}
value, err := dataset.GetStringByIndex(valueIndex)
if err != nil {
return "", err
}
if precision := normalizeTimestampPrecision(value); precision != "" {
return precision, nil
}
return "", fmt.Errorf("unsupported IoTDB timestamp precision: %s", value)
}
}
func normalizeTimestampPrecision(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "ms", "us", "ns":
return strings.ToLower(strings.TrimSpace(value))
default:
return ""
}
}
func (s *sessionClient) Close() error {
if s == nil || s.session == nil {
return nil
+31 -4
View File
@@ -43,6 +43,9 @@ func TestLiveIoTDBAgentTreeAndTable(t *testing.T) {
if err != nil || len(page.Rows) != 2 || !page.HasMore || page.SessionID == nil {
t.Fatalf("tree first page = %#v, %v", page, err)
}
if page.ColumnTypes[0] != "TIMESTAMP(ms)" || page.Rows[0][0] != "1" {
t.Fatalf("tree time column = types %#v row %#v", page.ColumnTypes, page.Rows[0])
}
lastPage, err := treeServer.fetchQueryPage(*page.SessionID, 2)
if err != nil || len(lastPage.Rows) != 1 || lastPage.HasMore || !lastPage.Truncated {
t.Fatalf("tree final page = %#v, %v", lastPage, err)
@@ -62,15 +65,15 @@ func TestLiveIoTDBAgentTreeAndTable(t *testing.T) {
mustExecuteNonQuery(t, tableServer, "CREATE DATABASE "+quoteTableIdentifier(tableDatabase), "")
mustExecuteNonQuery(t, tableServer,
"CREATE TABLE "+quoteTableIdentifier(tableDatabase)+"."+quoteTableIdentifier("d1")+
" (time TIMESTAMP TIME, device STRING TAG, s1 INT64 FIELD COMMENT 'value') COMMENT 'DBX live test' WITH (TTL='INF')",
" (time TIMESTAMP TIME, device STRING TAG, event_time TIMESTAMP FIELD, s1 INT64 FIELD COMMENT 'value') COMMENT 'DBX live test' WITH (TTL='INF')",
tableDatabase,
)
mustExecuteNonQuery(t, tableServer,
"INSERT INTO "+quoteTableIdentifier("d1")+"(time,device,s1) VALUES(1,'a',10),(2,'a',20)",
"INSERT INTO "+quoteTableIdentifier("d1")+"(time,device,event_time,s1) VALUES(1,'a',1001,10),(2,'a',1002,20)",
tableDatabase,
)
tableColumns, err := tableServer.getColumns(tableDatabase, "d1")
if err != nil || len(tableColumns) != 3 || !tableColumns[0].IsPrimaryKey || !tableColumns[1].IsPrimaryKey {
if err != nil || len(tableColumns) != 4 || !tableColumns[0].IsPrimaryKey || !tableColumns[1].IsPrimaryKey || tableColumns[2].IsPrimaryKey {
t.Fatalf("table getColumns() = %#v, %v", tableColumns, err)
}
comment, err := tableServer.getTableComment(tableDatabase, "d1")
@@ -78,7 +81,7 @@ func TestLiveIoTDBAgentTreeAndTable(t *testing.T) {
t.Fatalf("table comment = %#v, %v", comment, err)
}
result, err := tableServer.executeQuery(queryOptions{SQL: "SELECT * FROM d1 ORDER BY time", Database: tableDatabase, MaxRows: 10})
if err != nil || len(result.Rows) != 2 || result.Rows[0][0] != "1970-01-01T08:00:00.001+08:00" {
if err != nil || len(result.Rows) != 2 || result.ColumnTypes[0] != "TIMESTAMP(ms)" || result.ColumnTypes[2] != "TIMESTAMP(ms)" || result.Rows[0][0] != "1" || result.Rows[0][2] != "1001" {
t.Fatalf("table query = %#v, %v", result, err)
}
tableDDL, err := tableServer.getTableDDL(tableDatabase, "d1")
@@ -87,6 +90,30 @@ func TestLiveIoTDBAgentTreeAndTable(t *testing.T) {
}
}
func TestLiveIoTDBAgentTreeDatabaseConnection(t *testing.T) {
if os.Getenv("DBX_IOTDB_LIVE") != "1" {
t.Skip("set DBX_IOTDB_LIVE=1 to run against a real IoTDB server")
}
database := "root.dbx_go_connection_" + strconv.Itoa(os.Getpid())
bootstrap := liveIoTDBServer(t, connectParams{})
defer bootstrap.disconnect()
mustExecuteNonQuery(t, bootstrap, "CREATE DATABASE "+database, "")
defer func() { _ = bootstrap.executeNonQuery("DELETE DATABASE "+database, "", 0) }()
scoped := liveIoTDBServer(t, connectParams{Database: database})
defer scoped.disconnect()
if err := scoped.validateConnection(); err != nil {
t.Fatal(err)
}
tables, err := scoped.listTables(database, metadataListConstraints{})
if err != nil {
t.Fatal(err)
}
if tables == nil {
t.Fatal("expected an initialized tree table list")
}
}
func TestLiveIoTDBAgentProcessMultiSessionAndCancellation(t *testing.T) {
if os.Getenv("DBX_IOTDB_LIVE") != "1" {
t.Skip("set DBX_IOTDB_LIVE=1 to run against a real IoTDB server")
+49 -2
View File
@@ -120,8 +120,8 @@ func TestMetadataHelpers(t *testing.T) {
func TestNormalizeIoTDBValues(t *testing.T) {
when := time.Date(2026, time.August, 10, 12, 34, 56, 123, time.FixedZone("CST", 8*60*60))
if got := normalizeIoTDBValue(when, "TIMESTAMP"); got != "2026-08-10T12:34:56.000000123+08:00" {
t.Fatalf("unexpected timestamp: %#v", got)
if got := normalizeIoTDBValue(when, "DATETIME"); got != "2026-08-10T12:34:56.000000123+08:00" {
t.Fatalf("unexpected datetime: %#v", got)
}
if got := normalizeIoTDBValue(when, "DATE"); got != "2026-08-10" {
t.Fatalf("unexpected date: %#v", got)
@@ -131,6 +131,53 @@ func TestNormalizeIoTDBValues(t *testing.T) {
}
}
func TestTreeTimeColumnPresentation(t *testing.T) {
columns := []string{"Time", "root.db.d1.s1"}
for _, precision := range []string{"ms", "us", "ns"} {
types := normalizedColumnTypes([]string{"INT64", "DOUBLE"}, columns, client.TreeSqlDialect, precision)
want := []string{"TIMESTAMP(" + precision + ")", "DOUBLE"}
if !reflect.DeepEqual(types, want) {
t.Fatalf("unexpected tree column types for %s: %#v", precision, types)
}
}
}
func TestTableTimestampColumnPresentation(t *testing.T) {
columns := []string{"time", "device", "event_time"}
for _, precision := range []string{"ms", "us", "ns"} {
got := normalizedColumnTypes([]string{"TIMESTAMP", "STRING", "TIMESTAMP"}, columns, client.TableSqlDialect, precision)
want := []string{"TIMESTAMP(" + precision + ")", "STRING", "TIMESTAMP(" + precision + ")"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected table column types for %s: %#v", precision, got)
}
}
}
func TestUnknownTimestampPrecisionDoesNotEnableFormattingMetadata(t *testing.T) {
columns := []string{"Time", "root.db.d1.s1"}
if got := normalizedColumnTypes([]string{"INT64", "DOUBLE"}, columns, client.TreeSqlDialect, ""); !reflect.DeepEqual(got, []string{"TIMESTAMP", "DOUBLE"}) {
t.Fatalf("unexpected unknown-precision types: %#v", got)
}
for _, value := range []string{"ms", "US", " ns "} {
if normalizeTimestampPrecision(value) == "" {
t.Fatalf("expected supported precision %q", value)
}
}
if normalizeTimestampPrecision("seconds") != "" {
t.Fatal("unsupported precision must not be accepted")
}
}
func TestTreeTimeColumnPresentationDoesNotRewriteOrdinaryInt64(t *testing.T) {
columns := []string{"Time", "value"}
if got := normalizedColumnTypes([]string{"INT64", "INT64"}, columns, client.TableSqlDialect, "ms"); !reflect.DeepEqual(got, []string{"INT64", "INT64"}) {
t.Fatalf("unexpected table column types: %#v", got)
}
if got := normalizedColumnTypes([]string{"INT64", "INT64"}, []string{"value", "Time"}, client.TreeSqlDialect, "ms"); !reflect.DeepEqual(got, []string{"INT64", "INT64"}) {
t.Fatalf("unexpected non-axis column types: %#v", got)
}
}
func TestClassifyIoTDBErrors(t *testing.T) {
canceled := classifyRPCError("execute_query", "session-1", context.Canceled)
if canceled.Data.Category != "canceled" || canceled.Data.SessionDisposition != "quarantine" {
+42 -12
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -87,8 +88,8 @@ func (s *server) queryValues(sql, database string, limit, timeoutSecs int) (quer
}
defer dataset.Close()
columns := append([]string(nil), dataset.GetColumnNames()...)
columnTypes := normalizedColumnTypes(dataset.GetColumnTypes(), len(columns))
rows, truncated, err := readDataset(ctx, dataset, connected.dialect, columns, columnTypes, limit)
columnTypes := normalizedColumnTypes(dataset.GetColumnTypes(), columns, connected.dialect, connected.timestampPrecision)
rows, truncated, err := readDataset(ctx, dataset, connected, columns, columnTypes, limit)
if err != nil {
return queryResult{}, err
}
@@ -163,7 +164,7 @@ func (s *server) executeQueryPage(options queryOptions, requestedPageSize int) (
dataset: dataset,
client: connected,
columns: append([]string(nil), dataset.GetColumnNames()...),
columnTypes: normalizedColumnTypes(dataset.GetColumnTypes(), len(dataset.GetColumnNames())),
columnTypes: normalizedColumnTypes(dataset.GetColumnTypes(), dataset.GetColumnNames(), connected.dialect, connected.timestampPrecision),
remaining: limit,
}
rows, hasMore, truncated, err := s.readQuerySessionPage(ctx, queryState, pageSize)
@@ -279,7 +280,7 @@ func (s *server) nextDatasetRow(ctx context.Context, state *querySession) ([]any
if err := ctx.Err(); err != nil {
return nil, false, err
}
row, err := datasetRow(state.dataset, state.client.dialect, state.columns, state.columnTypes)
row, err := datasetRow(state.dataset, state.client, state.columns, state.columnTypes)
return row, true, err
}
@@ -372,7 +373,7 @@ func runCancelable[T any](s *server, timeoutSecs int, connected *sessionClient,
return value, err
}
func readDataset(ctx context.Context, dataset *client.SessionDataSet, dialect string, columns, columnTypes []string, limit int) ([][]any, bool, error) {
func readDataset(ctx context.Context, dataset *client.SessionDataSet, connected *sessionClient, columns, columnTypes []string, limit int) ([][]any, bool, error) {
if limit <= 0 {
limit = defaultMaxRows
}
@@ -391,7 +392,7 @@ func readDataset(ctx context.Context, dataset *client.SessionDataSet, dialect st
if err := ctx.Err(); err != nil {
return nil, false, err
}
row, err := datasetRow(dataset, dialect, columns, columnTypes)
row, err := datasetRow(dataset, connected, columns, columnTypes)
if err != nil {
return nil, false, err
}
@@ -404,7 +405,7 @@ func readDataset(ctx context.Context, dataset *client.SessionDataSet, dialect st
return rows, hasNext, err
}
func datasetRow(dataset *client.SessionDataSet, dialect string, columns, columnTypes []string) ([]any, error) {
func datasetRow(dataset *client.SessionDataSet, connected *sessionClient, columns, columnTypes []string) ([]any, error) {
row := make([]any, len(columns))
for index := range columns {
columnIndex := int32(index + 1)
@@ -412,12 +413,20 @@ func datasetRow(dataset *client.SessionDataSet, dialect string, columns, columnT
if index < len(columnTypes) {
columnType = strings.ToUpper(columnTypes[index])
}
if dialect == client.TreeSqlDialect && index == 0 && strings.EqualFold(columns[index], "Time") {
value, err := dataset.GetStringByIndex(columnIndex)
if iotdbColumnTypeBase(columnType) == "TIMESTAMP" {
isNull, err := dataset.IsNullByIndex(columnIndex)
if err != nil {
return nil, err
}
row[index] = value
if isNull {
row[index] = nil
continue
}
value, err := dataset.GetLongByIndex(columnIndex)
if err != nil {
return nil, err
}
row[index] = strconv.FormatInt(value, 10)
continue
}
value, err := dataset.GetObjectByIndex(columnIndex)
@@ -455,16 +464,37 @@ func normalizeIoTDBValue(value any, columnType string) any {
}
}
func normalizedColumnTypes(values []string, columnCount int) []string {
result := make([]string, columnCount)
func normalizedColumnTypes(values, columns []string, dialect, timestampPrecision string) []string {
result := make([]string, len(columns))
for index := range result {
if index < len(values) {
result[index] = strings.ToUpper(values[index])
}
if isTreeTimeColumn(dialect, columns, index) {
result[index] = "TIMESTAMP"
}
if iotdbColumnTypeBase(result[index]) == "TIMESTAMP" {
result[index] = timestampColumnType(timestampPrecision)
}
}
return result
}
func timestampColumnType(precision string) string {
if normalized := normalizeTimestampPrecision(precision); normalized != "" {
return "TIMESTAMP(" + normalized + ")"
}
return "TIMESTAMP"
}
func iotdbColumnTypeBase(value string) string {
return strings.TrimSpace(strings.SplitN(strings.ToUpper(value), "(", 2)[0])
}
func isTreeTimeColumn(dialect string, columns []string, index int) bool {
return dialect == client.TreeSqlDialect && index == 0 && len(columns) > 0 && strings.EqualFold(columns[0], "Time")
}
func timeoutMilliseconds(timeoutSecs int) *int64 {
if timeoutSecs <= 0 {
return nil
@@ -62,7 +62,7 @@ public final class IrisAgent extends ConfiguredJdbcAgent {
stringOrNull(rs, "COLUMN_DEFAULT"),
"YES".equalsIgnoreCase(stringOrNull(rs, "PRIMARY_KEY")),
null,
null,
stringOrNull(rs, "DESCRIPTION"),
intOrNull(rs, "NUMERIC_PRECISION"),
intOrNull(rs, "NUMERIC_SCALE"),
intOrNull(rs, "CHARACTER_MAXIMUM_LENGTH")
@@ -126,7 +126,7 @@ public final class IrisAgent extends ConfiguredJdbcAgent {
private static String columnSql(String schema) {
StringBuilder sql = new StringBuilder(
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, "
+ "PRIMARY_KEY, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH "
+ "PRIMARY_KEY, DESCRIPTION, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH "
+ "FROM INFORMATION_SCHEMA.COLUMNS WHERE "
);
if (!isBlank(schema)) {
@@ -19,6 +19,7 @@ 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 IrisAgentTest {
@@ -63,12 +64,45 @@ class IrisAgentTest {
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 = IrisAgent.irisColumns(conn, 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
@@ -185,8 +219,11 @@ class IrisAgentTest {
"COLUMN_NAME", "ID",
"DATA_TYPE", "INTEGER",
"IS_NULLABLE", "NO",
"COLUMN_DEFAULT", "42",
"PRIMARY_KEY", inlinePrimaryKey ? "YES" : "NO",
"NUMERIC_PRECISION", 10
"DESCRIPTION", "identifier",
"NUMERIC_PRECISION", 10,
"NUMERIC_SCALE", 0
),
Map.of(
"COLUMN_NAME", "NAME",

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