Compare commits

...
Author SHA1 Message Date
github-actions[bot] 9f6e9af634 chore(packages): release 0.4.117 [skip node-packages-release] 2026-10-12 16:31:46 +00:00
github-actions[bot] 0be46fc400 chore(jdbc): bump plugin version [skip ci] 2026-10-12 16:14:59 +00:00
t8y2 d9c464d7d5 chore(release): prepare v0.6.42 2026-10-13 00:14:10 +08:00
github-actions[bot] 287a21457e chore: bump module versions [skip ci] 2026-10-12 16:09:24 +00:00
renovate[bot] 42eafa6d6b fix(deps): update kafka-clients to v3.9.2 2026-10-13 00:03:05 +08:00
Abeautifulsnow 95f4511b0d feat(plugins): confirm dependent connections before plugin uninstall 2026-10-12 23:38:31 +08:00
lxk955 cd77b06a59 fix(oracle): copy and snapshot temporal cells in display format 2026-10-12 23:38:22 +08:00
HARRISON f5f58d4ae5 feat(grid): add a custom filter entry to the cell context menu 2026-10-12 23:19:33 +08:00
二丫讲梵 5ae344409a fix(sql-file): show DDL files by default 2026-10-12 23:19:23 +08:00
zipg e3dfe324e8 fix(import): lift the stale xlsx/xlsm size limit via incremental reads 2026-10-12 23:19:13 +08:00
Jeff 0feb065e48 fix(jdbc): keep Oracle PL/SQL units whole on generic JDBC connect 2026-10-12 23:19:05 +08:00
renovate[bot] 838c115a2c fix(deps): update iotdb-jdbc to v2.0.11 2026-10-12 23:18:56 +08:00
renovate[bot] 61939e5e46 fix(deps): update kylin-jdbc to v5.0.4 2026-10-12 23:18:47 +08:00
renovate[bot] bcbc64c635 chore(deps): update taiki-e/install-action digest to a3324fb 2026-10-12 22:43:42 +08:00
renovate[bot] c65a627045 chore(deps): update nacos-server docker tag to v2.5.4 2026-10-12 22:43:34 +08:00
renovate[bot] 440625949b chore(deps): update rnacos docker tag to v0.8.7 2026-10-12 22:43:26 +08:00
renovate[bot] f70a9b2937 fix(deps): update jackcess-encrypt to v5.1.6 2026-10-12 22:39:51 +08:00
_Mr丶s ゛😁® a1824c9079 fix(desktop): keep the macOS app icon dark in dark theme 2026-10-12 22:30:02 +08:00
_Mr丶s ゛😁® d022f068cd fix(sidebar): hide the MQTT console leaf expander 2026-10-12 22:29:53 +08:00
renovate[bot] 85fc95bac8 fix(deps): update oceanbase-client to v2.4.19 2026-10-12 22:29:44 +08:00
renovate[bot] b115002d07 fix(deps): update terajdbc to v20.00.00.60 2026-10-12 22:29:36 +08:00
renovate[bot] 3deb4bda7b fix(deps): update ucanaccess to v5.1.8 2026-10-12 22:29:17 +08:00
zipg e797340466 feat(web): keep saved connection credentials on the server 2026-10-12 21:50:56 +08:00
Andrew fdfdd25d23 feat(gaussdb): enable manual transaction management 2026-10-12 21:50:46 +08:00
Xsyl.way 4740942e8b fix(dameng): degrade trigger metadata to unavailable on permission errors 2026-10-12 21:28:52 +08:00
ordinary being d219f2b8ba fix(transfer): preserve explicit auto-increment as native identity 2026-10-12 21:16:09 +08:00
HARRISON 18980ecdc2 fix(editor): convert editor text from the keyword-case toggle 2026-10-12 21:14:31 +08:00
_Mr丶s ゛😁® fad22512cd feat(mqtt): filter the message list by direction 2026-10-12 21:14:25 +08:00
renovate[bot] 083e05d412 fix(deps): update databend-jdbc to v0.4.8 2026-10-12 21:12:53 +08:00
renovate[bot] 88ea213c35 fix(deps): update exasol-jdbc to v26.2.9 2026-10-12 21:12:48 +08:00
renovate[bot] 85b18a94d6 fix(deps): update informix jdbc to v4.50.14 2026-10-12 21:12:44 +08:00
renovate[bot] 7670e04879 fix(deps): update jackson-databind to v2.22.3 2026-10-12 21:12:39 +08:00
弈秋 fc84e09738 feat(dameng): native dexp/dimp (.dmp) fast export/import 2026-10-12 20:26:19 +08:00
_Mr丶s ゛😁® 177b9a3b39 fix(mqtt): preserve console history across disconnects 2026-10-12 19:50:43 +08:00
Jeff 4a4a7603b5 fix(sql): sort Oracle duplicate columns without derived tables 2026-10-12 19:32:53 +08:00
Jeff 6a1778eaf3 fix(jdbc): cancel running statements when queries stop 2026-10-12 19:32:34 +08:00
二丫讲梵 72a86b56ac feat(sql-file): support folder management 2026-10-12 19:31:52 +08:00
renovate[bot] 9dd7d1f044 fix(deps): update dependency com.highgo:hgdbjdbc to v6.2.5 2026-10-12 19:08:47 +08:00
Jeff cdb4ab5c80 fix(transfer): split reused Oracle table DDL and transferred trigger DDL 2026-10-12 18:28:02 +08:00
t8y2 97e3045a7c fix(ci): allow two concurrent contributor PR runs split by PR parity 2026-10-12 18:23:37 +08:00
Abeautifulsnow f43a99b4fe fix(ai): list @ table mentions from every selected database 2026-10-12 17:50:20 +08:00
HARRISON ccac7ba14f feat(sidebar): show the production badge on object rows 2026-10-12 16:18:48 +08:00
Elias d7df7fe3c0 feat(xugu): enable schema-scoped field lineage 2026-10-12 16:17:01 +08:00
lxk955 2bf6af1ff0 fix(grid): enable table data editing and add-row for Cache / IRIS 2026-10-12 15:53:50 +08:00
Jeff 860d901115 fix(export): include Oracle sequences in database export 2026-10-12 15:22:12 +08:00
lxk955 524a93203b fix(sql): complete common MySQL and Doris date and utility functions 2026-10-12 14:39:51 +08:00
ordinary being a6b26ff1a6 fix(objects): open database schema overview rows under clicked schema 2026-10-12 14:39:38 +08:00
lxk955 6f6cf2f57c fix(sql): route shortcut new query to selected sidebar connection 2026-10-12 14:39:15 +08:00
Abeautifulsnow 6b293e9eaa fix(editor): normalize shortcut search separator spaces 2026-10-12 14:38:52 +08:00
t8y2 a7dac3434d fix(sql-data): rename stale test literal to primary_key_mode 2026-10-12 14:36:57 +08:00
efeire 653bf84829 fix(grid): guard OceanBase ROWID query editing boundaries 2026-10-12 13:02:52 +08:00
efeire ba1d5ec943 fix(oceanbase): preserve full LOB snapshot read write and export semantics 2026-10-12 12:24:34 +08:00
_Mr丶s ゛😁® 994f520df1 feat(export): unify primary key modes for SQL copy and export 2026-10-12 12:22:15 +08:00
efeire 380f6a548b fix(oceanbase): preserve object paging and completion metadata identity 2026-10-12 12:04:08 +08:00
yangzidemuou a88f8dabe7 feat(redis): choose head or tail when pushing into lists 2026-10-12 12:02:32 +08:00
Yifeng Xu 885ccf43ba feat(tabs): 为标签页添加自定义标题后缀功能 (#11708)
* feat(tabs): 为标签页添加自定义标题后缀功能

* feat(tabs): 添加清除标签后缀功能并更新相关国际化文本
2026-10-12 10:06:33 +08:00
t8y2 7cbef302ea chore(release): prepare v0.6.41 2026-10-12 08:51:15 +08:00
github-actions[bot] 76afdb2e89 chore(packages): release 0.4.116 [skip node-packages-release] 2026-10-12 00:42:23 +00:00
github-actions[bot] ea8c2da594 chore: bump module versions [skip ci] 2026-10-12 00:27:27 +00:00
Guoyu Su 1b958c4b3f fix(redis): support Valkey cluster logical databases 2026-10-12 08:23:02 +08:00
github-actions[bot] 0c9330e4e6 chore(docs): refresh contributor activity 2026-10-11 18:12:48 +00:00
lxk955 da281de1d1 fix(query): prevent hidden ROWID in Oracle grouped queries 2026-10-11 23:53:52 +08:00
二丫讲梵 f73114a381 feat(plugins): add marketplace details and workbench launch 2026-10-11 23:31:22 +08:00
ordinary being b3fc86ac3b fix(oceanbase-oracle): cover sorting without an ORDER BY clause 2026-10-11 23:09:08 +08:00
lxk955 018fea60fa fix(jdbc): include SQL Server builtin schemas with legacy fallback 2026-10-11 21:36:49 +08:00
dienaso 29cab5c0b8 fix(transfer): retarget SQL Server DDL schemas and comment owners 2026-10-11 21:32:30 +08:00
Hang 169fc05087 fix(sql): preserve DBLinks and SQL function templates 2026-10-11 21:25:46 +08:00
onenewcode 69a1a4f2d4 fix(sql): align DuckDB multi-statement string splitting 2026-10-11 20:52:06 +08:00
t8y2 06c3bbaa7c feat(ai): add PackyCode partner preset 2026-10-11 20:26:56 +08:00
t8y2 0d12877d89 docs(sponsors): add PackyCode readme entries and website cards 2026-10-11 20:26:54 +08:00
ordinary being 8aca3975d1 fix(oceanbase-oracle): refuse sort rewrite when ORDER BY is hidden 2026-10-11 19:37:34 +08:00
onenewcode bc6dcf8395 fix(export): use table name for single-table exports 2026-10-11 19:37:30 +08:00
lxk955 f5806ba961 fix(sql): resolve GROUP BY/ORDER BY aliases in semicolon-terminated SQL 2026-10-11 19:37:25 +08:00
弈秋 ed3ed28d2b feat(structure): allow naming a newly created primary key 2026-10-11 19:37:20 +08:00
ordinary being 36aa8425e6 fix(safety): scan Oracle alternative-quoted strings in all safety scanners 2026-10-11 18:49:24 +08:00
二丫讲梵 9c069c9cfe fix(tabs): align side tab connection colors 2026-10-11 18:19:56 +08:00
HARRISON b612dec12c fix(editor): explain why Format SQL did nothing 2026-10-11 18:19:51 +08:00
t8y2 c91342ac9d feat(ui): add scroll shortcuts, empty-column hiding and error mapping 2026-10-11 17:59:52 +08:00
lxk955 ad643fcf78 fix(backup): close and hide detached connection popover on scroll 2026-10-11 17:34:57 +08:00
lxk955 f85fe496e6 fix(structure): speed up index editor rendering and column lookups 2026-10-11 17:34:52 +08:00
高桥凉介 03db4cad8c feat(grid): add current datetime generation with milliseconds 2026-10-11 16:46:58 +08:00
lxk955 9f41cdc3f5 fix(postgres): support altering column identity in the structure editor 2026-10-11 15:39:15 +08:00
t8y2 6cd2dc1c9d fix(linux): make transparent floating windows opt-in 2026-10-11 15:34:07 +08:00
ordinary being 74aaa4f643 fix(safety): honour PostgreSQL E'...' escape strings in the risk scanner 2026-10-11 15:30:14 +08:00
一克猫 e7d9bd4ea8 feat(search): add database structure search by column name and comment 2026-10-11 15:24:19 +08:00
弈秋 7b1c29f3cf feat(structure): sort unpicked columns alphabetically in index column picker 2026-10-11 15:11:22 +08:00
Abeautifulsnow ac0cecd1d1 feat(sql-file): export and copy failed statement details 2026-10-11 14:41:46 +08:00
Jeff cdcefb97d5 fix(core): keep the existing pool on concurrent pool creation 2026-10-11 14:22:02 +08:00
Rendegou 3edafe58dd fix(redis): complete collection searches and rank exact hash fields 2026-10-11 14:21:35 +08:00
t8y2 56ec883fb7 fix(core): skip connection identity probe in snapshot test history 2026-10-11 13:53:16 +08:00
t8y2 a7090cea7e fix(postgres): align notice buffer typing with the streaming refactor 2026-10-11 13:53:16 +08:00
lxk955 03a2cd1bd7 fix(grid): preview text stored in Oracle BLOB cells 2026-10-11 13:34:50 +08:00
lxk955 2826bb5fc5 fix(firebird): fix table trigger loading and source viewing 2026-10-11 13:34:23 +08:00
lxk955 63adea29c1 feat(tabs): optimize result run tabs layout and middle click close 2026-10-11 13:33:47 +08:00
弈秋 739e805b83 feat(sidebar): open the first search match with Enter 2026-10-11 13:33:18 +08:00
弈秋 c7fa862792 feat(grid): hint when copying a partially loaded result 2026-10-11 13:32:42 +08:00
弈秋 58e2d2978f feat(editor): support excluding completion items 2026-10-11 13:32:13 +08:00
zipgandzipg 4f56ad408d fix(postgres): preserve unconvertible legacy text on reads (#11647)
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-11 12:48:06 +08:00
zipg 8e52e61c39 fix(grid): 复制页签时保留独立筛选与排序规则 (#11628) 2026-10-11 12:48:02 +08:00
zipg c80ef798e5 fix(mcp): 显示并支持编辑隐藏的权限例外 (#11618) 2026-10-11 12:47:58 +08:00
zipgandzipg 528ad2f62b fix(connection): preserve Oracle JDBC credential characters (#11612)
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-11 12:47:55 +08:00
zipgandzipg 33adceae7d feat(ui): 分组右键菜单及自定义一级操作和排序 (#11564)
* feat(ui): add customizable grouped context menus

* docs: add redacted grouped menu screenshots

* fix(ui): decouple menu preferences and support i18n autofill

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-11 12:47:51 +08:00
zipgandzipg 06de88a367 fix(grid): preserve view state across result tab switches (#11521)
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-11 12:47:46 +08:00
一克猫 4f666d9f8f fix(toolbar): hide update button when check updates is toggled off 2026-10-11 11:56:10 +08:00
lxk955 7dea1ede4f feat(grid): support fetching filter distinct values from current page 2026-10-11 11:49:15 +08:00
Yifeng Xu ead4190584 fix(grid): brighten the current-row background for visibility 2026-10-11 11:48:26 +08:00
t8y2 21d6a5f719 docs(sponsors): add Taiyi Enterprise Edition sponsor 2026-10-11 11:47:39 +08:00
Abeautifulsnow aab0526f52 fix(shortcuts): scope conflict badges to search results 2026-10-11 11:46:41 +08:00
t8y2 41b771a185 docs(sponsors): add Taiyi Enterprise Edition sponsor 2026-10-11 11:44:19 +08:00
github-actions[bot] 66e020f615 chore(packages): release 0.4.115 [skip node-packages-release] 2026-10-11 02:15:09 +00:00
github-actions[bot] 38ca03ef57 chore: bump module versions [skip ci] 2026-10-11 02:03:27 +00:00
github-actions[bot] b2eb0c5f17 chore(jdbc): bump plugin version [skip ci] 2026-10-11 01:53:40 +00:00
t8y2 f39cd023f6 chore(release): prepare v0.6.40 2026-10-11 09:52:52 +08:00
t8y2 12b16cb026 feat(query): stream PostgreSQL notices and capture Kingbase messages
Refs: #2725
2026-10-11 09:36:27 +08:00
ordinary being 1f4e31deef fix(pagination): avoid a second LIMIT when the lexical scan misses it 2026-10-11 09:29:51 +08:00
弈秋 c3f1af5157 fix(oracle): keep JDBC credentials verbatim and hide unsafe ones 2026-10-11 09:29:18 +08:00
lxk955 dc3f6d0fa9 feat(sql): expand nested CTE columns from prior CTE references 2026-10-11 09:28:48 +08:00
Jeff c8816364f7 fix(export): read query result exports through the open manual transaction 2026-10-11 09:16:06 +08:00
t8y2 f468b99f76 fix(sponsors): update HiAPI top-up bonus to 10% on all top-ups 2026-10-11 09:15:29 +08:00
lxk955 a93f118d03 fix(kingbase): display json column type instead of jsonb 2026-10-11 09:15:18 +08:00
lxk955 adedde8e46 fix(export): export decimal columns as numeric in full XLSX export 2026-10-11 09:14:50 +08:00
lxk955 d84539fc42 feat(sidebar): collapse child nodes recursively when folding a parent 2026-10-11 09:14:17 +08:00
弈秋 0453ac83e5 fix(sql): keep MyBatis XML entity semicolons from splitting statements 2026-10-11 09:13:50 +08:00
Jeff a486628e18 fix(postgres): keep hidden key column hidden after sorting 2026-10-11 09:13:18 +08:00
lxk955 6894bb026e fix(editor): apply configured font to the autocomplete popup 2026-10-11 09:12:52 +08:00
t8y2 34fbe3510b fix(ci): restore job condition lines broken in ci.yml 2026-10-11 08:58:17 +08:00
t8y2 972519ba2d chore(ci): run full validation jobs on draft pull requests 2026-10-11 08:54:55 +08:00
dienaso 42faf6ad34 fix(mongo): accept single document shell inserts on legacy agent 2026-10-11 08:49:08 +08:00
弈秋 67847fed2a feat(shortcuts): add configurable Close All Tabs action 2026-10-11 08:48:41 +08:00
弈秋 5cfdf10e5c feat(grid): add values-only JSON array copy 2026-10-11 08:48:13 +08:00
lxk955 124ae27c0b fix(import): restore dimension column equality in xlsx streaming 2026-10-11 08:47:37 +08:00
弈秋 0f87ce0b6a feat(export): add two-row name/comment header mode for XLSX 2026-10-11 08:47:09 +08:00
lxk955 25feadbe37 fix(sql): map uniqueidentifier to uuid for sqlserver-to-postgresql 2026-10-11 08:46:36 +08:00
lxk955 c744598425 feat(import): allow empty string as null for Excel import 2026-10-11 08:46:09 +08:00
lxk955 39505896a6 fix(mysql): resolve ProxySQL hostgroup lock via literal floor setup 2026-10-11 08:45:37 +08:00
lxk955 035fecd17b feat(redis): support serialized JSON string presentation 2026-10-11 08:45:10 +08:00
xiaohuowa b799cf38df feat(sql): add multi-row INSERT template generation for tables 2026-10-11 08:23:19 +08:00
KeaneQy a21880ea13 fix(mcp): serve HTTP transport statelessly for all protocol versions 2026-10-11 08:11:32 +08:00
dienaso d918125a04 fix(mongodb): accept the copied EJSON.deserialize insert form 2026-10-11 08:10:56 +08:00
zipg baf5cf101f fix(ui): close the top dialog instead of the tab on Cmd+W 2026-10-11 08:10:22 +08:00
lxk955 5f6e983364 fix(diff): use pencil icon for renaming compare configs 2026-10-11 08:09:48 +08:00
lxk955 bb408d7f0e fix(sidebar): allow empty database context for hana pagination and filters 2026-10-11 08:09:05 +08:00
DengQingNian 13601ebe1d feat(schema-diff): add MySQL ignore foreign key checks deploy option 2026-10-10 21:10:58 +08:00
二丫讲梵 5eaccd3062 feat(shortcuts): support modifier double taps for search 2026-10-10 20:49:35 +08:00
二丫讲梵 56ea5714af feat(sidebar): pin table search below the database header 2026-10-10 20:44:40 +08:00
lxk955 8786c76ff4 fix(import): recover preview reload after invalid row range 2026-10-10 20:40:15 +08:00
Abeautifulsnow dfe7510ca0 fix(objects): keep the object count for single-kind scopes 2026-10-10 20:35:32 +08:00
zipg 507b6d355f fix(clipboard): keep @-prefixed non-ASCII text verbatim 2026-10-10 20:34:47 +08:00
Jeff 19c2d6d054 fix(sqlserver): filter comment reads by extended property class 2026-10-10 20:10:28 +08:00
github-actions[bot] 55942342d5 chore(packages): release 0.4.114 [skip node-packages-release] 2026-10-10 10:48:28 +00:00
t8y2 7fee71e702 fix(ci): un-draft agent release when resuming publish 2026-10-10 17:58:08 +08:00
t8y2 11974accdb fix(ci): serialize contributor PR runs with repository owner exemption 2026-10-10 17:34:38 +08:00
github-actions[bot] 6dd4d40234 chore: bump module versions [skip ci] 2026-10-10 09:22:13 +00:00
t8y2 9a49b5306c fix(ci): scope PR checks and reduce runner queue contention 2026-10-10 17:18:34 +08:00
github-actions[bot] 6254399add chore(packages): release 0.4.113 [skip node-packages-release] 2026-10-10 09:11:07 +00:00
t8y2 f6f88de508 fix(ci): reduce redundant Actions runs and disable PR notifications 2026-10-10 16:41:47 +08:00
t8y2 9f4205be32 chore(release): prepare v0.6.39 2026-10-10 15:32:57 +08:00
zipgandzipg 9262df465a fix(structure): restore horizontal scrollbar after tab switch (#11582)
* fix(structure): restore horizontal scrollbar after tab switch

* test(structure): cover DDL and foreign key scrollbar return

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-10 15:23:49 +08:00
efeire 66187eef70 fix(oceanbase): keep wildcard pagination on the same result cursor 2026-10-10 15:17:03 +08:00
_Mr丶s ゛😁® 8d61a7e944 feat(sync): extend auto-upload interval range and improve hints 2026-10-10 15:15:48 +08:00
efeire 7653e860dd fix(oracle): restrict DBLink detection to object references 2026-10-10 15:15:09 +08:00
efeire 6748009181 fix(oceanbase-oracle): keep char units and negative NUMBER scale 2026-10-10 14:37:30 +08:00
Jeff e7787b8bb8 fix(sqlserver): read and write comments via the legacy driver 2026-10-10 14:36:54 +08:00
efeire 2532e0ff59 feat(oceanbase-oracle): allow adding primary keys to keyless tables 2026-10-10 14:36:21 +08:00
efeire f899004140 ci: configure Docker Hub mirror cache before live test jobs 2026-10-10 14:35:04 +08:00
t8y2 8f418c59a5 chore(ci): skip heavy validation jobs on draft pull requests 2026-10-10 13:57:46 +08:00
t8y2 2540c5211c chore(ci): temporarily disable pull-request-labels trigger 2026-10-10 13:44:04 +08:00
t8y2 e725226f4e fix(ci): skip non-file entries when uploading agent release assets 2026-10-10 13:24:19 +08:00
t8y2 7c15e38b43 fix(ci): upload only missing agent release assets in batches 2026-10-10 12:11:46 +08:00
_Mr丶s ゛😁® 3825120b57 fix(backup): show app name on macOS backup notifications 2026-10-10 12:05:01 +08:00
lxk955 fa7c9ef49e fix(import): honor custom title rows in Excel streaming import 2026-10-10 11:23:43 +08:00
Abeautifulsnow 20fc49d7db fix(connection): block passive reconnect for user-closed connections 2026-10-10 11:14:10 +08:00
jearhe 36f69f4da9 fix(sql): inherit active context for new SQL editors 2026-10-10 11:09:03 +08:00
t8y2 6eb8242cf7 chore(ci): temporarily disable ui-preview auto trigger 2026-10-10 11:06:12 +08:00
t8y2 29d7d0f62a feat(ai): add HiAPI partner preset and AstraFlow bonus badge
Also point the AstraFlow visit link at the 50 CNY credit landing page.
2026-10-10 10:54:58 +08:00
t8y2 2684e0f316 docs(sponsors): add HiAPI readme entries and website cards 2026-10-10 10:54:57 +08:00
lxk955 68d05ea27c feat(editor): include column comments in batch column insert 2026-10-10 09:51:50 +08:00
lxk955 4f0ce82f4a feat(sql): complete unpaired keywords before existing ones 2026-10-10 09:48:41 +08:00
lxk955 e1e0614199 fix(mongo): preserve custom _id on document insert and copy 2026-10-10 09:46:56 +08:00
lxk955 eda4d3e2d5 fix(oracle): support sequence and pseudo-column completion 2026-10-10 09:46:46 +08:00
lxk955 e6ae58e989 fix(grid): prevent premature termination of multi-chunk load-all 2026-10-10 09:46:34 +08:00
Cristian 786ccad3c4 fix(linux): draw rounded corners, shadow and resize handles 2026-10-10 09:19:59 +08:00
zipg 2982646dee feat(firebird): add catalog object manager 2026-10-10 08:17:21 +08:00
Abeautifulsnow 6c57b0ca9b feat(formatter): add selectable classic layout style 2026-10-10 07:23:35 +08:00
t8y2 b281fbd5fa style(transfer): rustfmt the demo-mode validation gate 2026-10-10 05:00:27 +08:00
t8y2 d926a5cc00 fix(transfer): skip connection validation for demo-mode web transfers 2026-10-10 03:53:51 +08:00
github-actions[bot] 53914d2af2 chore(docs): refresh contributor activity 2026-10-09 18:33:18 +00:00
lxk955 84758d60b3 feat(connection): allow configuring connection idle timeout 2026-10-10 00:14:22 +08:00
Jeff 1f7623e7f5 fix(oracle): resolve current schema on the manual transaction session 2026-10-10 00:14:06 +08:00
lxk955 54b302683a fix(metadata): resolve visible schema for query DDL and loads 2026-10-10 00:13:37 +08:00
lxk955 26a579ba7c fix(kyuubi): use engine identifier quote for connect database 2026-10-10 00:13:14 +08:00
lxk955 28b12df340 feat(sql): respect formatter identifier casing in autocomplete 2026-10-10 00:12:54 +08:00
Jeff 37ea6d4fc8 fix(transfer): clear FK-linked target children first on overwrite 2026-10-10 00:12:38 +08:00
二丫讲梵 0d2293525e fix(sql): quote batch-selected MySQL columns 2026-10-10 00:12:25 +08:00
zipg 5c6030f413 fix(updates): keep MCP update button hidden after auto-update 2026-10-10 00:12:09 +08:00
xiaohuowa 1b703526ea fix(sql): truncate insert template placeholders to column length 2026-10-09 23:10:04 +08:00
Jeff 4af7777f49 fix(oracle): persist the Show Database Links opt-out 2026-10-09 22:41:35 +08:00
Elias 0c95c816b5 feat(xugu): support native Xugu-to-Xugu table transfer 2026-10-09 22:40:01 +08:00
lxk955 14eb349212 fix(import): carry over DataGrip username and warn on fallback 2026-10-09 22:26:05 +08:00
Terry Yao a812c94f67 fix(cloud-sync): improve sync selection dialog UX and counts 2026-10-09 22:23:57 +08:00
Yifeng Xu 7406cb4aac feat(backup): back up multiple selected connections at once 2026-10-09 22:23:41 +08:00
二丫讲梵 9a128d38b4 fix(kingbase): fall back to page estimates for table sizes 2026-10-09 22:23:23 +08:00
Jetsadaporn Klamtanya 3377bf96a6 fix(postgres): keep server text for json and jsonb cells 2026-10-09 21:16:59 +08:00
二丫讲梵 884eb047f9 fix(grid): restore first WHERE suggestion default selection 2026-10-09 21:12:51 +08:00
efeire 235842e6a9 fix(transfer): support OceanBase Oracle upsert and table comments 2026-10-09 21:12:19 +08:00
onenewcode caa43b6f2a fix(sql): recognize Oracle pseudo-columns in semantic diagnostics 2026-10-09 20:50:38 +08:00
efeire e9b6e5fcb8 fix(query): stop stale commits clearing newer transaction sessions 2026-10-09 20:48:14 +08:00
二丫讲梵 73692376ef feat(grid): transpose selected rows preserving selection scope 2026-10-09 20:47:20 +08:00
ccq 7407f12d07 feat(mcp): prompt in desktop before high-risk SQL 2026-10-09 20:43:11 +08:00
ninggc 3ad44a1811 feat(sql-file): add locate button for the active opened file 2026-10-09 20:42:43 +08:00
efeire ec42060ddf fix(oceanbase): preserve required quoting in Oracle-mode templates 2026-10-09 20:41:27 +08:00
efeire 268dad2018 fix(oceanbase): correct Oracle-mode ROWID query and row mapping 2026-10-09 20:39:59 +08:00
efeire d170a7e205 feat(data-generate): support OceanBase Oracle INSERT ALL batches 2026-10-09 20:38:50 +08:00
github-actions[bot] eb6532e573 chore(packages): release 0.4.112 [skip node-packages-release] 2026-10-09 11:46:48 +00:00
github-actions[bot] 9380e98865 chore: bump module versions [skip ci] 2026-10-09 11:25:45 +00:00
845 changed files with 60494 additions and 4298 deletions
+174
View File
@@ -0,0 +1,174 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { readFileSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { detectIssueCommand, handleIssueCommand } from "./issue-commands.mjs";
import { writeContributorSnapshot } from "../../docs/scripts/contributor-snapshot.mjs";
function workflow(name) {
return readFileSync(new URL(`../workflows/${name}.yml`, import.meta.url), "utf8");
}
function job(content, name) {
const jobs = content.slice(content.indexOf("\njobs:\n") + 7);
const definitions = [...jobs.matchAll(/^ ([\w-]+):\s*$/gm)];
const index = definitions.findIndex((match) => match[1] === name);
assert.ok(index >= 0, `missing job ${name}`);
return jobs.slice(definitions[index].index, definitions[index + 1]?.index ?? jobs.length);
}
function evaluateCondition(content, github) {
const folded = content.match(/^ if: >-\n((?: [^\n]*\n)+)/m);
const expression = (folded?.[1] ?? content.match(/^ if: (.+)$/m)?.[1])?.trim();
assert.ok(expression, "missing job condition");
const evaluate = new Function("github", "contains", `return (${expression.replace(/^\$\{\{\s*|\s*\}\}$/g, "")});`);
return evaluate(github, (search, item) => String(search ?? "").toLowerCase().includes(String(item).toLowerCase()));
}
function commentEvent(body, { userType = "User", login = "contributor", pullRequest = false } = {}) {
return { repository_owner: "t8y2", event: {
issue: { pull_request: pullRequest ? { url: "https://api.github.com/repos/t8y2/dbx/pulls/123" } : undefined },
comment: { body, user: { type: userType, login } },
} };
}
test("issue command prefilter admits every supported command and skips unrelated comments", async () => {
const content = job(workflow("issue-claim"), "claim");
for (const body of ["/claim", "please /claim this", "\t/claim\n", "/unclaim", " /unclaimed\n", "\t/close\r\n"]) {
assert.ok(detectIssueCommand(body), body);
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false, body);
assert.equal(evaluateCondition(content, commentEvent(body, { pullRequest: true })), false, body);
}
for (const body of ["Thanks!", "Fixed in abc123", "", null]) {
assert.equal(evaluateCondition(content, commentEvent(body)), false);
}
for (const body of ["/claimable", "please /close", "/unclaim more", "/CLAIM"]) {
assert.equal(evaluateCondition(content, commentEvent(body)), true);
assert.deepEqual(await handleIssueCommand({ commentBody: body, commentUserType: "User", client: {} }), { status: "ignored" });
}
});
test("spam prefilter retains all existing malicious-link cases and never bypasses the matcher", () => {
const content = job(workflow("spam-comment-guard"), "delete-malicious-link");
const pattern = content.match(/grep -qiP '([^']+)'/)[1];
const matcher = new RegExp(pattern.replaceAll("[:space:]", "\\s"), "i");
for (const body of ["https://evil.example/update.zip", "https://evil.example/update.ZIP?download=1",
"[update.zip](//evil.example/download)", "[update.ZIP](https://evil%2Eexample/download)",
"[update.7z](https://evil.example/download)", "[update.exe](https://evil.example/download)",
"[update.SCR](//evil.example/download)"]) {
assert.equal(matcher.test(body), true, body);
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false);
assert.equal(evaluateCondition(content, commentEvent(body, { login: "t8y2" })), false);
assert.equal(evaluateCondition(content, commentEvent(body, { pullRequest: true })), true);
}
for (const body of ["Thanks!", "https://example.com/docs", "", null]) {
assert.equal(evaluateCondition(content, commentEvent(body)), false);
}
for (const body of ["Please upload a .zip", "https://github.com/t8y2/dbx/archive/main.zip",
"https://user-images.githubusercontent.com/files/logs.zip", "[logs.exe](https://github.com/user-attachments/files/123/logs.exe)",
"[logs.7z](//raw.githubusercontent.com/t8y2/dbx/main/logs.7z)"]) {
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
assert.equal(matcher.test(body), false, body);
}
});
test("PR notification automation stays disabled while its configuration is retained", () => {
const content = workflow("notify");
assert.match(content, /# pull_request_target:\s+#\s+types: \[closed\]/);
assert.match(content, /^ workflow_dispatch:/m);
assert.doesNotMatch(content, /^ (?:pull_request_target|pull_request|issues|release|push|schedule):/m);
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: { merged: true } } }), true);
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: { merged: false } } }), false);
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: {} } }), false);
});
test("plugin checks run once before unchanged cross-platform verification", () => {
const content = workflow("plugin-dev-host");
const checks = job(content, "checks");
const tests = job(content, "test");
assert.match(checks, /runs-on: ubuntu-24\.04/);
assert.match(checks, /npm run lint --prefix plugins\/sdk\/dev-host/);
assert.match(checks, /npm run fmt:check --prefix plugins\/sdk\/dev-host/);
assert.doesNotMatch(checks, /cargo|setup-go|rust-toolchain/);
assert.match(tests, /needs: checks/);
assert.match(tests, /os: \[ubuntu-24\.04, macos-15, windows-2022\]/);
assert.doesNotMatch(tests, /npm run (?:lint|fmt:check)/);
for (const command of ["npm test --prefix plugins/sdk/dev-host", "cargo nextest run", "cargo test --doc",
"npm test --prefix packages/plugin-cli", "node scripts/verify-plugin-cli-package.mjs", "DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'"]) {
assert.ok(tests.includes(command), command);
}
assert.match(content, /group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}\s+cancel-in-progress: true/);
});
test("contributor refresh is weekly and remains manually available", () => {
const content = workflow("contributors");
assert.match(content, /cron: "0 18 \* \* 0"/);
assert.match(content, /workflow_dispatch:/);
});
test("GitHub script selection excludes planner tests only when fast checks own them", () => {
const content = job(workflow("ci"), "github-scripts");
const script = content.match(/run: \|\n((?: [^\n]*\n)+)/)[1].replace(/^ /gm, "");
const repository = new URL("../../", import.meta.url);
const files = readdirSync(new URL(".github/scripts/", repository)).filter((file) => file.endsWith(".test.mjs"));
for (const fast of ["true", "false", ""]) {
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", `node() { printf '%s\\n' "$@"; }\n${script}`], {
cwd: repository, encoding: "utf8", env: { ...process.env, FAST_CHECKS: fast },
});
assert.equal(result.status, 0, result.stderr);
const selected = result.stdout.trim().split("\n").slice(1).sort();
const expected = files.filter((file) => fast !== "true" || !file.startsWith("ci-")).map((file) => `.github/scripts/${file}`).sort();
assert.deepEqual(selected, expected);
assert.ok(selected.includes(".github/scripts/actions-efficiency.test.mjs"));
}
});
async function snapshotFixture(context) {
const directory = await mkdtemp(join(tmpdir(), "dbx-contributor-snapshot-"));
context.after(() => rm(directory, { recursive: true, force: true }));
return join(directory, "data", "contributors.json");
}
function snapshot() {
return { repository: "t8y2/dbx", generatedAt: "2026-10-01T18:00:00.000Z", stars: 10,
contributors: [{ login: "contributor", commits: 3, mergedPullRequests: 1 }] };
}
test("a missing contributor snapshot is created and timestamp-only refreshes preserve exact bytes", async (context) => {
const outputPath = await snapshotFixture(context);
const original = snapshot();
assert.equal(await writeContributorSnapshot(outputPath, original), true);
const originalBytes = await readFile(outputPath, "utf8");
assert.equal(originalBytes, `${JSON.stringify(original, null, 2)}\n`);
const refreshed = { ...original, generatedAt: "2026-10-08T18:00:00.000Z" };
assert.equal(await writeContributorSnapshot(outputPath, refreshed), false);
assert.equal(await readFile(outputPath, "utf8"), originalBytes);
assert.equal(refreshed.generatedAt, "2026-10-08T18:00:00.000Z");
});
test("actual contributor, star and repository changes update the snapshot", async (context) => {
const outputPath = await snapshotFixture(context);
const original = snapshot();
for (const updated of [{ ...original, stars: 11 }, { ...original, repository: "other/dbx" },
{ ...original, contributors: [{ ...original.contributors[0], commits: 4 }] },
{ ...original, contributors: [] }]) {
await writeContributorSnapshot(outputPath, original);
const refreshed = { ...updated, generatedAt: "2026-10-08T18:00:00.000Z" };
assert.equal(await writeContributorSnapshot(outputPath, refreshed), true);
assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), refreshed);
}
});
test("invalid contributor snapshots and filesystem errors are not silently overwritten", async (context) => {
const outputPath = await snapshotFixture(context);
await writeContributorSnapshot(outputPath, snapshot());
await writeFile(outputPath, "invalid JSON");
await assert.rejects(writeContributorSnapshot(outputPath, snapshot()), SyntaxError);
assert.equal(await readFile(outputPath, "utf8"), "invalid JSON");
await assert.rejects(writeContributorSnapshot(join(outputPath, "invalid"), snapshot()), { code: "ENOTDIR" });
});
+21
View File
@@ -0,0 +1,21 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
export function changeBase({ eventName, before, root = process.cwd() }) {
if (eventName === "pull_request") {
const commits = execFileSync("git", ["rev-list", "--parents", "-n", "1", "HEAD"], {
cwd: root, encoding: "utf8", stdio: "pipe",
}).trim().split(/\s+/);
if (commits.length !== 3) throw new Error("PR change detection requires the checked-out merge commit and both parents");
return commits[1];
}
if (!before || /^0+$/.test(before)) return null;
if (!/^[0-9a-f]{40}$/.test(before)) throw new Error("Invalid push base SHA");
return before;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const base = changeBase({ eventName: process.env.GITHUB_EVENT_NAME, before: process.env.BEFORE_SHA });
if (base) console.log(base);
}
+117
View File
@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import test from "node:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { changeBase } from "./ci-change-base.mjs";
import { changedPaths, planCi } from "./ci-plan.mjs";
function mergeFixture(context) {
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-change-base-"));
context.after(() => rmSync(directory, { recursive: true, force: true }));
const root = path.join(directory, "source");
mkdirSync(root);
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }).trim();
const write = (file, content) => {
const filename = path.join(root, file);
mkdirSync(path.dirname(filename), { recursive: true });
writeFileSync(filename, content);
};
const commit = (message) => {
git("add", ".");
git("-c", "core.hooksPath=/dev/null", "commit", "-m", message);
return git("rev-parse", "HEAD");
};
git("init", "--initial-branch=main");
git("config", "user.email", "fixture@example.invalid");
git("config", "user.name", "CI Fixture");
write("crates/dbx-core/tests/old.rs", "original source\n");
write("crates/dbx-core/tests/deleted.rs", "removed source\n");
write("crates/dbx-web/src/routes/transfer.rs", "original route\n");
const staleBase = commit("baseline");
git("checkout", "-b", "pull-request");
git("mv", "crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/renamed\nfile.rs");
git("rm", "crates/dbx-core/tests/deleted.rs");
write("crates/dbx-core/tests/runtime_diagnostic_history.rs", "diagnostic test\n");
write("crates/dbx-web/src/routes/transfer.rs", "updated route\n");
const pullRequestHead = commit("PR diagnostic changes");
git("checkout", "main");
write(".github/workflows/ci.yml", "main CI changes\n");
write("agents/drivers/zookeeper/main.go", "main native Agent changes\n");
write("agents/drivers/oceanbase-oracle/src/main/java/Agent.java", "main JDBC changes\n");
const actualBase = commit("main advances while PR remains open");
git("-c", "core.hooksPath=/dev/null", "merge", "--no-ff", "pull-request", "-m", "synthetic PR test merge");
return { directory, root, staleBase, actualBase, pullRequestHead };
}
test("PR routing uses the exact test merge first parent, excluding unrelated main changes", (context) => {
const fixture = mergeFixture(context);
const base = changeBase({ eventName: "pull_request", before: fixture.staleBase, root: fixture.root });
assert.equal(base, fixture.actualBase);
assert.notEqual(base, fixture.pullRequestHead);
const files = changedPaths(base, fixture.root);
assert.deepEqual(files.toSorted(), [
"crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/deleted.rs", "crates/dbx-core/tests/renamed\nfile.rs",
"crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs",
].toSorted());
const metadata = {
workspace_members: ["dbx-core", "dbx-web", "dbx"],
packages: [
{ id: "dbx-core", name: "dbx-core", manifest_path: path.join(fixture.root, "crates/dbx-core/Cargo.toml"), dependencies: [] },
{ id: "dbx-web", name: "dbx-web", manifest_path: path.join(fixture.root, "crates/dbx-web/Cargo.toml"), dependencies: [{ name: "dbx-core" }] },
{ id: "dbx", name: "dbx", manifest_path: path.join(fixture.root, "src-tauri/Cargo.toml"), dependencies: [{ name: "dbx-core" }] },
],
};
const corrected = planCi({ files, metadata, root: fixture.root });
assert.equal(corrected.rust_full, false);
assert.deepEqual(corrected.rust_matrix.include.map((entry) => entry.group), ["application"]);
assert.deepEqual(corrected.agent_rust.include, [{ driver: "duckdb" }]);
assert.equal(corrected.agent_java, false);
assert.deepEqual(corrected.agent_go.include, []);
assert.deepEqual(corrected.agent_integration.include, []);
assert.equal(corrected.windows_standard, true);
assert.equal(corrected.windows_win7_bundle, false);
const staleFiles = changedPaths(fixture.staleBase, fixture.root);
assert.ok(staleFiles.includes(".github/workflows/ci.yml"));
assert.ok(staleFiles.includes("agents/drivers/zookeeper/main.go"));
const polluted = planCi({ files: staleFiles, metadata, root: fixture.root });
assert.equal(polluted.rust_full, true);
assert.equal(polluted.agent_java, true);
assert.ok(polluted.agent_go.include.length > 0);
assert.ok(polluted.agent_integration.include.length > 0);
});
test("a depth-two checkout preserves the PR parents and supports the base CLI", (context) => {
const fixture = mergeFixture(context);
const checkout = path.join(fixture.directory, "depth-two");
execFileSync("git", ["clone", "--depth=2", pathToFileURL(fixture.root).href, checkout], { stdio: "pipe" });
assert.equal(changeBase({ eventName: "pull_request", root: checkout }), fixture.actualBase);
assert.deepEqual(changedPaths(fixture.actualBase, checkout), changedPaths(fixture.actualBase, fixture.root));
const output = execFileSync(process.execPath, [path.join(import.meta.dirname, "ci-change-base.mjs")], {
cwd: checkout, encoding: "utf8", env: { ...process.env, GITHUB_EVENT_NAME: "pull_request", BEFORE_SHA: fixture.staleBase },
});
assert.equal(output.trim(), fixture.actualBase);
});
test("PR base detection rejects a nonmerge commit and a depth-one checkout", (context) => {
const fixture = mergeFixture(context);
const checkout = path.join(fixture.directory, "depth-one");
execFileSync("git", ["clone", "--depth=1", pathToFileURL(fixture.root).href, checkout], { stdio: "pipe" });
assert.throws(() => changeBase({ eventName: "pull_request", root: checkout }), /merge commit and both parents/);
execFileSync("git", ["checkout", "--detach", fixture.pullRequestHead], { cwd: fixture.root, stdio: "pipe" });
assert.throws(() => changeBase({ eventName: "pull_request", root: fixture.root }), /merge commit and both parents/);
});
test("push routing preserves the event before SHA even when HEAD is a merge", (context) => {
const fixture = mergeFixture(context);
assert.equal(changeBase({ eventName: "push", before: fixture.staleBase, root: fixture.root }), fixture.staleBase);
assert.equal(changeBase({ eventName: "push", before: fixture.pullRequestHead, root: fixture.root }), fixture.pullRequestHead);
for (const before of [undefined, "", "0".repeat(40)]) {
assert.equal(changeBase({ eventName: "push", before }), null);
}
for (const before of ["not-a-ref", "HEAD", "a".repeat(39), "a".repeat(41)]) {
assert.throws(() => changeBase({ eventName: "push", before }), /Invalid push base SHA/);
}
});
+1 -1
View File
@@ -8,7 +8,7 @@ export const rustGroups = {
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers",
"dbx-sqlite-worker",
],
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp"],
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
};
export const goAgents = [
+1 -1
View File
@@ -9,7 +9,7 @@ export function gateFailures(needs, mode) {
const flags = ["rust", "agents", "fast", "agent_java", "agent_go_changed", "agent_rust_changed", "agent_integration_changed"];
if (!flags.every((flag) => typeof plan[flag] === "boolean")) return ["incomplete CI plan"];
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
"windows-standard-check": "windows_win7_bundle", "windows-win7-bundle": "windows_win7_bundle",
"windows-standard-check": "windows_standard", "windows-win7-bundle": "windows_win7_bundle",
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
if (mode === "all" && !Object.values(routedJobs).every((output) => ["true", "false"].includes(needs.changes.outputs[output]))) {
return ["missing or invalid job selection outputs"];
+70 -11
View File
@@ -2,19 +2,48 @@
Paths in this guide are relative to `.github/`.
`workflows/ci.yml` retains one workflow and one cancellation group. Splitting jobs
does not change release workflows or branch-protection settings. The existing
`workflows/ci.yml` retains one workflow. Splitting jobs does not change release
workflows or branch-protection settings. The existing
`rust` and `agents` aggregate check names remain; `ci` additionally summarizes all
selected jobs. None of these gates accepts a failed, cancelled, missing, or
unexpectedly skipped prerequisite. The gates are guarded by
`if: always() && !cancelled()`: on a superseded commit a newer push cancels the
run, and `always()` alone would still execute the gate and report those
cancellations as a failure of the commit that no longer matters.
`if: always() && !cancelled()`: on a cancelled run, `always()` alone would still
execute the gate and report cancelled prerequisites as a failure.
## Contributor concurrency
PRs from anyone other than the repository owner share main `CI` concurrency
groups keyed by the PR author's stable numeric user ID and the parity of the PR
number (`pr-author-<id>-even` / `pr-author-<id>-odd`). At most two runs per author
execute at a time — one per parity group — and other authors have independent
groups. Actions expressions have no modulo, so parity is read from the PR number's
last digit with `endsWith`. Two PRs of the same parity still queue behind each other.
The PR author, not the trigger actor or author association, determines this
policy, so a maintainer retrigger cannot bypass contributor limits.
`cancel-in-progress: false` preserves the executing PR, and `queue: max` retains
up to 100 pending runs rather than replacing earlier pending PRs. Further runs
beyond that limit are cancelled by GitHub. Updates to the same contributor PR
also enqueue separate runs; this policy does not deduplicate superseded commits.
The repository owner (`t8y2` here, compared with `github.repository_owner`) is
exempt from author-level limits. Owner PRs retain one cancellation group per PR,
so separate owner PRs run independently and a new commit cancels that PR's old
run. Main pushes retain their independent ref-based cancellation group. These
paths use `queue: single`, never `queue: max` with in-progress cancellation.
This limits main CI workflow runs, not the runner count inside one selected PR.
Other PR workflows and all release workflows remain unchanged. Existing runs
retain their original workflow configuration until a new event uses this policy.
## Selection
`scripts/ci-plan.mjs` reads Cargo workspace metadata and the exact event base/head
diff. Renames include both paths and deleted paths remain visible. Rust package
`scripts/ci-change-base.mjs` uses the first parent of the checked-out PR test merge
as its base, not the potentially stale base SHA in the PR event. A depth-two
checkout preserves both parents; a missing merge parent fails change detection.
Pushes use the exact event `before` SHA. `paths-filter` uses Git, with an empty API
token and explicit base/head, so its selection matches `scripts/ci-plan.mjs`
rather than a mutable PR file list. An unavailable push diff keeps conservative
planner coverage. Renames include both paths and deleted paths remain visible. Rust package
selection follows reverse dependencies, including build and test dependencies.
Connection-type descriptors belong to `dbx-types`; dialect descriptors belong to
`dbx-sql`. A changed core dependency also selects the standalone DuckDB Agent tests,
@@ -22,7 +51,10 @@ whose development dependency is `dbx-core`.
- Ordinary Rust PRs select foundation, driver, and application test groups from
`scripts/ci-config.mjs`. Each group contains several packages, not one runner per
crate. At most three Rust test groups run concurrently.
crate. The application group includes `dbx-tauri-consul` and `dbx-tauri-schema`.
At most three Rust test groups run concurrently; a contract checks the groups
against actual workspace metadata so extracted crates cannot silently force
every PR into the full-workspace fallback.
- Main pushes, shared Cargo manifests/locks, toolchain/config/vendor changes, CI
infrastructure changes, an unknown workspace member, or an unavailable diff
select the original full-workspace test lane instead of duplicating it with all
@@ -34,8 +66,33 @@ whose development dependency is `dbx-core`.
cases. Shared Agent inputs conservatively select all existing Agent checks.
Windows DuckDB packaging is selected by its own inputs or shared build/lock
changes, not every change to its development dependency's source.
- Existing frontend, package, Windows, JDBC, offline-payload and Nix jobs retain
their commands and path filters.
- Standard Windows compilation follows desktop reverse dependencies, including
ordinary core/driver source changes. Full Win7 packaging runs for Cargo
manifests/locks, toolchain/config/vendor changes, platform/update/installer
code, Tauri configuration, Win7 fixtures/scripts and release/CI workflow inputs.
Unknown Rust paths, unassigned Rust workspace members and unavailable diffs
retain both Windows jobs. Other desktop source and test changes keep standard
Windows compilation without repeating the full Win7 installer build. The final
gate validates each Windows selection independently; release builds are unchanged.
- Existing frontend, package, JDBC, offline-payload and Nix jobs retain their
commands and path filters, except for the snapshot-only routing below.
Contributor snapshots refresh weekly, at 02:00 Monday in Asia/Shanghai, and can
still be refreshed manually. An unchanged snapshot keeps its existing timestamp
and creates no commit. A diff containing only `docs/data/contributors.json` skips
desktop frontend validation, but the docs deployment still rebuilds the site.
Mixed diffs and unavailable diffs keep the existing validation routing.
When `fast-checks` is selected, it owns the CI planner and gate tests before
expensive dependent jobs start. `github-scripts` omits that subset in these runs,
but runs every GitHub script test when the fast-check lane is not selected.
Issue command and spam jobs reject irrelevant comments before allocating a
runner. Their existing command parser and malicious-link matcher remain the
authoritative checks. PR notification triggers are commented out; release
notifications in the publishing workflow are unchanged. Plugin development
host lint and formatting run once on Linux before the Linux, macOS and Windows
runtime, Rust and package checks; cross-platform coverage is unchanged.
Changes to the planner itself select the broad checks. Missing/invalid planning
data fails the aggregate gates rather than producing a successful skip.
@@ -70,7 +127,9 @@ incompatible SQLite backends.
| `agent-integration` | Sixteen existing engine/version/authentication cases | 4 |
Matrix jobs set `fail-fast: false`, so one failure does not conceal results from
the other selected cases. Live test predicates, readiness checks, time limits,
the other selected cases. Go and live matrix parallelism is bounded at four each
to reduce bursts competing with other workflows; this is not a reservation or a
priority guarantee for release jobs. Live test predicates, readiness checks, time limits,
credentials for disposable fixtures, and failure cleanup are retained.
Native, Java and live jobs wait for fast checks and Agent source validation before
starting expensive builds or containers.
+14 -15
View File
@@ -17,12 +17,14 @@ const win7InfrastructureFiles = new Set([
"src-tauri/build.rs",
]);
const win7InfrastructurePrefixes = [
".cargo/",
"rust-toolchain",
".github/fixtures/win7-",
"src-tauri/windows/nsis/",
"vendor/wry/",
"vendor/webview2-com-sys/",
"vendor/ctor/",
"vendor/dirs-sys/",
"vendor/pageant/",
"src-tauri/src/commands/update",
"crates/dbx-core/src/host/update",
"crates/dbx-platform/",
"vendor/",
];
function expandAffectedPackages(packages, members, affected) {
@@ -93,7 +95,8 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
if (full) for (const pkg of packages) affected.add(pkg.name);
expandAffectedPackages(packages, members, affected);
const win7InfrastructureChanged = unknownDiff || files.some((file) => win7InfrastructureFiles.has(file)
|| win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix)));
|| win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix))
|| /^src-tauri\/tauri.*\.json$/.test(file));
const win7DependencyChanged = files.includes("Cargo.toml") || files.includes("Cargo.lock")
|| files.some((file) => /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
const windowsWin7Reasons = {
@@ -101,6 +104,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
dependency_input: win7DependencyChanged,
desktop_dependency: win7Affected.has("dbx"),
unknown_rust: unknownRust,
unknown_member: rust && unknownMember,
};
const windowsWin7Candidate = Object.values(windowsWin7Reasons).some(Boolean);
const rustMatrix = !rust ? [] : full ? [{ group: "workspace" }] : Object.entries(rustGroups)
@@ -128,6 +132,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
const java = allAgents || javaDriverChanges;
const agents = agentsChanged || sharedAgents || nativeChanges.size > 0 || javaDriverChanges;
return {
contributor_snapshot_only: files.length > 0 && files.every((file) => file === "docs/data/contributors.json"),
rust, rust_full: full, rust_matrix: { include: rustMatrix }, rust_groups_known: !unknownMember,
affected_packages: [...affected].sort(),
agents, agent_java: java, agent_go: { include: goMatrix }, agent_rust: { include: nativeRustMatrix },
@@ -135,6 +140,8 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
agent_rust_changed: nativeRustMatrix.length > 0, agent_integration_changed: liveMatrix.length > 0,
duckdb_changed: nativeChanges.has("duckdb"),
duckdb_windows: sharedRust || files.some((file) => file.startsWith("agents/drivers/duckdb/")),
windows_standard: windowsWin7Candidate,
windows_win7_bundle: win7InfrastructureChanged || win7DependencyChanged || unknownRust || (rust && unknownMember),
windows_win7_candidate: windowsWin7Candidate,
windows_win7_affected_packages: [...win7Affected].sort(),
windows_win7_reasons: windowsWin7Reasons,
@@ -153,13 +160,5 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
const outputs = { ...plan, plan };
appendFileSync(process.env.GITHUB_OUTPUT, Object.entries(outputs).map(([name, value]) => `${name}=${JSON.stringify(value)}\n`).join(""));
}
console.log(JSON.stringify({
...plan,
win7_routing_comparison: {
current: process.env.WIN7_CURRENT === "true",
candidate: plan.windows_win7_candidate,
affected_packages: plan.windows_win7_affected_packages,
reasons: plan.windows_win7_reasons,
},
}, null, 2));
console.log(JSON.stringify(plan, null, 2));
}
+96 -7
View File
@@ -29,7 +29,8 @@ const graph = {
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support",
"dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"],
"dbx-core": ["dbx-drivers", "dbx-sql", "dbx-types", "dbx-platform", "dbx-ai-provider", "dbx-plugin-runtime", "dbx-formats"],
"dbx-mcp": ["dbx-core"], "dbx-cli": ["dbx-core", "dbx-mcp"], "dbx-web": ["dbx-core", "dbx-mcp"], "dbx": ["dbx-core", "dbx-mcp"],
"dbx-mcp": ["dbx-core"], "dbx-cli": ["dbx-core", "dbx-mcp"], "dbx-web": ["dbx-core", "dbx-mcp"], "dbx": ["dbx-core", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
"dbx-tauri-consul": ["dbx-core"], "dbx-tauri-schema": ["dbx-core"],
};
const metadata = {
workspace_members: Object.keys(graph),
@@ -52,12 +53,22 @@ test("the planner uses the runner stable Cargo only for metadata", () => {
assert.deepEqual(invocation.args, ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"]);
assert.equal(invocation.options.cwd, "/workspace");
});
// Keep the gate fixtures in sync with ci-gate.mjs routedJobs. Both Windows jobs
// share the windows_win7_bundle routing output.
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
"windows-standard-check": "windows_win7_bundle", "windows-win7-bundle": "windows_win7_bundle",
"windows-standard-check": "windows_standard", "windows-win7-bundle": "windows_win7_bundle",
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
test("only an exact contributor snapshot diff skips desktop frontend validation", () => {
for (const eventName of ["pull_request", "push"]) {
assert.equal(plan(["docs/data/contributors.json"], { eventName }).contributor_snapshot_only, true);
for (const files of [null, [], ["docs/lib/contributorActivity.test.ts"],
["docs/data/contributors.json", "apps/desktop/src/main.ts"],
["docs/data/contributors.json", "docs/lib/contributorActivity.ts"],
["docs/data/contributors.json", "Cargo.lock"]]) {
assert.equal(plan(files, { eventName }).contributor_snapshot_only, false);
}
}
});
test("foundation changes select transitive consumers and standalone DuckDB", () => {
const result = plan(["crates/dbx-types/src/lib.rs"]);
assert.deepEqual(groups(result), ["foundation", "drivers", "application"]);
@@ -85,6 +96,21 @@ test("Win7 candidate follows the desktop package dependency graph", () => {
assert.equal(plan(["crates/dbx-cli/src/main.rs"]).windows_win7_candidate, false);
});
test("ordinary desktop Rust changes keep Windows compilation without full Win7 packaging", () => {
for (const file of ["crates/dbx-core/src/query/mod.rs", "crates/dbx-core/tests/runtime_diagnostic_history.rs",
"crates/dbx-drivers/src/lib.rs", "crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-driver-mysql/tests/query.rs",
"src-tauri/src/commands/query.rs", "crates/dbx-tauri-consul/src/lib.rs", "crates/dbx-tauri-schema/src/lib.rs"]) {
const result = plan([file]);
assert.equal(result.windows_standard, true, file);
assert.equal(result.windows_win7_bundle, false, file);
}
for (const file of ["crates/dbx-cli/src/main.rs", "crates/dbx-web/src/routes/transfer.rs", "docs/README.md"]) {
const result = plan([file]);
assert.equal(result.windows_standard, false, file);
assert.equal(result.windows_win7_bundle, false, file);
}
});
test("old Win7 Rust paths remain candidates through the dependency graph", () => {
for (const file of [
"src-tauri/src/commands/update.rs",
@@ -114,20 +140,45 @@ test("Win7 infrastructure changes remain candidates outside the Cargo graph", ()
"vendor/ctor/src/lib.rs",
"vendor/dirs-sys/src/lib.rs",
"vendor/pageant/src/lib.rs",
".cargo/config.toml",
"rust-toolchain.toml",
".github/fixtures/win7-imports/dll.json",
"src-tauri/tauri.conf.json",
"src-tauri/tauri.windows.conf.json",
"src-tauri/src/commands/update.rs",
"crates/dbx-core/src/host/update.rs",
"crates/dbx-platform/src/lib.rs",
]) {
assert.equal(plan([file]).windows_win7_candidate, true, file);
const result = plan([file]);
assert.equal(result.windows_win7_candidate, true, file);
assert.equal(result.windows_standard, true, file);
assert.equal(result.windows_win7_bundle, true, file);
}
});
test("Win7 dependency inputs fail open", () => {
for (const file of ["Cargo.toml", "Cargo.lock", "src-tauri/Cargo.toml", "crates/dbx-cli/Cargo.toml"]) {
assert.equal(plan([file]).windows_win7_candidate, true, file);
assert.equal(plan([file]).windows_standard, true, file);
assert.equal(plan([file]).windows_win7_bundle, true, file);
}
});
test("unknown Rust changes and unknown diffs remain Win7 candidates", () => {
assert.equal(plan(["crates/new-engine/src/lib.rs"]).windows_win7_candidate, true);
assert.equal(plan(null).windows_win7_candidate, true);
for (const files of [["crates/new-engine/src/lib.rs"], null]) {
const result = plan(files);
assert.equal(result.windows_win7_candidate, true);
assert.equal(result.windows_standard, true);
assert.equal(result.windows_win7_bundle, true);
}
const fixture = structuredClone(metadata);
fixture.workspace_members.push("new-engine");
fixture.packages.push({ id: "new-engine", name: "new-engine", manifest_path: `${root}/crates/new-engine/Cargo.toml`, dependencies: [] });
const result = plan(["crates/dbx-web/src/lib.rs"], { metadata: fixture });
assert.equal(result.windows_standard, true);
assert.equal(result.windows_win7_bundle, true);
assert.equal(result.windows_win7_reasons.unknown_member, true);
assert.equal(plan(["docs/README.md"], { metadata: fixture }).windows_win7_bundle, false);
});
test("unrelated CI inputs do not become Win7 candidates through full Rust coverage", () => {
@@ -152,6 +203,7 @@ test("Win7 candidate reports each routing reason", () => {
dependency_input: false,
desktop_dependency: false,
unknown_rust: false,
unknown_member: false,
});
});
@@ -252,6 +304,22 @@ test("test groups partition every current workspace package exactly once", () =>
assert.deepEqual(selected.toSorted(), Object.keys(graph).toSorted());
});
test("Rust groups cover the actual workspace metadata, including extracted Tauri crates", () => {
const repository = path.resolve(import.meta.dirname, "../..");
const actual = cargoMetadata(repository);
const members = actual.packages.filter((pkg) => actual.workspace_members.includes(pkg.id)).map((pkg) => pkg.name);
assert.deepEqual(Object.values(rustGroups).flat().toSorted(), members.toSorted());
const result = planCi({ files: ["crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"],
metadata: actual, root: repository });
assert.equal(result.rust_groups_known, true);
assert.equal(result.rust_full, false);
assert.deepEqual(groups(result), ["application"]);
assert.deepEqual(result.agent_rust.include, [{ driver: "duckdb" }]);
assert.equal(result.agent_java, false);
assert.equal(result.agent_go.include.length, 0);
assert.equal(result.agent_integration.include.length, 0);
});
test("workspace Cargo flags preserve full versus fast coverage and strict clippy", () => {
for (const mode of ["fast", "full"]) {
const command = rustCommand("test", "workspace", mode);
@@ -382,6 +450,7 @@ test("the final gate requires both Windows jobs when the bundle routing is selec
needs.changes.outputs[output] = "false";
}
needs.changes.outputs.windows_win7_bundle = "true";
needs.changes.outputs.windows_standard = "true";
needs["windows-standard-check"].result = "success";
needs["windows-win7-bundle"].result = "success";
assert.deepEqual(gateFailures(needs, "all"), []);
@@ -393,6 +462,26 @@ test("the final gate requires both Windows jobs when the bundle routing is selec
assert.ok(gateFailures(needs, "all").length);
});
test("the final gate independently requires standard Windows compilation without Win7 packaging", () => {
const needs = results(["crates/dbx-core/src/query/mod.rs"]);
const selected = plan(["crates/dbx-core/src/query/mod.rs"]);
needs.rust = needs.agents = { result: "success" };
for (const [job, output] of Object.entries(routedJobs)) {
needs.changes.outputs[output] = String(selected[output] ?? false);
needs[job] = { result: selected[output] ? "success" : "skipped" };
}
assert.equal(needs.changes.outputs.windows_standard, "true");
assert.equal(needs.changes.outputs.windows_win7_bundle, "false");
assert.deepEqual(gateFailures(needs, "all"), []);
for (const result of ["failure", "skipped", "cancelled"]) {
needs["windows-standard-check"].result = result;
assert.ok(gateFailures(needs, "all").some((failure) => failure.includes("windows-standard-check")));
}
needs["windows-standard-check"].result = "success";
delete needs.changes.outputs.windows_standard;
assert.ok(gateFailures(needs, "all").length);
});
test("coverage audit rejects lost features, omitted packages, and overlapping group selection", () => {
const coverage = { workspace: Object.fromEntries(Object.keys(graph).map((name) => [name, ["fixture"]])),
...Object.fromEntries(Object.entries(rustGroups).map(([group, packages]) => [group, Object.fromEntries(packages.map((name) => [name, ["fixture"]]))])) };
+90 -11
View File
@@ -15,6 +15,65 @@ function job(name, content = workflow) {
return jobs.slice(definitions[index].index, definitions[index + 1]?.index ?? jobs.length);
}
function concurrencySettings(github) {
const block = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)?.[1];
assert.ok(block, "missing workflow concurrency");
const format = (template, ...values) => template.replace(/\{(\d+)\}/g, (_, index) => String(values[Number(index)]));
const endsWith = (search, value) => String(search).toLowerCase().endsWith(String(value).toLowerCase());
return Object.fromEntries(block.trim().split("\n").map((line) => {
const [, setting, value] = line.match(/^\s*([\w-]+):\s*(.+)$/);
const evaluate = (expression) =>
new Function("github", "format", "endsWith", `return (${expression});`)(github, format, endsWith);
const expressions = [...value.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)];
return [setting, expressions.length === 1 && expressions[0][0] === value
? evaluate(expressions[0][1]) : value.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_, expression) => evaluate(expression))];
}));
}
function concurrencyContext({ authorId = 101, author = "contributor", actor = author, number = 100, ref = `refs/pull/${number}/merge`, eventName = "pull_request" } = {}) {
return { workflow: "CI", repository_owner: "t8y2", actor, ref, event_name: eventName,
event: eventName === "pull_request" ? { pull_request: { number, user: { id: authorId, login: author } } } : {} };
}
test("a contributor's PRs share two main CI queues split by PR number parity, regardless of trigger actor", () => {
const even = concurrencySettings(concurrencyContext());
assert.deepEqual(even, { group: "CI-pr-author-101-even", "cancel-in-progress": false, queue: "max" });
for (const context of [concurrencyContext({ number: 202 }), concurrencyContext({ actor: "t8y2" }),
concurrencyContext({ actor: "github-actions[bot]" }), concurrencyContext({ author: "renamed-contributor" })]) {
assert.deepEqual(concurrencySettings(context), even);
}
for (const number of [101, 11849, 11853, 7]) {
assert.deepEqual(concurrencySettings(concurrencyContext({ number })), { ...even, group: "CI-pr-author-101-odd" }, String(number));
}
for (const number of [2, 4, 6, 8, 11850]) assert.equal(concurrencySettings(concurrencyContext({ number })).group, even.group);
assert.notEqual(concurrencySettings(concurrencyContext({ authorId: 102, author: "another-contributor" })).group, even.group);
});
test("repository-owner PRs remain independent and superseded runs are cancelled only within a PR", () => {
const first = concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2" }));
const second = concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2", ref: "refs/pull/200/merge" }));
assert.deepEqual(first, { group: "CI-refs/pull/100/merge", "cancel-in-progress": true, queue: "single" });
assert.notEqual(first.group, second.group);
assert.deepEqual(concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2", actor: "another-contributor" })), first);
});
test("main pushes retain their independent cancellation group without PR context", () => {
const main = concurrencySettings(concurrencyContext({ eventName: "push", ref: "refs/heads/main", actor: "github-actions[bot]" }));
assert.deepEqual(main, { group: "CI-refs/heads/main", "cancel-in-progress": true, queue: "single" });
assert.notEqual(main.group, concurrencySettings(concurrencyContext()).group);
});
test("expanded author queues never combine max queuing with in-progress cancellation", () => {
for (const context of [concurrencyContext(), concurrencyContext({ author: "t8y2" }),
concurrencyContext({ eventName: "push", ref: "refs/heads/main" })]) {
const settings = concurrencySettings(context);
assert.ok(settings.queue === "single" || settings.queue === "max");
assert.equal(settings.queue === "max" && settings["cancel-in-progress"], false);
}
const concurrency = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)[1];
assert.doesNotMatch(concurrency, /github\.actor|author_association/);
});
test("fast checks run format and contracts before graph resolution or compilation", () => {
const fast = job("fast-checks");
for (const command of ["cargo fmt --check", "node --test scripts/core-architecture.test.mjs", "node scripts/sync-connection-types.mjs --check", "node .github/scripts/ci-lockfiles.mjs", "node .github/scripts/ci-rust-coverage.mjs"]) assert.ok(fast.includes(command));
@@ -23,12 +82,31 @@ test("fast checks run format and contracts before graph resolution or compilatio
assert.ok(fast.includes("needs.changes.outputs.rust_groups_known == 'true'"));
});
test("fast checks retain ownership of the planner subset before expensive jobs", () => {
assert.match(job("github-scripts"), /FAST_CHECKS: \$\{\{ needs\.changes\.outputs\.fast \}\}/);
assert.match(job("github-scripts"), /for file in \.github\/scripts\/\*\.test\.mjs/);
assert.match(job("fast-checks"), /name: CI planner and gate tests\s+run: node --test \.github\/scripts\/ci-\*\.test\.mjs/);
});
test("contributor snapshot routing preserves docs deployment and mixed frontend validation", () => {
assert.ok(job("changes").includes("frontend: ${{ steps.filter.outputs.frontend == 'true' && steps.plan.outputs.contributor_snapshot_only != 'true' }}"));
assert.match(job("changes"), /frontend:\s+- 'apps\/desktop\/\*\*'\s+- 'docs\/\*\*'/);
const docsWorkflow = readFileSync(new URL("../workflows/docs.yml", import.meta.url), "utf8");
assert.match(docsWorkflow, /paths: \["docs\/\*\*"\]/);
for (const file of ["contributors", "issue-claim", "notify", "plugin-dev-host", "spam-comment-guard"]) {
assert.ok(job("changes").includes(`- '.github/workflows/${file}.yml'`));
}
for (const file of ["contributor-snapshot", "update-contributors"]) {
assert.ok(job("changes").includes(`- 'docs/scripts/${file}.mjs'`));
}
});
test("Agent and Rust matrices are bounded and do not cancel sibling failures", () => {
for (const [name, output, parallel] of [
["rust-test", "rust_matrix", 3],
["agent-rust", "agent_rust", 2],
["agent-go", "agent_go", 8],
["agent-integration", "agent_integration", 8],
["agent-go", "agent_go", 4],
["agent-integration", "agent_integration", 4],
]) {
const content = job(name);
assert.match(content, /fail-fast: false/);
@@ -188,7 +266,7 @@ test("Rust test jobs install pinned nextest and retain separate doctests", () =>
const pluginRelease = readFileSync(new URL("../workflows/plugin-cli-release.yml", import.meta.url), "utf8");
const consumers = [job("packages"), job("rust-test"), job("agent-rust"), job("agent-integration"), job("test", pluginDevHost), job("prepare", pluginRelease)];
for (const content of consumers) {
assert.ok(content.includes("uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3"));
assert.ok(content.includes("uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b"));
assert.ok(content.includes("tool: cargo-nextest@0.9.137"));
assert.doesNotMatch(content, /cargo test (?!.*--doc)/);
const testCommand = content.indexOf("cargo nextest run") >= 0 ? "cargo nextest run" : content.includes("pnpm test:packages") ? "pnpm test:packages" : content.includes("ci-rust.mjs test") ? "ci-rust.mjs test" : "ci-agent-integration.sh";
@@ -224,9 +302,8 @@ test("DuckDB Windows builds persist Rust and C++ compiler results", () => {
});
test("Windows compatibility jobs cache Rust compilation without wrapping C or C++", () => {
assert.ok(job("changes").includes("'vendor/webview2-com-sys/**'"));
const standard = job("windows-standard-check");
assert.ok(standard.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
assert.ok(standard.includes("needs.changes.outputs.windows_standard == 'true'"));
assert.ok(standard.includes("RUSTC_WRAPPER: sccache"));
assert.ok(standard.includes('SCCACHE_GHA_ENABLED: "true"'));
assert.ok(standard.includes("SCCACHE_GHA_VERSION: windows-standard-v1"));
@@ -236,6 +313,7 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
assert.ok(standard.includes("sccache --show-stats"));
const win7 = job("windows-win7-bundle");
assert.ok(win7.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
assert.doesNotMatch(win7, /x86_64-pc-windows-msvc|Setup Rust for standard Windows/);
for (const setting of ["RUSTC_WRAPPER: sccache", 'SCCACHE_GHA_ENABLED: "true"', "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1", 'SCCACHE_IDLE_TIMEOUT: "0"', 'CARGO_PROFILE_RELEASE_LTO: "off"', 'CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"']) assert.ok(win7.includes(setting));
assert.ok(win7.includes("fc920bf0ec8de6ee65d409111f7ec508035751ba"));
@@ -276,20 +354,21 @@ test("Win7 TLS cache keys ignore the workspace lockfile", () => {
assert.doesNotMatch(win7, /key: win7-(?:aws-lc|openssl).*hashFiles\('Cargo\.lock'/);
});
test("the planner uses the exact event base and preserves a single workflow cancellation scope", () => {
test("path filtering and the planner use the exact test merge first parent", () => {
const changes = job("changes");
assert.ok(changes.includes("github.event.pull_request.base.sha || github.event.before"));
assert.ok(changes.includes("BEFORE_SHA: ${{ github.event.before }}"));
assert.ok(changes.includes('BASE_SHA="$(node .github/scripts/ci-change-base.mjs)"'));
assert.ok(changes.includes("fetch-depth: 2"));
assert.ok(changes.includes('git fetch --no-tags --depth=1 origin "$BASE_SHA"'));
assert.ok(changes.includes("base: ${{ steps.change-base.outputs.sha }}"));
assert.match(changes, /with:\s+token: ''\s+base: \$\{\{ steps\.change-base\.outputs\.sha \}\}\s+ref: \$\{\{ github\.sha \}\}/);
assert.ok(changes.includes("BASE_SHA: ${{ steps.change-base.outputs.sha }}"));
assert.ok(changes.includes("node .github/scripts/ci-plan.mjs"));
assert.doesNotMatch(changes, /fetch-depth:\s*0/);
assert.doesNotMatch(changes, /dtolnay\/rust-toolchain/);
for (const flag of ["rust", "rust_full", "rust_matrix", "agents", "agent_go", "agent_rust", "agent_integration",
"windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
"windows_standard", "windows_win7_bundle", "windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
assert.ok(changes.includes(`steps.plan.outputs.${flag}`));
}
assert.ok(changes.includes("WIN7_CURRENT: ${{ steps.filter.outputs.windows_win7_bundle }}"));
assert.ok(workflow.includes("group: ${{ github.workflow }}-${{ github.ref }}"));
assert.ok(workflow.includes("cancel-in-progress: true"));
assert.doesNotMatch(changes, /steps\.filter\.outputs\.windows_win7_bundle|github\.event\.pull_request\.base\.sha|WIN7_CURRENT/);
});
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Add Google's public Docker Hub cache without replacing daemon settings."""
import argparse
import json
import os
import shutil
import stat
import subprocess
import tempfile
from pathlib import Path
MIRROR = "https://mirror.gcr.io"
def configure(path, validate=None):
path = Path(path)
config = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
if not isinstance(config, dict):
raise ValueError("Docker daemon config must be a JSON object")
mirrors = config.get("registry-mirrors", [])
if not isinstance(mirrors, list) or any(not isinstance(item, str) for item in mirrors):
raise ValueError("registry-mirrors must be an array of strings")
if any(item.rstrip("/") == MIRROR for item in mirrors):
return
config["registry-mirrors"] = [*mirrors, MIRROR]
original_stat = path.stat() if path.exists() else None
candidate = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
prefix="daemon-mirror-", suffix=".json", delete=False) as output:
candidate = Path(output.name)
output.write(json.dumps(config, indent=2) + "\n")
candidate.chmod(stat.S_IMODE(original_stat.st_mode) if original_stat else 0o644)
if original_stat and hasattr(os, "chown"):
os.chown(candidate, original_stat.st_uid, original_stat.st_gid)
if validate:
validate(candidate)
if original_stat:
backup = path.with_name(path.name + ".before-dbx-mirror")
if not backup.exists():
descriptor = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
stat.S_IMODE(original_stat.st_mode))
with os.fdopen(descriptor, "wb") as output:
output.write(path.read_bytes())
shutil.copystat(path, backup)
if hasattr(os, "chown"):
os.chown(backup, original_stat.st_uid, original_stat.st_gid)
os.replace(candidate, path)
finally:
if candidate and candidate.exists():
candidate.unlink()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("config", type=Path)
parser.add_argument("--validate", action="store_true", help="Validate with dockerd before replacing config")
args = parser.parse_args()
def validate(candidate):
subprocess.run(["dockerd", "--validate", "--config-file", str(candidate)], check=True, timeout=15)
configure(args.config, validate if args.validate else None)
@@ -0,0 +1,115 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
spec = importlib.util.spec_from_file_location(
"docker_mirror", Path(__file__).with_name("configure-docker-mirror.py")
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class DockerMirrorTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.path = Path(self.directory.name) / "daemon.json"
def test_missing_config(self):
module.configure(self.path)
self.assertEqual(json.loads(self.path.read_text()), {"registry-mirrors": [module.MIRROR]})
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
def test_preserves_settings_and_existing_mirror_order(self):
original = {"log-driver": "json-file", "features": {"containerd-snapshotter": True},
"registry-mirrors": ["https://existing.example", "https://second.example/"]}
self.path.write_text(json.dumps(original))
module.configure(self.path)
expected = {**original, "registry-mirrors": [*original["registry-mirrors"], module.MIRROR]}
self.assertEqual(json.loads(self.path.read_text()), expected)
def test_second_run_does_not_write(self):
module.configure(self.path)
before = self.path.read_bytes()
modified = self.path.stat().st_mtime_ns
module.configure(self.path)
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(self.path.stat().st_mtime_ns, modified)
def test_existing_mirror_with_trailing_slash_does_not_write(self):
before = '{"registry-mirrors": ["https://mirror.gcr.io/"], "debug": false}\n'
self.path.write_text(before)
module.configure(self.path)
self.assertEqual(self.path.read_text(), before)
def test_invalid_json_fails_without_writing(self):
before = b'{"debug":'
self.path.write_bytes(before)
with self.assertRaises(json.JSONDecodeError):
module.configure(self.path)
self.assertEqual(self.path.read_bytes(), before)
def test_invalid_structure_fails_without_writing(self):
for value in [[], {"registry-mirrors": "https://existing.example"},
{"registry-mirrors": [None]}]:
with self.subTest(value=value):
before = json.dumps(value).encode()
self.path.write_bytes(before)
with self.assertRaises(ValueError):
module.configure(self.path)
self.assertEqual(self.path.read_bytes(), before)
def test_existing_file_permissions_preserved(self):
self.path.write_text('{"debug": false}')
self.path.chmod(0o600)
mode = self.path.stat().st_mode
module.configure(self.path)
self.assertEqual(self.path.stat().st_mode, mode)
def test_backup_preserves_original_bytes_and_mode(self):
before = b'{ "debug": false, "log-driver": "json-file" }\n'
self.path.write_bytes(before)
self.path.chmod(0o600)
mode = self.path.stat().st_mode
module.configure(self.path)
backup = self.path.with_name(self.path.name + ".before-dbx-mirror")
self.assertEqual(backup.read_bytes(), before)
self.assertEqual(backup.stat().st_mode, mode)
module.configure(self.path)
self.assertEqual(backup.read_bytes(), before)
def test_validator_failure_preserves_original_and_removes_candidate(self):
before = b'{"debug": false}\n'
self.path.write_bytes(before)
def reject(candidate):
self.assertEqual(json.loads(candidate.read_text())["registry-mirrors"], [module.MIRROR])
self.assertEqual(self.path.read_bytes(), before)
raise ValueError("daemon validation failed")
with self.assertRaisesRegex(ValueError, "daemon validation failed"):
module.configure(self.path, reject)
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
def test_validation_precedes_atomic_replace(self):
before = b'{"debug": true}\n'
self.path.write_bytes(before)
candidates = []
def validate(candidate):
self.assertEqual(candidate.parent, self.path.parent)
self.assertEqual(self.path.read_bytes(), before)
self.assertTrue(json.loads(candidate.read_text())["debug"])
candidates.append(candidate)
module.configure(self.path, validate)
self.assertEqual(len(candidates), 1)
self.assertFalse(candidates[0].exists())
self.assertIn(module.MIRROR, json.loads(self.path.read_text())["registry-mirrors"])
if __name__ == "__main__":
unittest.main()
+42 -6
View File
@@ -1793,12 +1793,48 @@ jobs:
cat RELEASE_NOTES.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
token: ${{ steps.release-token.outputs.token }}
files: release/*
body_path: RELEASE_NOTES.md
make_latest: false
env:
GH_TOKEN: ${{ steps.release-token.outputs.token }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
# Idempotent release publish: create the release only when missing and
# upload only assets it does not have yet, in small spaced batches.
# Re-uploading the full set on every retry trips GitHub secondary
# rate limits and dropped connections strand the whole job.
if gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
# --draft=false: a tag deleted mid-recovery leaves the release as a
# draft, which /releases/tags then 404s for reuse jobs and users.
gh release edit "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
--notes-file RELEASE_NOTES.md --draft=false --latest=false >/dev/null
else
gh release create "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
--verify-tag --title "$TAG_NAME" \
--notes-file RELEASE_NOTES.md --latest=false
fi
mapfile -t existing < <(gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
--json assets --jq '.assets[].name')
declare -A have=()
for name in "${existing[@]:-}"; do
[ -n "$name" ] && have["$name"]=1
done
missing=()
for f in release/*; do
[ -f "$f" ] || continue
[ -n "${have["$(basename "$f")"]:-}" ] || missing+=("$f")
done
echo "release already has ${#existing[@]} assets; uploading ${#missing[@]} missing"
batch_size=5
i=0
while [ "$i" -lt "${#missing[@]}" ]; do
gh release upload "$TAG_NAME" "${missing[@]:$i:$batch_size}" --repo "$GITHUB_REPOSITORY"
i=$((i + batch_size))
if [ "$i" -lt "${#missing[@]}" ]; then sleep 20; fi
done
echo "uploaded ${#missing[@]} assets in batches of $batch_size"
- name: Update agents-latest GitHub release
env:
+60 -42
View File
@@ -7,8 +7,9 @@ on:
branches: [ main ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && format('pr-author-{0}-{1}', github.event.pull_request.user.id, (endsWith(github.event.pull_request.number, '0') || endsWith(github.event.pull_request.number, '2') || endsWith(github.event.pull_request.number, '4') || endsWith(github.event.pull_request.number, '6') || endsWith(github.event.pull_request.number, '8')) && 'even' || 'odd') || github.ref }}
cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login == github.repository_owner }}
queue: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && 'max' || 'single' }}
env:
RUSTFLAGS: -C debuginfo=line-tables-only
@@ -156,7 +157,20 @@ jobs:
node-version: 22.13.0
- name: GitHub script tests
run: node --test .github/scripts/*.test.mjs
env:
FAST_CHECKS: ${{ needs.changes.outputs.fast }}
run: |
tests=()
for file in .github/scripts/*.test.mjs; do
if [[ "$FAST_CHECKS" == true && "${file##*/}" == ci-* ]]; then
continue
fi
tests+=("$file")
done
node --test "${tests[@]}"
- name: Docker mirror config tests
run: python3 .github/scripts/configure-docker-mirror.test.py
- name: PowerShell MCP installer tests
shell: pwsh
@@ -217,7 +231,7 @@ jobs:
uses: dtolnay/rust-toolchain@1.97.1
- name: Install nextest
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.68.13
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
with:
tool: cargo-nextest@0.9.137
@@ -287,7 +301,7 @@ jobs:
windows-standard-check:
needs: changes
if: needs.changes.outputs.windows_win7_bundle == 'true'
if: needs.changes.outputs.windows_standard == 'true'
runs-on: windows-2022
timeout-minutes: 45
env:
@@ -869,6 +883,16 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Configure public Docker Hub cache
if: runner.os == 'Linux'
run: |
source /etc/os-release
[[ "$ID" == ubuntu ]] || exit 0
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
timeout 30s sudo systemctl restart docker
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
- name: Install system dependencies
run: |
sudo timeout --kill-after=30s 10m apt-get \
@@ -944,7 +968,7 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install nextest
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
with:
tool: cargo-nextest@0.9.137
@@ -1077,7 +1101,7 @@ jobs:
changes:
runs-on: ubuntu-24.04
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
frontend: ${{ steps.filter.outputs.frontend == 'true' && steps.plan.outputs.contributor_snapshot_only != 'true' }}
packages: ${{ steps.filter.outputs.packages }}
rust: ${{ steps.plan.outputs.rust }}
rust_full: ${{ steps.plan.outputs.rust_full }}
@@ -1086,7 +1110,8 @@ jobs:
agents: ${{ steps.plan.outputs.agents }}
duckdb_windows: ${{ steps.filter.outputs.duckdb_windows == 'true' || steps.plan.outputs.duckdb_windows == 'true' }}
nix: ${{ steps.filter.outputs.nix }}
windows_win7_bundle: ${{ steps.filter.outputs.windows_win7_bundle }}
windows_standard: ${{ steps.plan.outputs.windows_standard }}
windows_win7_bundle: ${{ steps.plan.outputs.windows_win7_bundle }}
windows_win7_candidate: ${{ steps.plan.outputs.windows_win7_candidate }}
windows_win7_affected_packages: ${{ steps.plan.outputs.windows_win7_affected_packages }}
windows_win7_reasons: ${{ steps.plan.outputs.windows_win7_reasons }}
@@ -1105,14 +1130,15 @@ jobs:
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 1
fetch-depth: 2
- name: Fetch change base
id: change-base
env:
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
BEFORE_SHA: ${{ github.event.before }}
run: |
if [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]]; then
BASE_SHA="$(node .github/scripts/ci-change-base.mjs)"
if [[ -z "$BASE_SHA" ]]; then
exit 0
fi
git fetch --no-tags --depth=1 origin "$BASE_SHA"
@@ -1122,7 +1148,9 @@ jobs:
uses: dorny/paths-filter@v4
id: filter
with:
token: ''
base: ${{ steps.change-base.outputs.sha }}
ref: ${{ github.sha }}
filters: |
frontend:
- 'apps/desktop/**'
@@ -1205,35 +1233,16 @@ jobs:
- 'flake.lock'
- '.github/workflows/ci.yml'
- '.github/workflows/update-nix-pnpm-hash.yml'
windows_win7_bundle:
- '.github/scripts/assert-win7-pe-compat.ps1'
- '.github/scripts/assert-webview2-win7-loader.ps1'
- '.github/scripts/assert-win7-installer-content.ps1'
- '.github/scripts/assert-webview2-win7-runtime.ps1'
- '.github/scripts/prepare-webview2-win7-loader.ps1'
- '.github/scripts/prepare-webview2-win7-runtime.ps1'
- '.github/workflows/ci.yml'
- '.github/workflows/release.yml'
- 'src-tauri/tauri.webview2-win7-fixed.conf.json'
- 'src-tauri/build.rs'
- 'src-tauri/Cargo.toml'
- 'src-tauri/windows/nsis/**'
- 'src-tauri/src/commands/update.rs'
- 'crates/dbx-core/Cargo.toml'
- 'crates/dbx-drivers/**'
- 'crates/dbx-driver-*/**'
- 'crates/dbx-platform/**'
- 'crates/dbx-core/src/host/update.rs'
- 'Cargo.toml'
- 'Cargo.lock'
- 'vendor/ctor/**'
- 'vendor/dirs-sys/**'
- 'vendor/pageant/**'
- 'vendor/webview2-com-sys/**'
- 'vendor/wry/**'
github_scripts:
- '.github/scripts/**'
- 'docs/scripts/contributor-snapshot.mjs'
- 'docs/scripts/update-contributors.mjs'
- 'docs/public/install-mcp*'
- '.github/workflows/contributors.yml'
- '.github/workflows/issue-claim.yml'
- '.github/workflows/notify.yml'
- '.github/workflows/plugin-dev-host.yml'
- '.github/workflows/spam-comment-guard.yml'
- '.github/workflows/mcp-release.yml'
- '.github/workflows/publish-packages.yml'
- '.github/workflows/plugin-release-reusable.yml'
@@ -1246,7 +1255,6 @@ jobs:
BASE_SHA: ${{ steps.change-base.outputs.sha }}
RUST_CHANGED: ${{ steps.filter.outputs.rust }}
AGENTS_CHANGED: ${{ steps.filter.outputs.agents }}
WIN7_CURRENT: ${{ steps.filter.outputs.windows_win7_bundle }}
run: node .github/scripts/ci-plan.mjs
agents:
@@ -1439,7 +1447,7 @@ jobs:
cache-on-failure: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install nextest
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
with:
tool: cargo-nextest@0.9.137
# mold -run wraps the whole process tree (see the packages job note);
@@ -1492,7 +1500,7 @@ jobs:
run: bash ../.github/scripts/ci-agent-go.sh "$DRIVER" "$BINARY" "$RACE"
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix: ${{ fromJSON(needs.changes.outputs.agent_go) }}
agent-integration:
needs: [changes, fast-checks, agent-checks]
@@ -1503,6 +1511,16 @@ jobs:
working-directory: agents
steps:
- uses: actions/checkout@v7
- name: Configure public Docker Hub cache
if: runner.os == 'Linux'
working-directory: ${{ github.workspace }}
run: |
source /etc/os-release
[[ "$ID" == ubuntu ]] || exit 0
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
timeout 30s sudo systemctl restart docker
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
- name: Setup RocketMQ server Java
if: matrix.scenario == 'rocketmq'
uses: actions/setup-java@v6
@@ -1520,7 +1538,7 @@ jobs:
if: matrix.driver == 'tdengine'
- name: Install nextest
if: matrix.driver == 'tdengine'
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
with:
tool: cargo-nextest@0.9.137
- name: TDengine Rust cache
@@ -1540,7 +1558,7 @@ jobs:
name: agent-integration (${{ matrix.scenario }}, ${{ matrix.version }})
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix: ${{ fromJSON(needs.changes.outputs.agent_integration) }}
ci:
needs:
+1 -1
View File
@@ -2,7 +2,7 @@ name: Update Contributors
on:
schedule:
- cron: "0 18 * * *"
- cron: "0 18 * * 0"
workflow_dispatch:
permissions:
+5 -1
View File
@@ -16,7 +16,11 @@ permissions:
jobs:
claim:
runs-on: ubuntu-latest
if: ${{ !github.event.issue.pull_request }}
if: >-
${{ !github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
(contains(github.event.comment.body, '/claim') ||
contains(github.event.comment.body, '/unclaim') ||
contains(github.event.comment.body, '/close')) }}
steps:
- uses: actions/checkout@v5
+1 -1
View File
@@ -159,7 +159,7 @@ jobs:
run: cargo check -p dbx-mcp -p dbx-cli --no-default-features
- name: Install nextest
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.68.13
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
with:
tool: cargo-nextest@0.9.137
+4 -2
View File
@@ -3,13 +3,15 @@ name: Notifications
on:
# issues:
# types: [opened]
pull_request_target:
types: [opened, closed]
# pull_request_target:
# types: [closed]
workflow_dispatch:
# Release 不在此通知:桌面正式版由 publish-packages.yml 在转正后发飞书卡片;
# 组件包(agents-v*/packages-v*)发布不进群。
jobs:
notify:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Build message
+1 -1
View File
@@ -130,7 +130,7 @@ jobs:
fi
- name: Install nextest
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
with:
tool: cargo-nextest@0.9.137
+15 -3
View File
@@ -11,8 +11,22 @@ on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci --prefix plugins/sdk/dev-host
- run: npm run lint --prefix plugins/sdk/dev-host
- run: npm run fmt:check --prefix plugins/sdk/dev-host
test:
needs: checks
strategy:
fail-fast: false
matrix:
@@ -29,11 +43,9 @@ jobs:
go-version: '1.22'
cache: false
- run: npm ci --prefix plugins/sdk/dev-host
- run: npm run lint --prefix plugins/sdk/dev-host
- run: npm run fmt:check --prefix plugins/sdk/dev-host
- run: npm test --prefix plugins/sdk/dev-host
timeout-minutes: 5
- uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
- uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
with:
tool: cargo-nextest@0.9.137
- run: cargo nextest run --locked --manifest-path plugins/sdk/cli/Cargo.toml --no-fail-fast
+5 -2
View File
@@ -1,8 +1,11 @@
name: Pull Request Labels
on:
pull_request_target:
types: [opened, edited, synchronize, reopened, ready_for_review]
# Temporarily disabled (2026-10-10): informational labels only, no
# mechanical consumers, fires on every PR event. Uncomment to re-enable.
# pull_request_target:
# types: [opened, edited, synchronize, reopened, ready_for_review]
workflow_dispatch:
concurrency:
group: pull-request-labels-${{ github.event.pull_request.number }}
+6 -1
View File
@@ -12,7 +12,12 @@ jobs:
delete-malicious-link:
runs-on: ubuntu-latest
# 跳过机器人与仓库 owner,避免误删维护者的正当评论
if: ${{ github.event.comment.user.type != 'Bot' && github.event.comment.user.login != github.repository_owner }}
if: >-
${{ github.event.comment.user.type != 'Bot' && github.event.comment.user.login != github.repository_owner &&
(contains(github.event.comment.body, '.zip') ||
contains(github.event.comment.body, '.7z') ||
contains(github.event.comment.body, '.exe') ||
contains(github.event.comment.body, '.scr')) }}
env:
# owner PAT:GITHUB_TOKEN 非 admin 删不掉别人的评论,必须用 owner 令牌
GH_TOKEN: ${{ secrets.PROJECT_AUTOMATION_TOKEN }}
+4 -2
View File
@@ -19,8 +19,10 @@ name: UI preview
# PR's own frontend build is what's on screen (DBX_STATIC_DIR).
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled]
# Temporarily disabled (2026-10-10) while jobs queue without a runner;
# manual dispatch below still works. Uncomment to re-enable.
# pull_request_target:
# types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
inputs:
pr:
Generated
+45 -17
View File
@@ -262,6 +262,12 @@ dependencies = [
"rustversion",
]
[[package]]
name = "arcstr"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "argon2"
version = "0.5.3"
@@ -1527,9 +1533,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
version = "4.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"futures-core",
@@ -2137,7 +2143,7 @@ dependencies = [
[[package]]
name = "dbx"
version = "0.6.38"
version = "0.6.42"
dependencies = [
"anyhow",
"axum",
@@ -2153,6 +2159,7 @@ dependencies = [
"font-kit",
"futures",
"grep",
"gtk",
"ignore",
"libloading 0.8.9",
"log",
@@ -2220,7 +2227,7 @@ dependencies = [
[[package]]
name = "dbx-cli"
version = "0.4.111"
version = "0.4.117"
dependencies = [
"async-trait",
"dbx-core",
@@ -2428,7 +2435,9 @@ dependencies = [
"base64 0.22.1",
"dbx-driver-support",
"dbx-types",
"futures-util",
"redis",
"rustls 0.23.43",
"serde",
"serde_json",
"tokio",
@@ -2557,7 +2566,7 @@ dependencies = [
[[package]]
name = "dbx-mcp"
version = "0.4.111"
version = "0.4.117"
dependencies = [
"async-trait",
"axum",
@@ -2741,7 +2750,7 @@ dependencies = [
[[package]]
name = "dbx-web"
version = "0.6.38"
version = "0.6.42"
dependencies = [
"aes-gcm 0.10.3",
"argon2",
@@ -5892,7 +5901,7 @@ dependencies = [
"darling 0.23.0",
"heck 0.5.0",
"manyhow",
"num-bigint",
"num-bigint 0.4.8",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
@@ -5947,7 +5956,7 @@ dependencies = [
"getrandom 0.3.4",
"hex",
"mysql-common-derive",
"num-bigint",
"num-bigint 0.4.8",
"num-traits",
"regex",
"rust_decimal",
@@ -6098,7 +6107,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-bigint 0.4.8",
"num-complex",
"num-integer",
"num-iter",
@@ -6116,6 +6125,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -6172,7 +6191,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-bigint 0.4.8",
"num-integer",
"num-traits",
]
@@ -7627,22 +7646,24 @@ dependencies = [
[[package]]
name = "redis"
version = "0.32.7"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
dependencies = [
"arcstr",
"async-lock",
"bytes",
"cfg-if",
"combine",
"crc16",
"futures-sink",
"futures-channel",
"futures-util",
"itoa",
"log",
"num-bigint",
"num-bigint 0.5.1",
"percent-encoding",
"pin-project-lite",
"rand 0.9.5",
"rand 0.10.2",
"rustls 0.23.43",
"rustls-native-certs 0.8.4",
"ryu",
@@ -7652,6 +7673,7 @@ dependencies = [
"tokio-rustls 0.26.4",
"tokio-util",
"url",
"xxhash-rust",
]
[[package]]
@@ -8055,7 +8077,7 @@ dependencies = [
"md5",
"ml-kem",
"module-lattice",
"num-bigint",
"num-bigint 0.4.8",
"p256",
"p384",
"p521",
@@ -9055,7 +9077,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-bigint 0.4.8",
"num-traits",
"thiserror 2.0.19",
"time",
@@ -12112,6 +12134,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "xxhash-rust"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.1"
+34
View File
@@ -124,6 +124,17 @@
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">Visit HuaLongAI</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-en" target="_blank">
<img src="docs/public/sponsors/hiapi-card.png" alt="HiAPI" width="175" />
</a>
</td>
<td>
HiAPI is a developer-focused API platform for image, video, audio, and text models — GPT Image 2.5 (Flare & Sunburst), OpenAI-compatible text endpoints, and Remote MCP/Agent Skills for Claude Code, Cursor, and more. Pay as you go with no minimum spend; new users get 200 Credits on signup, plus 10% bonus Credits on top-ups.
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-en" target="_blank">Visit HiAPI</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
@@ -135,6 +146,29 @@
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">Visit UCloud</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">
<img src="docs/public/sponsors/packyapi-card.png" alt="PackyCode" width="175" />
</a>
</td>
<td>
PackyCode is a stable, efficient API relay for mainstream AI models. Access them with one API endpoint and one API key — automatic failover keeps access fast and reliable, with dedicated high-speed routes for Codex and Claude Code. Get started with $1 in free credits, a first top-up discount, and up to 80% off eligible routes; pay in RMB with no currency conversion markups or extra top-up fees.
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">Visit PackyCode</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://mate.vip/ee/" target="_blank">
<img src="docs/public/sponsors/taiyi-card.png" alt="太一企业版" width="175" />
</a>
</td>
<td>
太一智能体企业版是面向央国企的私有化智能体平台,采用“1+N+N”架构:1 个平台、N 个终端、N 个场景。<strong>可信</strong>:Java 微服务架构,私有化部署,全程留痕可审计。<strong>可控</strong>:关键操作人工审批,资料按密级管理。<strong>可达</strong>:按单位模板直接生成公文、台账和汇报材料。<strong>可复制</strong>:场景方案包化交付,便于推广和二次开发。欢迎预约演示,或在 GitHub 搜索 MateClaw。
<a href="https://mate.vip/ee/" target="_blank">访问太一企业版</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
+34
View File
@@ -124,6 +124,17 @@
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">访问 HuaLongAI</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-zh" target="_blank">
<img src="docs/public/sponsors/hiapi-card.png" alt="HiAPI" width="175" />
</a>
</td>
<td>
HiAPI 是面向开发者的图片、视频、音频及文本模型 API 平台——GPT Image 2.5(Flare 与 Sunburst)、OpenAI 兼容文本接口,并可通过 Remote MCP/Agent Skills 接入 Claude Code、Cursor 等编程工具。按量付费、无最低消费;新用户注册赠送 200 Credits,充值送 10% 积分。
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-zh" target="_blank">访问 HiAPI</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
@@ -135,6 +146,29 @@
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">访问 UCloud 优刻得</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">
<img src="docs/public/sponsors/packyapi-card.png" alt="PackyCode" width="175" />
</a>
</td>
<td>
PackyCode 是一家稳定、高效的 API 中转服务商,一句话接入主流大模型。统一域名、统一密钥、智能容灾切换,97% 可用性;人民币 1:1 充值,无汇率无手续费坑,新用户首充立享折扣 + $1 免费体验额度,多分组折扣低至 2 折起,并提供专属 Codex/Claude Code 高速通道。
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">访问 PackyCode</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://mate.vip/ee/" target="_blank">
<img src="docs/public/sponsors/taiyi-card.png" alt="太一企业版" width="175" />
</a>
</td>
<td>
太一智能体企业版是面向央国企的私有化智能体平台,采用“1+N+N”架构:1 个平台、N 个终端、N 个场景。<strong>可信</strong>:Java 微服务架构,私有化部署,全程留痕可审计。<strong>可控</strong>:关键操作人工审批,资料按密级管理。<strong>可达</strong>:按单位模板直接生成公文、台账和汇报材料。<strong>可复制</strong>:场景方案包化交付,便于推广和二次开发。欢迎预约演示,或在 GitHub 搜索 MateClaw。
<a href="https://mate.vip/ee/" target="_blank">访问太一企业版</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
@@ -421,7 +421,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
releasePooledConnection(true);
return;
}
if (sessionAffinity || executor.hasOpenSessions() || executor.hasActiveStatements()) {
if (sessionAffinity || executor.hasOpenSessions() || executor.hasActiveStatements() || hasRetainedResultResources()) {
return;
}
releasePooledConnection(!preparePooledConnectionForReturn());
@@ -438,7 +438,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
if (sessionAffinity) {
return;
}
if (executor.hasOpenSessions() || executor.hasActiveStatements()) {
if (executor.hasOpenSessions() || executor.hasActiveStatements() || hasRetainedResultResources()) {
return;
}
releasePooledConnection(!preparePooledConnectionForReturn());
@@ -713,7 +713,12 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
);
}
protected boolean hasRetainedResultResources() { return false; }
protected void releaseRetainedResultResources() { }
private void closeCurrentConnection() throws Exception {
releaseRetainedResultResources();
if (pooledLease != null) {
boolean evict = pooledConnectionPoisoned || sessionAffinity || !preparePooledConnectionForReturn();
releasePooledConnection(evict);
@@ -732,7 +737,17 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
return JdbcSchemaSwitcher.resetBeforeReturn(connection);
}
protected final synchronized void discardConnection(Connection failedConnection) throws java.sql.SQLException {
if (pooledLease != null && connection == failedConnection) {
// Evict the checked-out lease before closing its proxy; close alone returns it to the pool.
releasePooledConnection(true);
} else {
failedConnection.close();
}
}
private void releasePooledConnection(boolean evict) {
releaseRetainedResultResources();
JdbcConnectionPoolRegistry.Lease lease = pooledLease;
pooledLease = null;
connection = null;
@@ -38,6 +38,8 @@ public final class AgentProtocol {
public static final String METHOD_EXECUTE_QUERY_PAGE = "execute_query_page";
public static final String METHOD_FETCH_QUERY_PAGE = "fetch_query_page";
public static final String METHOD_CLOSE_QUERY_SESSION = "close_query_session";
public static final String METHOD_READ_LARGE_VALUE_CHUNK = "read_large_value_chunk";
public static final String METHOD_RELEASE_LARGE_VALUE = "release_large_value";
public static final String METHOD_START_TABLE_READ = "start_table_read";
public static final String METHOD_FETCH_TABLE_READ_PAGE = "fetch_table_read_page";
public static final String METHOD_CLOSE_TABLE_READ_SESSION = "close_table_read_session";
@@ -141,6 +143,7 @@ public final class AgentProtocol {
public static final String CAPABILITY_MONGO_FIND_CURSOR = "mongo_find_cursor";
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
public static final String CAPABILITY_BLOB_BIND_STATEMENTS_V1 = "blob_bind_statements_v1";
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
CAPABILITY_CONNECT,
@@ -215,6 +218,8 @@ public final class AgentProtocol {
METHOD_EXECUTE_QUERY_PAGE,
METHOD_FETCH_QUERY_PAGE,
METHOD_CLOSE_QUERY_SESSION,
METHOD_READ_LARGE_VALUE_CHUNK,
METHOD_RELEASE_LARGE_VALUE,
METHOD_START_TABLE_READ,
METHOD_FETCH_TABLE_READ_PAGE,
METHOD_CLOSE_TABLE_READ_SESSION,
@@ -267,6 +272,7 @@ public final class AgentProtocol {
List<String> jdbcAllCapabilities = new java.util.ArrayList<>(MULTI_SESSION_ALL_CAPABILITIES);
jdbcAllCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
jdbcAllCapabilities.add(CAPABILITY_BLOB_BIND_STATEMENTS_V1);
MULTI_SESSION_JDBC_ALL_CAPABILITIES = Collections.unmodifiableList(jdbcAllCapabilities);
List<String> methods = new java.util.ArrayList<>(COMMON_METHODS);
@@ -383,6 +389,13 @@ public final class AgentProtocol {
);
}
public static HandshakeResult multiSessionJdbcHandshakeResult(boolean blobBindStatements) {
if (!blobBindStatements) return multiSessionJdbcHandshakeResult();
java.util.ArrayList<String> capabilities = new java.util.ArrayList<>(MULTI_SESSION_JDBC_CAPABILITIES);
capabilities.add(CAPABILITY_BLOB_BIND_STATEMENTS_V1);
return new HandshakeResult(MULTI_SESSION_PROTOCOL_VERSION, MULTI_SESSION_PROTOCOL_VERSION, capabilities);
}
public static final class HandshakeResult {
private final int protocolVersion;
private final int agentProtocolVersion;
@@ -57,6 +57,11 @@ final class AgentRpcError extends RuntimeException {
);
}
static AgentRpcError rollbackConfirmedSql(SQLException error) {
return new AgentRpcError(message(error), "sql", false, "keep", "execute", "unknown",
safeSqlState(error.getSQLState()), error.getErrorCode(), safeClassName(error), error);
}
static AgentRpcError backpressure(String stage, Throwable cause) {
return new AgentRpcError(
"Agent request capacity is temporarily exhausted",
@@ -0,0 +1,208 @@
package com.dbx.agent;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Savepoint;
import java.sql.SQLException;
import java.sql.SQLTransientConnectionException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;
import java.util.function.Supplier;
public final class BlobBoundExecutor {
private BlobBoundExecutor() { }
@FunctionalInterface
public interface PreparedStatementConfigurer {
void configure(PreparedStatement statement) throws SQLException;
default void discardConnection(Connection connection) throws SQLException { connection.close(); }
default boolean isRollbackConfirmedBusinessError(SQLException error) { return false; }
default boolean isUnsupportedSavepointRelease(SQLException error) { return false; }
}
public static QueryResult execute(Connection conn, List<String> previews, List<BlobBoundStatement> statements,
String schema, Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql,
int timeoutSecs, boolean transaction) {
return execute(conn, previews, statements, schema, setSchemaSql, resetSchemaSql, timeoutSecs,
transaction, statement -> { });
}
public static QueryResult execute(Connection conn, List<String> previews, List<BlobBoundStatement> statements,
String schema, Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql,
int timeoutSecs, boolean transaction, PreparedStatementConfigurer configurer) {
BlobBoundStatement.validate(previews, statements);
if (timeoutSecs < 0) throw new IllegalArgumentException("BLOB binding timeout must be non-negative");
try (JdbcExecutor.NativeOperation operation = JdbcExecutor.current().beginNativeOperation()) {
operation.checkCancelled();
boolean autoCommit = conn.getAutoCommit();
if (transaction && !autoCommit) {
throw new IllegalStateException("Cannot start a one-shot transaction while a manual transaction is open");
}
// Typed grid saves are atomic even when execute_batch omits useTransaction.
// Existing manual transactions keep their batch-only savepoint boundary.
boolean oneShot = transaction || autoCommit;
if (oneShot && !conn.getMetaData().supportsTransactions()) {
throw new UnsupportedOperationException("Transactions are not supported by this JDBC driver");
}
if (oneShot) conn.setAutoCommit(false);
Savepoint savepoint = !oneShot ? conn.setSavepoint() : null;
boolean finished = !oneShot;
boolean discarded = false;
boolean commitAttempted = false;
try {
long start = System.currentTimeMillis();
long affected = executeAll(conn, statements, schema, setSchemaSql, resetSchemaSql, timeoutSecs, operation, configurer);
operation.checkCancelled();
if (oneShot) { commitAttempted = true; conn.commit(); }
if (savepoint != null) releaseSavepoint(conn, savepoint, configurer);
finished = true;
return new QueryResult(Collections.emptyList(), Collections.emptyList(), affected,
System.currentTimeMillis() - start, false);
} catch (Exception failure) {
if (oneShot) {
try { conn.rollback(); finished = true; }
catch (Exception rollbackFailure) {
discarded = true;
throw rollbackUnconfirmed(conn, failure, rollbackFailure, configurer);
}
if (!commitAttempted && failure instanceof SQLException sqlFailure
&& configurer.isRollbackConfirmedBusinessError(sqlFailure)) {
throw AgentRpcError.rollbackConfirmedSql(sqlFailure);
}
}
if (savepoint != null) {
try { conn.rollback(savepoint); }
catch (Exception rollbackFailure) { throw rollbackUnconfirmed(conn, failure, rollbackFailure, configurer); }
try { releaseSavepoint(conn, savepoint, configurer); }
catch (Exception releaseFailure) {
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
"BLOB savepoint cleanup failed after batch rollback; discard this connection and treat transaction state as unknown", "08007", releaseFailure);
unsafe.addSuppressed(failure);
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
throw unsafe;
}
if (failure instanceof SQLException sqlFailure && configurer.isRollbackConfirmedBusinessError(sqlFailure)) {
throw AgentRpcError.rollbackConfirmedSql(sqlFailure);
}
}
throw failure;
} finally {
if (oneShot) {
if (finished) {
try { conn.setAutoCommit(autoCommit); }
catch (Exception resetFailure) {
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
"BLOB transaction connection reset failed; committed writes may exist and operation outcome is unknown", "08007", resetFailure);
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
throw unsafe;
}
} else if (!discarded) configurer.discardConnection(conn); // Never enable auto-commit after an unconfirmed rollback.
}
}
} catch (RuntimeException failure) {
throw failure;
} catch (Exception failure) {
throw new RuntimeException(failure);
}
}
private static SQLTransientConnectionException rollbackUnconfirmed(Connection conn, Exception failure,
Exception rollbackFailure, PreparedStatementConfigurer configurer) {
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
"BLOB binding rollback was not confirmed; discard this connection and treat the operation outcome as unknown", "08007", failure);
unsafe.addSuppressed(rollbackFailure);
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
return unsafe;
}
private static void releaseSavepoint(Connection conn, Savepoint savepoint,
PreparedStatementConfigurer configurer) throws SQLException {
try { conn.releaseSavepoint(savepoint); }
catch (SQLException error) {
if (!configurer.isUnsupportedSavepointRelease(error)) throw error;
}
}
private static long executeAll(Connection conn, List<BlobBoundStatement> statements, String schema,
Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql, int timeoutSecs,
JdbcExecutor.NativeOperation operation, PreparedStatementConfigurer configurer) throws Exception {
JdbcExecutor executor = JdbcExecutor.current();
try (BoundResources resources = new BoundResources()) {
operation.checkCancelled();
JdbcSchemaSwitcher.apply(conn, schema, setSchemaSql, resetSchemaSql);
for (BlobBoundStatement bound : statements) {
operation.checkCancelled();
String sql = JdbcExecutor.trimSql(bound.sql());
Statement statement = bound.blobParameters().isEmpty() ? conn.createStatement()
: isAnonymousBlock(sql) ? conn.prepareCall(sql) : conn.prepareStatement(sql);
resources.statements.add(executor.trackStatement(statement));
statement.setQueryTimeout(timeoutSecs);
if (statement instanceof PreparedStatement prepared) {
configurer.configure(prepared);
int index = 1;
for (String hex : bound.blobParameters()) {
InputStream stream = new HexStream(hex);
resources.streams.add(stream);
prepared.setBlob(index++, stream, (long) hex.length() / 2);
}
}
}
// Prepare and bind the complete batch before the first write.
long affected = 0;
for (int i = 0; i < statements.size(); i++) {
operation.checkCancelled();
Statement statement = resources.statements.get(i).statement();
boolean result = statement instanceof PreparedStatement prepared ? prepared.execute()
: statement.execute(JdbcExecutor.trimSql(statements.get(i).sql()));
if (!result) affected += Math.max(0, statement.getUpdateCount());
operation.checkCancelled();
}
return affected;
}
}
private static final class BoundResources implements AutoCloseable {
final List<JdbcExecutor.TrackedStatement<Statement>> statements = new ArrayList<>();
final List<InputStream> streams = new ArrayList<>();
@Override public void close() throws Exception {
Exception failure = null;
List<AutoCloseable> resources = new ArrayList<>(statements);
resources.addAll(streams);
for (AutoCloseable resource : resources) {
try { resource.close(); }
catch (Exception error) {
if (failure == null) failure = error;
else failure.addSuppressed(error);
}
}
if (failure != null) throw failure;
}
}
private static boolean isAnonymousBlock(String sql) {
String normalized = sql.toUpperCase(Locale.ROOT);
return normalized.startsWith("DECLARE") || normalized.startsWith("BEGIN");
}
/** Decode already-validated hex without retaining a second full byte array. */
private static final class HexStream extends InputStream {
private final String hex;
private int offset;
private boolean closed;
HexStream(String hex) { this.hex = hex; }
@Override public int available() { return closed ? 0 : (hex.length() - offset) / 2; }
@Override public int read() throws java.io.IOException {
if (closed) throw new java.io.IOException("BLOB binding stream is closed");
if (offset == hex.length()) return -1;
int value = Character.digit(hex.charAt(offset), 16) * 16 + Character.digit(hex.charAt(offset + 1), 16);
offset += 2;
return value;
}
@Override public void close() { closed = true; }
}
}
@@ -0,0 +1,93 @@
package com.dbx.agent;
import java.util.List;
/** Optional BLOB-only binding payload; preview text stays authoritative. */
public final class BlobBoundStatement {
private String previewSql;
private String sql;
private List<String> blobParameters;
public BlobBoundStatement(String previewSql, String sql, List<String> blobParameters) {
this.previewSql = previewSql;
this.sql = sql;
this.blobParameters = blobParameters;
}
public String sql() { return sql; }
public List<String> blobParameters() { return blobParameters; }
public static void validate(List<String> statements, List<BlobBoundStatement> boundStatements) {
if (statements == null || boundStatements == null || statements.size() != boundStatements.size()) {
throw new IllegalArgumentException("BLOB bound statements must match the complete statement batch");
}
for (int i = 0; i < statements.size(); i++) {
BlobBoundStatement bound = boundStatements.get(i);
if (bound == null || statements.get(i) == null || !statements.get(i).equals(bound.previewSql)
|| bound.sql == null || bound.sql.trim().isEmpty() || bound.blobParameters == null) {
throw new IllegalArgumentException("BLOB binding preview mismatch at statement " + (i + 1));
}
if (bound.blobParameters.isEmpty() && !bound.sql.equals(bound.previewSql)) {
throw new IllegalArgumentException("Unbound statement SQL must match its preview");
}
SqlScan scan = scanSql(bound.sql);
String visible = scan.visible().stripLeading().toUpperCase(java.util.Locale.ROOT);
if (!visible.matches("(?s)^(?:UPDATE|INSERT|DELETE)\\b.*")
&& !visible.matches("(?s)^(?:DECLARE\\b.*?)?BEGIN\\s+(?:UPDATE|INSERT|DELETE)\\b.*")
|| java.util.regex.Pattern.compile("\\b(?:CREATE|ALTER|DROP|TRUNCATE|COMMIT|ROLLBACK|SAVEPOINT|GRANT|REVOKE|EXECUTE)\\b").matcher(visible).find()) {
throw new IllegalArgumentException("BLOB binding only supports data-grid DML, without DDL or transaction control");
}
if (!bound.blobParameters.isEmpty() && scan.parameters() != bound.blobParameters.size()) {
throw new IllegalArgumentException("BLOB parameter count does not match statement " + (i + 1));
}
for (String hex : bound.blobParameters) {
if (hex == null || (hex.length() & 1) != 0) {
throw new IllegalArgumentException("BLOB parameters must contain even-length hexadecimal bytes");
}
for (int j = 0; j < hex.length(); j++) {
if (Character.digit(hex.charAt(j), 16) < 0 || hex.charAt(j) > 127) {
throw new IllegalArgumentException("BLOB parameters must contain hexadecimal bytes only");
}
}
}
}
}
private record SqlScan(int parameters, String visible) { }
private static SqlScan scanSql(String sql) {
int count = 0;
StringBuilder visible = new StringBuilder();
for (int i = 0; i < sql.length(); i++) {
char ch = sql.charAt(i);
char next = i + 1 < sql.length() ? sql.charAt(i + 1) : '\0';
if (ch == '-' && next == '-') {
visible.append(' ');
while (i + 1 < sql.length() && sql.charAt(i + 1) != '\n') i++;
} else if (ch == '/' && next == '*') {
visible.append(' ');
int end = sql.indexOf("*/", i + 2);
i = end < 0 ? sql.length() : end + 1;
} else if ((ch == 'q' || ch == 'Q') && next == '\'' && i + 2 < sql.length()) {
visible.append(' ');
char open = sql.charAt(i + 2);
char close = open == '[' ? ']' : open == '(' ? ')' : open == '{' ? '}' : open == '<' ? '>' : open;
int end = sql.indexOf("" + close + '\'', i + 3);
i = end < 0 ? sql.length() : end + 1;
} else if (ch == '\'' || ch == '"') {
visible.append(' ');
char quote = ch;
while (++i < sql.length()) {
if (sql.charAt(i) == quote) {
if (i + 1 < sql.length() && sql.charAt(i + 1) == quote) i++;
else break;
}
}
} else {
visible.append(ch);
if (ch == '?') count++;
}
}
return new SqlScan(count, visible.toString());
}
}
@@ -15,6 +15,9 @@ public final class ColumnInfo {
private Integer character_maximum_length;
private String character_set;
private String collation;
private String resolved_schema;
private String resolved_table;
private String resolved_object_type;
public ColumnInfo() {
this("", "", true, null, false);
@@ -128,6 +131,18 @@ public final class ColumnInfo {
return collation;
}
public String getResolved_schema() {
return resolved_schema;
}
public String getResolved_table() {
return resolved_table;
}
public String getResolved_object_type() {
return resolved_object_type;
}
public void setName(String name) {
this.name = name;
}
@@ -176,6 +191,18 @@ public final class ColumnInfo {
this.collation = collation;
}
public void setResolved_schema(String resolved_schema) {
this.resolved_schema = resolved_schema;
}
public void setResolved_table(String resolved_table) {
this.resolved_table = resolved_table;
}
public void setResolved_object_type(String resolved_object_type) {
this.resolved_object_type = resolved_object_type;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
@@ -192,7 +219,10 @@ public final class ColumnInfo {
&& Objects.equals(numeric_scale, that.numeric_scale)
&& Objects.equals(character_maximum_length, that.character_maximum_length)
&& Objects.equals(character_set, that.character_set)
&& Objects.equals(collation, that.collation);
&& Objects.equals(collation, that.collation)
&& Objects.equals(resolved_schema, that.resolved_schema)
&& Objects.equals(resolved_table, that.resolved_table)
&& Objects.equals(resolved_object_type, that.resolved_object_type);
}
@Override
@@ -209,7 +239,10 @@ public final class ColumnInfo {
numeric_scale,
character_maximum_length,
character_set,
collation
collation,
resolved_schema,
resolved_table,
resolved_object_type
);
}
@@ -227,6 +260,9 @@ public final class ColumnInfo {
+ ", character_maximum_length=" + character_maximum_length
+ ", character_set=" + character_set
+ ", collation=" + collation
+ ", resolved_schema=" + resolved_schema
+ ", resolved_table=" + resolved_table
+ ", resolved_object_type=" + resolved_object_type
+ ")";
}
}
@@ -9,6 +9,8 @@ public final class CompletionAssistantCandidate {
private final String parent_name;
private final String comment;
private final String data_type;
private final String signature;
private final String routine_id;
public CompletionAssistantCandidate(
String name,
@@ -19,6 +21,21 @@ public final class CompletionAssistantCandidate {
String parentName,
String comment,
String dataType
) {
this(name, kind, database, schema, parentSchema, parentName, comment, dataType, null, null);
}
public CompletionAssistantCandidate(
String name,
CompletionAssistantCandidateKind kind,
String database,
String schema,
String parentSchema,
String parentName,
String comment,
String dataType,
String signature,
String routineId
) {
this.name = name;
this.kind = kind;
@@ -28,6 +45,8 @@ public final class CompletionAssistantCandidate {
this.parent_name = parentName;
this.comment = comment;
this.data_type = dataType;
this.signature = signature;
this.routine_id = routineId;
}
public String getName() { return name; }
@@ -38,4 +57,6 @@ public final class CompletionAssistantCandidate {
public String getParent_name() { return parent_name; }
public String getComment() { return comment; }
public String getData_type() { return data_type; }
public String getSignature() { return signature; }
public String getRoutine_id() { return routine_id; }
}
@@ -6,11 +6,17 @@ public final class CompletionAssistantResponse {
private final List<CompletionAssistantCandidate> candidates;
private final boolean incomplete;
private final boolean fallback_used;
private final Boolean routine_search_supported;
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed) {
this(candidates, incomplete, fallbackUsed, null);
}
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed, Boolean routineSearchSupported) {
this.candidates = candidates;
this.incomplete = incomplete;
this.fallback_used = fallbackUsed;
this.routine_search_supported = routineSearchSupported;
}
public List<CompletionAssistantCandidate> getCandidates() { return candidates; }
@@ -14,6 +14,13 @@ public interface DatabaseAgent {
/** Opt in to request-boundary timing, including pooled connection lifecycle. */
default boolean supportsQueryTiming() { return false; }
default boolean supportsBlobBindStatements() { return false; }
default QueryResult executeBlobBoundStatements(List<String> previews, List<BlobBoundStatement> statements,
String schema, int timeoutSecs, boolean transaction) {
throw new UnsupportedOperationException("BLOB statement binding is not supported by this agent");
}
/** A dedicated manual transaction must never migrate to a replacement connection. */
default boolean permitsAutomaticReconnect() { return true; }
@@ -86,6 +93,10 @@ public interface DatabaseAgent {
List<ColumnInfo> getColumns(String schema, String table);
default List<ColumnInfo> getColumnsInContext(String schema, String table, String currentSchema) {
return getColumns(schema, table);
}
default ObjectSource getObjectSource(String schema, String name, String objectType) {
throw new UnsupportedOperationException("Object source is not supported");
}
@@ -204,6 +215,14 @@ public interface DatabaseAgent {
return AgentExecutionContext.jdbcExecutor().closeQuerySession(sessionId);
}
default Object readLargeValueChunk(String valueRef, long offset, int limit) {
throw new UnsupportedOperationException("LOB snapshot chunks are not supported by this agent");
}
default boolean releaseLargeValue(String valueRef) { return false; }
default void invalidateLargeValues() { }
default QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
Connection conn = getConnection();
if (conn == null) {
@@ -589,10 +589,12 @@ public final class JdbcExecutor {
Math.max(options.getMaxRows(), 1),
statementMessageReader
);
} else {
appendStatementWarnings(result, stmt);
}
activeStatements.remove(stmt);
stmt.close();
return new QueryPageResult(
QueryPageResult page = new QueryPageResult(
result.getColumns(),
result.getColumn_types(),
result.getRows(),
@@ -602,6 +604,8 @@ public final class JdbcExecutor {
null,
false
);
page.setMessages(result.getMessages());
return page;
}
phaseStarted = System.nanoTime();
@@ -926,8 +930,9 @@ public final class JdbcExecutor {
while (rows.size() < effectivePageSize && session.rowsRead < session.maxRows) {
if (!session.resultSet.next()) {
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, false, null, false);
closeSession(targetSessions, session.id);
return sessionPageResult(session, rows, executionTimeMs, false, null, false);
return page;
}
rows.add(rowValues(session.resultSet, session.valueReader, session.sqlTypeByIndex, session.typeNameByIndex));
session.rowsRead += 1;
@@ -935,14 +940,16 @@ public final class JdbcExecutor {
if (session.rowsRead >= session.maxRows) {
boolean truncated = session.resultSet.next();
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, truncated, null, false);
closeSession(targetSessions, session.id);
return sessionPageResult(session, rows, executionTimeMs, truncated, null, false);
return page;
}
boolean hasMore = session.resultSet.next();
if (!hasMore) {
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, false, null, false);
closeSession(targetSessions, session.id);
return sessionPageResult(session, rows, executionTimeMs, false, null, false);
return page;
}
session.pendingRow = rowValues(session.resultSet, session.valueReader, session.sqlTypeByIndex, session.typeNameByIndex);
@@ -960,6 +967,9 @@ public final class JdbcExecutor {
boolean hasMore
) {
QueryPageResult result = new QueryPageResult(session.columns, session.columnTypes, rows, 0L, executionTimeMs, truncated, sessionId, hasMore);
QueryResult messages = new QueryResult();
appendStatementWarnings(messages, session.statement);
result.setMessages(messages.getMessages());
result.setCursor_rows_read(session.rowsRead);
return result;
}
@@ -1066,6 +1076,7 @@ public final class JdbcExecutor {
StatementMessageReader statementMessageReader
) {
if (!result.getColumns().isEmpty() || !result.getRows().isEmpty()) {
appendStatementWarnings(result, stmt);
return result;
}
@@ -1082,6 +1093,7 @@ public final class JdbcExecutor {
break;
}
rows.add(Collections.singletonList(message));
result.addInformationalMessage(message, warning.getSQLState());
}
}
stmt.clearWarnings();
@@ -1102,6 +1114,7 @@ public final class JdbcExecutor {
break;
}
rows.add(Collections.singletonList(message));
result.addInformationalMessage(message, null);
}
}
} catch (Exception ignored) {
@@ -1111,7 +1124,7 @@ public final class JdbcExecutor {
if (rows.isEmpty()) {
return result;
}
return new QueryResult(
QueryResult messageResult = new QueryResult(
Collections.singletonList("Message"),
Collections.singletonList("nvarchar"),
rows,
@@ -1119,6 +1132,8 @@ public final class JdbcExecutor {
result.getExecution_time_ms(),
truncated
);
messageResult.setMessages(result.getMessages());
return messageResult;
}
private QueryResult emptyQueryResult(long start) {
@@ -1202,6 +1217,20 @@ public final class JdbcExecutor {
* agents to convert vendor-specific column types (e.g. PostGIS
* {@code geometry}) without re-querying the metadata.
*/
public <T> T withActiveStatement(Statement statement, SqlOperation<T> operation) throws SQLException {
activeStatements.add(statement);
try {
return operation.run();
} finally {
activeStatements.remove(statement);
}
}
@FunctionalInterface
public interface SqlOperation<T> {
T run() throws SQLException;
}
public interface ColumnAwareResultValueReader extends ResultValueReader {
Object read(ResultSet rs, int index, int sqlType, String columnTypeName) throws SQLException;
@@ -152,6 +152,7 @@ public final class JsonRpcServer {
void cancelActiveStatements() {
jdbcExecutor.cancelActiveStatements();
agent.invalidateLargeValues();
AbstractJdbcAgent jdbcAgent = pooledJdbcAgent();
if (jdbcAgent != null) {
jdbcAgent.releaseIdlePooledConnection(jdbcExecutor);
@@ -265,7 +266,9 @@ public final class JsonRpcServer {
}
if (AgentProtocol.METHOD_GET_COLUMNS.equals(method)) {
switchCatalog(params);
return agent.getColumns(params.get("schema").getAsString(), params.get("table").getAsString());
String currentSchema = params.has("current_schema") && !params.get("current_schema").isJsonNull()
? params.get("current_schema").getAsString() : null;
return agent.getColumnsInContext(params.get("schema").getAsString(), params.get("table").getAsString(), currentSchema);
}
if (AgentProtocol.METHOD_LIST_INDEXES.equals(method)) {
switchCatalog(params);
@@ -323,6 +326,13 @@ public final class JsonRpcServer {
if (AgentProtocol.METHOD_CLOSE_QUERY_SESSION.equals(method)) {
return agent.closeQuerySession(params.get("sessionId").getAsString());
}
if (AgentProtocol.METHOD_READ_LARGE_VALUE_CHUNK.equals(method)) {
return agent.readLargeValueChunk(params.get("valueRef").getAsString(),
params.get("offset").getAsLong(), intOrDefault(params, "limit", 4096));
}
if (AgentProtocol.METHOD_RELEASE_LARGE_VALUE.equals(method)) {
return agent.releaseLargeValue(params.get("valueRef").getAsString());
}
if (AgentProtocol.METHOD_START_TABLE_READ.equals(method)) {
return agent.startTableRead(
params.get("sql").getAsString(),
@@ -359,22 +369,33 @@ public final class JsonRpcServer {
return result;
}
if (AgentProtocol.METHOD_EXECUTE_TRANSACTION.equals(method)) {
agent.invalidateLargeValues();
Type statementsType = new TypeToken<List<String>>() {}.getType();
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
if (params.has("boundStatements")) {
return executeBlobBoundStatements(params, statements, true);
}
return agent.executeTransaction(statements, stringOrNull(params, "schema"));
}
if (AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION.equals(method)) {
agent.invalidateLargeValues();
return agent.beginManualTransaction(stringOrNull(params, "schema"));
}
if (AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION.equals(method)) {
agent.invalidateLargeValues();
return agent.commitManualTransaction();
}
if (AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION.equals(method)) {
agent.invalidateLargeValues();
return agent.rollbackManualTransaction();
}
if (AgentProtocol.METHOD_EXECUTE_BATCH.equals(method)) {
agent.invalidateLargeValues();
Type statementsType = new TypeToken<List<String>>() {}.getType();
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
if (params.has("boundStatements")) {
return executeBlobBoundStatements(params, statements, false);
}
return agent.executeBatch(statements, stringOrNull(params, "schema"));
}
if (AgentProtocol.METHOD_DISCONNECT.equals(method)) {
@@ -394,6 +415,38 @@ public final class JsonRpcServer {
throw new IllegalArgumentException("Unknown method: " + method);
}
private QueryResult executeBlobBoundStatements(JsonObject params, List<String> previews, boolean transaction) {
if (!agent.supportsBlobBindStatements()) {
throw new UnsupportedOperationException("Agent does not support blob_bind_statements_v1");
}
if (!params.get("boundStatements").isJsonArray()) {
throw new IllegalArgumentException("BLOB bound statements must be an array");
}
for (com.google.gson.JsonElement entry : params.getAsJsonArray("boundStatements")) {
if (!entry.isJsonObject()) throw new IllegalArgumentException("BLOB bound statement must be an object");
JsonObject item = entry.getAsJsonObject();
for (String field : java.util.Arrays.asList("previewSql", "sql")) {
if (!item.has(field) || !item.get(field).isJsonPrimitive()
|| !item.get(field).getAsJsonPrimitive().isString()) {
throw new IllegalArgumentException("BLOB bound statement text fields must be strings");
}
}
if (!item.has("blobParameters") || !item.get("blobParameters").isJsonArray()) {
throw new IllegalArgumentException("BLOB parameters must be an array of hexadecimal strings");
}
for (com.google.gson.JsonElement value : item.getAsJsonArray("blobParameters")) {
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
throw new IllegalArgumentException("BLOB parameter must be a hexadecimal string");
}
}
}
Type boundType = new TypeToken<List<BlobBoundStatement>>() {}.getType();
List<BlobBoundStatement> statements = gson.fromJson(params.get("boundStatements"), boundType);
BlobBoundStatement.validate(previews, statements);
return agent.executeBlobBoundStatements(previews, statements, stringOrNull(params, "schema"),
intOrDefault(params, "queryTimeoutSecs", 0), transaction);
}
private void switchCatalog(JsonObject params) throws Exception {
String database = stringOrNull(params, "database");
if (database != null && !database.trim().isEmpty() && agent.getConnection() != null) {
@@ -45,6 +45,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
private final Object maintenanceLock = new Object();
private final AtomicBoolean closed = new AtomicBoolean();
private ScheduledExecutorService maintenance;
private boolean blobBindStatements;
public MultiSessionJsonRpcServer(Supplier<? extends DatabaseAgent> agentFactory) {
this(agentFactory, new JdbcConnectionPoolRegistry(), RuntimeLimits.defaults());
@@ -91,6 +92,12 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
return new MultiSessionJsonRpcServer(sessionHandlerFactory, true);
}
public static MultiSessionJsonRpcServer withBlobBindings(Supplier<? extends DatabaseAgent> agentFactory) {
MultiSessionJsonRpcServer server = new MultiSessionJsonRpcServer(agentFactory);
server.blobBindStatements = true;
return server;
}
public void run() {
synchronized (outputLock) {
protocolOutput.println("{\"ready\":true}");
@@ -138,7 +145,9 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
try {
Object result;
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
result = sessionHandlerFactory == null ? AgentProtocol.multiSessionJdbcHandshakeResult() : customHandshake();
result = sessionHandlerFactory == null
? AgentProtocol.multiSessionJdbcHandshakeResult(blobBindStatements)
: customHandshake();
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
result = openSession(requiredSessionId(params), params);
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
@@ -8,6 +8,7 @@ public final class ObjectInfo {
private String schema;
private String comment;
private Boolean valid;
private String parent_name;
public ObjectInfo() {
this("", "", null, null, null);
@@ -69,6 +70,14 @@ public final class ObjectInfo {
this.valid = valid;
}
public String getParent_name() {
return parent_name;
}
public void setParent_name(String parentName) {
this.parent_name = parentName;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
@@ -78,12 +87,13 @@ public final class ObjectInfo {
&& Objects.equals(object_type, that.object_type)
&& Objects.equals(schema, that.schema)
&& Objects.equals(comment, that.comment)
&& Objects.equals(parent_name, that.parent_name)
&& Objects.equals(valid, that.valid);
}
@Override
public int hashCode() {
return Objects.hash(name, object_type, schema, comment, valid);
return Objects.hash(name, object_type, schema, comment, valid, parent_name);
}
@Override
@@ -19,6 +19,15 @@ public final class QueryPageResult {
private boolean truncated;
private String session_id;
private boolean has_more;
private List<java.util.Map<String, Object>> messages;
public List<java.util.Map<String, Object>> getMessages() {
return messages == null ? Collections.emptyList() : messages;
}
public void setMessages(List<java.util.Map<String, Object>> messages) {
this.messages = messages;
}
public QueryPageResult() {
this(Collections.emptyList(), Collections.emptyList(), 0L, 0L, false, null, false);
@@ -27,6 +27,10 @@ public final class QueryResult {
return messages == null ? Collections.emptyList() : messages;
}
public void setMessages(List<Map<String, Object>> messages) {
this.messages = messages;
}
public void addInformationalMessage(String message, String code) {
if (messages == null) messages = new ArrayList<>();
if (messages.size() >= 512) return;
@@ -32,6 +32,8 @@
"execute_query_page",
"fetch_query_page",
"close_query_session",
"read_large_value_chunk",
"release_large_value",
"start_table_read",
"fetch_table_read_page",
"close_table_read_session",
@@ -2,7 +2,7 @@
"protocolVersion": 2,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor", "multi_session", "structured_error_v1"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor", "multi_session", "structured_error_v1", "blob_bind_statements_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": [
@@ -36,6 +36,8 @@
"execute_query_page",
"fetch_query_page",
"close_query_session",
"read_large_value_chunk",
"release_large_value",
"start_table_read",
"fetch_table_read_page",
"close_table_read_session",
@@ -51,5 +53,15 @@
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "bulk_write", "rename_collection", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"],
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"],
"sessionField": "agentSessionId",
"cursorSessionField": "sessionId"
"cursorSessionField": "sessionId",
"blobBindStatementsV1": {
"capability": "blob_bind_statements_v1",
"methods": ["execute_batch", "execute_transaction"],
"optionalFields": ["boundStatements", "queryTimeoutSecs"],
"statementFields": ["previewSql", "sql", "blobParameters"],
"blobEncoding": "even-length hexadecimal without 0x",
"previewRule": "complete array length and each previewSql must exactly match statements; unbound sql equals previewSql",
"manualTransaction": "same connection, per-batch savepoint, no implicit commit; rollback to savepoint on failure",
"unsupported": "reject complete request before executing any statement; no SQL-text fallback"
}
}
@@ -0,0 +1,322 @@
package com.dbx.agent;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.CallableStatement;
import java.sql.Savepoint;
import java.sql.SQLTimeoutException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class BlobBoundExecutorTest {
private static final String PREVIEW = "UPDATE t SET b=HEXTORAW('00ff80')";
private static final String SQL = "BEGIN UPDATE t SET b=? WHERE DBMS_LOB.COMPARE(b,?)=0; END;";
private static BlobBoundStatement bound() { return new BlobBoundStatement(PREVIEW, SQL, Arrays.asList("00ff80", "cafe")); }
@Test void bindsStreamsOnFixedCallableSqlAndClosesThemBeforeCommit() throws Exception {
Fake jdbc = new Fake();
QueryResult result = run(jdbc, Arrays.asList(bound()), true);
assertEquals(SQL, jdbc.preparedSql);
assertEquals(Arrays.asList("00ff80", "cafe"), jdbc.boundHex);
assertEquals(Arrays.asList(3L, 2L), jdbc.lengths);
assertEquals(7, jdbc.timeout);
assertEquals(1, jdbc.closedStatements);
assertTrue(jdbc.events.indexOf("close") < jdbc.events.indexOf("commit"));
assertEquals(1, jdbc.commits);
assertEquals(0, jdbc.rollbacks);
assertTrue(jdbc.autoCommit);
assertEquals(1, result.getAffected_rows());
for (InputStream stream : jdbc.streams) assertThrows(java.io.IOException.class, stream::read);
}
@Test void piecewiseDriverSeesRemainingBytesUntilEntireHexStreamIsSent() throws Exception {
Fake jdbc=new Fake(); jdbc.piecewiseRead=true;
String hex="00ff80".repeat(40000);
BlobBoundStatement statement=new BlobBoundStatement(PREVIEW,"UPDATE t SET b=?",Arrays.asList(hex));
run(jdbc,Arrays.asList(statement),false);
assertEquals(Arrays.asList(hex),jdbc.boundHex);
assertEquals(4,jdbc.pieces);
assertEquals(0,jdbc.streams.get(0).available());
}
@Test void timeoutRollsBackClosesStreamsAndNeverFallsBackToText() throws Exception {
Fake jdbc = new Fake(); jdbc.fail = true;
RuntimeException failure = assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound()), true));
assertInstanceOf(SQLTimeoutException.class, failure.getCause());
assertEquals(1, jdbc.executions); assertEquals(1, jdbc.rollbacks); assertEquals(0, jdbc.commits);
assertEquals(1, jdbc.closedStatements); assertTrue(jdbc.autoCommit);
for (InputStream stream : jdbc.streams) assertThrows(java.io.IOException.class, stream::read);
}
@Test void manualFailureRollsBackOnlyBatchSavepoint() {
Fake jdbc = new Fake(); jdbc.autoCommit = false; jdbc.fail = true;
assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound()), false));
assertEquals(1, jdbc.savepointRollbacks); assertEquals(0, jdbc.rollbacks);
assertEquals(0, jdbc.commits); assertFalse(jdbc.autoCommit); assertEquals(1, jdbc.releases);
}
@Test void manualSuccessDoesNotCommitOrChangeAutoCommit() {
Fake jdbc = new Fake(); jdbc.autoCommit = false;
run(jdbc, Arrays.asList(bound()), false);
assertEquals(1, jdbc.savepoints); assertEquals(1, jdbc.releases);
assertEquals(0, jdbc.commits); assertEquals(0, jdbc.rollbacks); assertFalse(jdbc.autoCommit);
}
@Test void laterManualConflictRollsBackFirstWriteButPreservesPriorTransactionWork() {
Fake jdbc = new Fake(); jdbc.autoCommit=false; jdbc.failAt=2;
assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound(),bound()), false));
assertEquals(2,jdbc.executions); assertEquals(42,jdbc.value);
assertEquals(1,jdbc.savepointRollbacks); assertEquals(0,jdbc.rollbacks);
assertEquals(0,jdbc.commits); assertFalse(jdbc.autoCommit);
}
@Test void failedManualRollbackQuarantinesConnectionWithUnknownOutcome() {
Fake jdbc = new Fake(); jdbc.autoCommit=false; jdbc.failAt=1; jdbc.failRollback=true;
RuntimeException failure=assertThrows(RuntimeException.class,()->run(jdbc,Arrays.asList(bound()),false));
assertTrue(jdbc.connectionClosed); assertEquals(0,jdbc.commits);
com.google.gson.JsonObject data=AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data");
assertEquals("quarantine",data.get("sessionDisposition").getAsString());
assertEquals("unknown",data.get("operationOutcome").getAsString());
assertEquals("connection",data.get("category").getAsString());
assertEquals("08007",data.get("sqlState").getAsString());
}
@Test void confirmedObBusinessErrorKeepsPriorWorkButRollbackFailureStillQuarantines() {
for (int cleanupFailure : new int[]{0,1,2}) {
boolean cleanupFails=cleanupFailure!=0;
Fake jdbc=new Fake(); jdbc.autoCommit=false; jdbc.failAt=2; jdbc.failRollback=cleanupFailure==1; jdbc.failRelease=cleanupFailure==2;
jdbc.executionFailure=new java.sql.SQLTransientConnectionException("ORA-20001: stale target", "HY000", 20001);
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
public void configure(PreparedStatement statement) { }
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
};
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer));
var data=AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data");
assertEquals(cleanupFails?"quarantine":"keep",data.get("sessionDisposition").getAsString());
assertEquals(cleanupFails?"connection":"sql",data.get("category").getAsString());
assertEquals("unknown",data.get("operationOutcome").getAsString());
assertEquals(0,jdbc.commits); assertEquals(0,jdbc.rollbacks);
if(!cleanupFails) { assertEquals(42,jdbc.value); assertEquals(1,jdbc.savepointRollbacks); assertFalse(jdbc.connectionClosed); }
else { assertTrue(jdbc.connectionClosed); assertEquals("08007",data.get("sqlState").getAsString()); }
}
}
@Test void exactUnsupportedReleaseDoesNotCommitOrDiscardSuccessfulOrRolledBackManualBatch() {
for(boolean conflict : new boolean[]{false,true}) {
Fake jdbc=new Fake(); jdbc.autoCommit=false; jdbc.failRelease=true;
jdbc.releaseFailure=new java.sql.SQLException("releaseSavepoint is not supported","99999",17023);
if(conflict) { jdbc.failAt=2; jdbc.executionFailure=new java.sql.SQLTransientConnectionException("ORA-20001: stale", "HY000",20001); }
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
public void configure(PreparedStatement statement) { }
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
public boolean isUnsupportedSavepointRelease(java.sql.SQLException error) { return error.getErrorCode()==17023 && "99999".equals(error.getSQLState()); }
};
if(conflict) {
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer));
assertEquals("keep",AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data").get("sessionDisposition").getAsString());
assertEquals(42,jdbc.value); assertEquals(1,jdbc.savepointRollbacks);
} else {
BlobBoundExecutor.execute(jdbc.connection(),Arrays.asList(PREVIEW),Arrays.asList(bound()),null,s->s,()->null,7,false,configurer);
assertEquals(43,jdbc.value); assertEquals(0,jdbc.savepointRollbacks);
}
assertEquals(0,jdbc.commits); assertFalse(jdbc.autoCommit); assertFalse(jdbc.connectionClosed);
}
}
@Test void defaultTypedBatchCommitsOrRollsBackBeforeRestoringAutoCommit() {
for(boolean conflict : new boolean[]{false,true}) {
Fake jdbc=new Fake();
if(conflict) { jdbc.failAt=2; jdbc.executionFailure=new java.sql.SQLTransientConnectionException("ORA-20001: stale", "HY000",20001); }
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
public void configure(PreparedStatement statement) { }
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
};
if(conflict) {
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer));
assertEquals("keep",AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data").get("sessionDisposition").getAsString());
assertEquals(42,jdbc.value); assertEquals(1,jdbc.rollbacks); assertEquals(0,jdbc.commits);
} else {
BlobBoundExecutor.execute(jdbc.connection(),Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer);
assertEquals(44,jdbc.value); assertEquals(1,jdbc.commits); assertEquals(0,jdbc.rollbacks);
}
assertTrue(jdbc.autoCommit); assertEquals(0,jdbc.savepoints);
}
Fake manual=new Fake(); manual.autoCommit=false;
assertThrows(IllegalStateException.class,()->run(manual,Arrays.asList(bound()),true));
assertEquals(0,manual.executions); assertFalse(manual.autoCommit);
}
@Test void typedDdlOrTransactionControlRejectsCompleteBatchBeforeOpeningConnection() {
for(String sql : List.of("CREATE TABLE t (b BLOB)","BEGIN UPDATE t SET b=?; COMMIT; END;", "BEGIN EXECUTE IMMEDIATE 'DROP TABLE t'; END;")) {
Fake jdbc=new Fake();
BlobBoundStatement invalid=new BlobBoundStatement(sql,sql,sql.contains("?")?List.of("00"):List.of());
assertThrows(IllegalArgumentException.class,()->run(jdbc,Arrays.asList(bound(),invalid),false));
assertEquals(0,jdbc.executions); assertNull(jdbc.preparedSql); assertTrue(jdbc.autoCommit);
}
BlobBoundStatement.validate(List.of(PREVIEW),List.of(new BlobBoundStatement(PREVIEW,"UPDATE t SET b=? /* COMMIT */ WHERE note='CREATE TABLE x'",List.of("00"))));
}
@Test void defaultTypedCommitRollbackOrResetFailuresNeverBecomeKnownBusinessKeep() {
for(String stage : List.of("commit","rollback","reset")) {
Fake jdbc=new Fake(); jdbc.failCommit=stage.equals("commit"); jdbc.failReset=stage.equals("reset");
if(stage.equals("rollback")) { jdbc.failAt=1; jdbc.failRollback=true; jdbc.executionFailure=new java.sql.SQLTransientConnectionException("stale", "HY000",20001); }
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
public void configure(PreparedStatement statement) { }
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
};
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
Arrays.asList(PREVIEW),Arrays.asList(bound()),null,s->s,()->null,7,false,configurer));
var data=AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data");
assertEquals("quarantine",data.get("sessionDisposition").getAsString());
assertEquals("unknown",data.get("operationOutcome").getAsString());
}
}
@Test void unsupportedSavepointFailsBeforeAnyWrite() {
Fake jdbc = new Fake(); jdbc.autoCommit = false; jdbc.unsupportedSavepoint = true;
assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound()), false));
assertEquals(0, jdbc.executions); assertEquals(0, jdbc.commits); assertEquals(0, jdbc.rollbacks);
}
@Test void rejectsInvalidLaterBindingBeforeOpeningAnyStatement() {
Fake jdbc = new Fake();
assertThrows(IllegalArgumentException.class, () -> run(jdbc,
Arrays.asList(bound(), new BlobBoundStatement(PREVIEW, SQL, Arrays.asList("0x00"))), true));
assertEquals(0, jdbc.executions); assertNull(jdbc.preparedSql); assertTrue(jdbc.autoCommit);
assertThrows(IllegalArgumentException.class, () -> BlobBoundStatement.validate(Arrays.asList("edited"), Arrays.asList(bound())));
}
@Test void cancellationTracksStatementAndPreventsNextStatement() {
Fake jdbc = new Fake(); jdbc.cancel = true;
assertThrows(java.util.concurrent.CancellationException.class, () -> run(jdbc, Arrays.asList(bound(), bound()), true));
assertEquals(2, jdbc.cancels); assertEquals(1, jdbc.executions);
assertEquals(1, jdbc.rollbacks); assertEquals(2, jdbc.closedStatements);
}
@Test void invalidLaterParameterCountFailsBeforeAnyWriteAndIgnoresQuotedMarkers() {
Fake jdbc=new Fake();
assertThrows(IllegalArgumentException.class,()->run(jdbc,Arrays.asList(bound(),new BlobBoundStatement(PREVIEW,SQL,Arrays.asList("cafe"))),true));
assertEquals(0,jdbc.executions); assertNull(jdbc.preparedSql);
BlobBoundStatement.validate(Arrays.asList(PREVIEW),Arrays.asList(new BlobBoundStatement(PREVIEW,
"BEGIN UPDATE t SET b=?; x:='?''?'; x:=q'[?]'; -- ?\n/* ? */ END;",Arrays.asList("cafe"))));
}
@Test void legacyAgentRejectsBindingBeforeCallingLegacyBatch() {
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(), new Class<?>[]{DatabaseAgent.class},
(p, m, a) -> { if (m.getName().equals("supportsBlobBindStatements")) return false;
if (m.getName().equals("getConnection")) return new Fake().connection();
if (m.getName().startsWith("execute")) throw new AssertionError("Must not execute unsupported payload"); return defaultValue(m.getReturnType()); });
String response = new JsonRpcServer(agent).handleRequest("{\"id\":1,\"method\":\"execute_batch\",\"params\":{\"statements\":[],\"boundStatements\":[]}}");
assertTrue(JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error").get("message").getAsString().contains("blob_bind_statements_v1"), response);
}
@Test void onlyOptedInServerAdvertisesBindingCapability() {
com.google.gson.Gson gson = new com.google.gson.Gson();
assertFalse(gson.toJson(AgentProtocol.multiSessionJdbcHandshakeResult()).contains("blob_bind_statements_v1"));
assertTrue(gson.toJson(AgentProtocol.multiSessionJdbcHandshakeResult(true)).contains("blob_bind_statements_v1"));
}
@Test void laterSetterFailureOccursBeforeAnyWriteAndClosesAllResources() {
Fake jdbc=new Fake(); jdbc.failBindAt=3;
assertThrows(RuntimeException.class,()->run(jdbc,Arrays.asList(bound(),bound()),true));
assertEquals(0,jdbc.executions); assertEquals(1,jdbc.rollbacks); assertEquals(2,jdbc.closedStatements);
for(InputStream stream:jdbc.streams)assertThrows(java.io.IOException.class,stream::read);
}
@Test void cancellationDuringResourceCloseStillPreventsCommit() {
Fake jdbc=new Fake();jdbc.cancelOnClose=true;
assertThrows(java.util.concurrent.CancellationException.class,()->run(jdbc,Arrays.asList(bound()),true));
assertEquals(1,jdbc.executions);assertEquals(0,jdbc.commits);assertEquals(1,jdbc.rollbacks);
}
@Test void connectionResetAfterCommitReportsUnknownInsteadOfClaimingNoWrite() {
Fake jdbc=new Fake();jdbc.failReset=true;
RuntimeException failure=assertThrows(RuntimeException.class,()->run(jdbc,Arrays.asList(bound()),true));
assertEquals(1,jdbc.commits);assertTrue(jdbc.connectionClosed);
com.google.gson.JsonObject data=AgentRpcError.toJson(failure,"execute_transaction","test").getAsJsonObject("data");
assertEquals("quarantine",data.get("sessionDisposition").getAsString());
assertEquals("unknown",data.get("operationOutcome").getAsString());
}
@Test void rpcRejectsNonStringHexRatherThanGsonCoercingIt() {
DatabaseAgent agent=(DatabaseAgent)Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),new Class<?>[]{DatabaseAgent.class},
(p,m,a)->{if(m.getName().equals("supportsBlobBindStatements"))return true;
if(m.getName().equals("getConnection"))return new Fake().connection();
if(m.getName().startsWith("execute"))throw new AssertionError("Malformed payload must not execute");return defaultValue(m.getReturnType());});
String response=new JsonRpcServer(agent).handleRequest("{\"id\":2,\"method\":\"execute_transaction\",\"params\":{\"statements\":[\"preview\"],\"boundStatements\":[{\"previewSql\":\"preview\",\"sql\":\"BEGIN x:=?; END;\",\"blobParameters\":[12]}]}}");
assertTrue(response.contains("hexadecimal string"),response);
}
private static QueryResult run(Fake jdbc, List<BlobBoundStatement> statements, boolean transaction) {
List<String> previews = new ArrayList<>(); for (BlobBoundStatement ignored : statements) previews.add(PREVIEW);
return BlobBoundExecutor.execute(jdbc.connection(), previews, statements, null, schema -> null, () -> "", 7, transaction);
}
private static final class Fake {
boolean autoCommit = true, fail, cancel, unsupportedSavepoint, failRollback, connectionClosed, failReset, cancelOnClose, piecewiseRead, failRelease, failCommit;
int pieces;
int value=42,savedValue=42,transactionStartValue=42,failAt,failBindAt;
int commits, rollbacks, savepointRollbacks, savepoints, releases, executions, closedStatements, cancels, timeout;
String preparedSql;
java.sql.SQLException executionFailure;
java.sql.SQLException releaseFailure;
List<String> events = new ArrayList<>(), boundHex = new ArrayList<>();
List<Long> lengths = new ArrayList<>(); List<InputStream> streams = new ArrayList<>();
Connection connection() {
return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[]{Connection.class}, (p,m,a) -> {
switch (m.getName()) {
case "getAutoCommit": return autoCommit;
case "isValid": return true;
case "close": connectionClosed=true; return null;
case "setAutoCommit": if(failReset && (boolean)a[0])throw new java.sql.SQLException("reset failed"); if(autoCommit && !(boolean)a[0])transactionStartValue=value; autoCommit = (boolean)a[0]; return null;
case "getMetaData": return Proxy.newProxyInstance(DatabaseMetaData.class.getClassLoader(), new Class<?>[]{DatabaseMetaData.class}, (x,y,z) -> y.getName().equals("supportsTransactions") ? true : defaultValue(y.getReturnType()));
case "setSavepoint": if (unsupportedSavepoint) throw new java.sql.SQLFeatureNotSupportedException(); savepoints++; savedValue=value; return Proxy.newProxyInstance(Savepoint.class.getClassLoader(),new Class<?>[]{Savepoint.class},(x,y,z)->defaultValue(y.getReturnType()));
case "releaseSavepoint": releases++; if(failRelease)throw releaseFailure!=null?releaseFailure:new java.sql.SQLTransientConnectionException("release lost connection","08006"); return null;
case "commit": commits++; events.add("commit"); if(failCommit)throw new java.sql.SQLTransientConnectionException("commit outcome unknown","HY000",20001); return null;
case "rollback": if(failRollback)throw new java.sql.SQLException("rollback lost connection","08006"); if (a == null || a.length == 0) {rollbacks++;value=transactionStartValue;} else {savepointRollbacks++;value=savedValue;} return null;
case "prepareCall": case "prepareStatement": preparedSql=(String)a[0]; return statement(m.getName().equals("prepareCall"));
default: return defaultValue(m.getReturnType());
}
});
}
PreparedStatement statement(boolean callable) {
Class<?> type = callable ? CallableStatement.class : PreparedStatement.class;
return (PreparedStatement) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, (p,m,a) -> {
switch (m.getName()) {
case "setQueryTimeout": timeout=(int)a[0]; return null;
case "setBlob": assertInstanceOf(InputStream.class,a[1]); assertInstanceOf(Long.class,a[2]);
InputStream stream=(InputStream)a[1]; streams.add(stream); lengths.add((Long)a[2]);
if(streams.size()==failBindAt)throw new java.sql.SQLException("binding failed");
byte[] bytes;
if(piecewiseRead) {
java.io.ByteArrayOutputStream sent=new java.io.ByteArrayOutputStream(); byte[] buffer=new byte[32768];
int read;
while((read=stream.read(buffer))!=-1) { sent.write(buffer,0,read); pieces++; if(stream.available()==0)break; }
bytes=sent.toByteArray();
} else bytes=stream.readAllBytes();
StringBuilder hex=new StringBuilder(); for(byte b:bytes)hex.append(String.format("%02x",b&255)); boundHex.add(hex.toString()); return null;
case "execute": executions++;value++; if(fail)throw new SQLTimeoutException("timed out","HYT00"); if(executions==failAt)throw executionFailure!=null?executionFailure:new java.sql.SQLException("stale target","23000"); if(cancel)JdbcExecutor.current().cancelActiveStatements(); return false;
case "getUpdateCount": return 1;
case "cancel": cancels++; return null;
case "close": closedStatements++; events.add("close"); if(cancelOnClose)JdbcExecutor.current().cancelActiveStatements(); return null;
case "hashCode": return System.identityHashCode(p);
case "equals": return p==a[0];
default: return defaultValue(m.getReturnType());
}
});
}
}
private static Object defaultValue(Class<?> type) {
if(type==boolean.class)return false;if(type==int.class)return 0;if(type==long.class)return 0L;return null;
}
}
@@ -37,6 +37,86 @@ import static org.junit.jupiter.api.Assertions.fail;
class JdbcConnectionPoolingTest {
private static final Gson GSON = new Gson();
@Test
void blobRollbackFailureCannotReturnDirtyConnectionToAnotherSession() throws Exception {
for (boolean manual : List.of(false, true)) {
AtomicInteger physicalOpens = new AtomicInteger();
CountDownLatch batchFinished = new CountDownLatch(1);
CountDownLatch finishRequest = new CountDownLatch(1);
ExecutorService worker = Executors.newSingleThreadExecutor();
String url = h2Url("blob_rollback");
try (Connection setup = DriverManager.getConnection(url, "sa", "");
Statement statement = setup.createStatement();
JdbcConnectionPoolRegistry registry = new JdbcConnectionPoolRegistry(poolSettings(2))) {
statement.execute("CREATE TABLE rollback_probe (id INT)");
List<JsonRpcServer> sessions = new java.util.ArrayList<>();
for (int index = 0; index < 2; index++) {
H2TestAgent agent = new H2TestAgent(url, physicalOpens) {
@Override public boolean supportsBlobBindStatements() { return true; }
@Override protected Connection openConnection(ConnectParams params) throws Exception {
Connection physical = openH2(url, physicalOpens);
if (physicalOpens.get() != 1) return physical;
return (Connection) Proxy.newProxyInstance(getClass().getClassLoader(),
new Class<?>[]{Connection.class}, (proxy, method, args) -> {
if (method.getName().equals("rollback")) {
throw new SQLException("Rollback was not acknowledged", "HY000");
}
try { return method.invoke(physical, args); }
catch (InvocationTargetException error) { throw error.getCause(); }
});
}
@Override public QueryResult executeBlobBoundStatements(List<String> previews,
List<BlobBoundStatement> statements, String schema, int timeoutSecs, boolean transaction) {
try {
return BlobBoundExecutor.execute(requireConnected(), previews, statements,
schema, value -> null, () -> null, timeoutSecs, transaction,
new BlobBoundExecutor.PreparedStatementConfigurer() {
@Override public void configure(java.sql.PreparedStatement statement) { }
@Override public void discardConnection(Connection connection) throws SQLException {
agentDiscard(connection);
}
});
} finally {
batchFinished.countDown();
awaitUninterruptibly(finishRequest);
}
}
private void agentDiscard(Connection connection) throws SQLException {
discardConnection(connection);
}
};
agent.attachConnectionPoolRegistry(registry);
JsonRpcServer server = new JsonRpcServer(agent);
server.dispatchForRuntime(AgentProtocol.METHOD_CONNECT,
JsonParser.parseString("{\"database\":\"pooling\",\"username\":\"sa\",\"password\":\"\"}").getAsJsonObject());
sessions.add(server);
}
if (manual) sessions.get(0).dispatchForRuntime(AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION, new JsonObject());
List<String> sql = List.of("INSERT INTO rollback_probe VALUES (1)", "INSERT INTO missing_table VALUES (2)");
JsonObject request = new JsonObject();
request.add("statements", GSON.toJsonTree(sql));
request.add("boundStatements", GSON.toJsonTree(sql.stream()
.map(value -> new BlobBoundStatement(value, value, List.of())).toList()));
Future<Exception> failure = worker.submit(() -> assertThrows(Exception.class,
() -> sessions.get(0).dispatchForRuntime(AgentProtocol.METHOD_EXECUTE_BATCH, request)));
try {
assertTrue(batchFinished.await(3, TimeUnit.SECONDS));
QueryResult result = (QueryResult) sessions.get(1).dispatchForRuntime(AgentProtocol.METHOD_EXECUTE_QUERY,
JsonParser.parseString("{\"sql\":\"SELECT COUNT(*) FROM rollback_probe\"}").getAsJsonObject());
assertEquals(0L, ((Number) result.getRows().get(0).get(0)).longValue(), "manual=" + manual);
assertEquals(2, physicalOpens.get());
} finally { finishRequest.countDown(); }
assertTrue(failure.get(3, TimeUnit.SECONDS).toString().contains("rollback was not confirmed"));
} finally {
finishRequest.countDown();
worker.shutdownNow();
}
}
}
@Test
void registryReusesOnePhysicalConnectionAcrossSequentialLeases() throws Exception {
AtomicInteger physicalOpens = new AtomicInteger();
@@ -300,7 +300,7 @@ class JdbcExecutorTest {
}
@Test
void executePageKeepsWarningsHiddenWithoutADriverMessageReader() {
void executePageRetainsWarningsWithoutReplacingTheResult() {
QueryPageResult result = JdbcExecutor.INSTANCE.executePage(
executionConnection(
false,
@@ -318,6 +318,7 @@ class JdbcExecutorTest {
assertEquals(Collections.emptyList(), result.getColumns());
assertEquals(Collections.emptyList(), result.getRows());
assertEquals("existing paged warning", result.getMessages().get(0).get("message"));
}
@Test
@@ -332,6 +333,23 @@ class JdbcExecutorTest {
assertEquals(Arrays.asList("id", "name"), result.getColumns());
assertEquals(Arrays.asList(Arrays.asList(1, "Ada")), result.getRows());
assertEquals("informational", result.getMessages().get(0).get("message"));
}
@Test
void executePageRetainsWarningsWithOrdinaryRows() {
CountingResultSetFixture fixture = countingResultSet(new Object[][]{{1, "Ada"}});
QueryPageResult result = JdbcExecutor.INSTANCE.executePage(
executionConnection(true, -1, new SQLWarning("notice with rows"), new AtomicInteger(), fixture.resultSet(), null),
"SELECT id, name FROM people",
"",
schema -> "",
new QueryPageOptions()
);
assertEquals(Arrays.asList("id", "name"), result.getColumns());
assertEquals(Arrays.asList(Arrays.asList(1, "Ada")), result.getRows());
assertEquals("notice with rows", result.getMessages().get(0).get("message"));
}
@Test
@@ -0,0 +1,75 @@
package com.dbx.agent;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
class JsonRpcLargeValueTest {
@Test
void dispatchesOpaqueLocatorAndCharacterOffsetAndReleasesItWithoutSql() throws Exception {
List<String> calls = new ArrayList<>();
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),
new Class<?>[]{DatabaseAgent.class}, (object, method, args) -> {
if (method.getName().equals("supportsQueryTiming")) return false;
if (method.getName().equals("permitsAutomaticReconnect")) return false;
if (method.getName().equals("readLargeValueChunk")) {
calls.add("read");
assertArrayEquals(new Object[]{"opaque-original", 700L, 4096}, args);
return Map.of("status", "ok", "data", "中文😀", "next_offset", 703L, "eof", false, "value_kind", "text");
}
if (method.getName().equals("releaseLargeValue")) { calls.add("release"); assertEquals("opaque-original", args[0]); return true; }
return null;
});
JsonRpcServer server = new JsonRpcServer(agent);
Object result = server.dispatchForRuntime(AgentProtocol.METHOD_READ_LARGE_VALUE_CHUNK,
JsonParser.parseString("{\"valueRef\":\"opaque-original\",\"offset\":700,\"limit\":4096}").getAsJsonObject());
assertEquals("中文😀", ((Map<?, ?>) result).get("data"));
assertEquals(true, server.dispatchForRuntime(AgentProtocol.METHOD_RELEASE_LARGE_VALUE,
JsonParser.parseString("{\"valueRef\":\"opaque-original\"}").getAsJsonObject()));
assertEquals(List.of("read", "release"), calls);
}
@Test
void invalidatesLocatorsBeforeTransactionBoundary() throws Exception {
List<String> calls = new ArrayList<>();
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),
new Class<?>[]{DatabaseAgent.class}, (object, method, args) -> {
if (method.getName().equals("supportsQueryTiming")) return false;
if (method.getName().equals("permitsAutomaticReconnect")) return false;
if (method.getName().equals("invalidateLargeValues")) { calls.add("invalidate"); return null; }
if (method.getName().equals("commitManualTransaction")) { calls.add("commit"); return Map.of("ok", true); }
return null;
});
new JsonRpcServer(agent).dispatchForRuntime(AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION,
JsonParser.parseString("{}").getAsJsonObject());
assertEquals(List.of("invalidate", "commit"), calls);
}
@Test
void invalidatesBeforeFailedWriteAndRollbackAndOnCancellation() throws Exception {
List<String> calls = new ArrayList<>();
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),
new Class<?>[]{DatabaseAgent.class}, (object, method, args) -> {
if (method.getName().equals("supportsQueryTiming")) return false;
if (method.getName().equals("permitsAutomaticReconnect")) return false;
if (method.getName().equals("invalidateLargeValues")) { calls.add("invalidate"); return null; }
if (method.getName().equals("executeBatch")) { calls.add("write"); throw new java.sql.SQLException("concurrent row removed"); }
if (method.getName().equals("rollbackManualTransaction")) { calls.add("rollback"); return Map.of("ok", true); }
return null;
});
JsonRpcServer server = new JsonRpcServer(agent);
assertThrows(Exception.class, () -> server.dispatchForRuntime(AgentProtocol.METHOD_EXECUTE_BATCH,
JsonParser.parseString("{\"statements\":[\"UPDATE T SET C='full original value'\"]}").getAsJsonObject()));
assertEquals(List.of("invalidate", "write"), calls);
calls.clear();
server.dispatchForRuntime(AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION, JsonParser.parseString("{}").getAsJsonObject());
assertEquals(List.of("invalidate", "rollback"), calls);
calls.clear();
server.cancelActiveStatements();
assertEquals(List.of("invalidate"), calls);
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
dependencies {
implementation 'io.github.spannm:ucanaccess:5.1.5'
implementation 'io.github.spannm:jackcess-encrypt:5.1.0'
implementation 'io.github.spannm:ucanaccess:5.1.8'
implementation 'io.github.spannm:jackcess-encrypt:5.1.6'
}
tasks.named('shadowJar') {
@@ -744,6 +744,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
if (errorCode == -3325 || errorCode == -2207) {
return true;
}
if (isDamengPermissionError(candidate)) {
return true;
}
String message = candidate.getMessage();
if (message == null) {
continue;
@@ -786,6 +789,27 @@ public final class DamengAgent extends AbstractJdbcAgent {
return false;
}
/**
* 达梦中文环境在无权限时只返回「权限不足」(vendorCode -5669),消息里不带对象名,
* 无法通过 {@link #isDamengMetadataUnavailableError} 的对象名匹配识别。
* 这类错误同样应按「元数据不可用」处理:走字典视图降级,而不是让查看对象直接报错。
*/
private static boolean isDamengPermissionError(SQLException error) {
if (error.getErrorCode() == -5669) {
return true;
}
String message = error.getMessage();
if (message == null) {
return false;
}
String normalized = message.toLowerCase(Locale.ROOT);
return normalized.contains("权限")
|| normalized.contains("privilege")
|| normalized.contains("permission")
|| normalized.contains("access denied")
|| normalized.contains("not authorized");
}
/** 连接类错误(断连/超时等)不属于元数据不可用,必须继续上抛。 */
private static boolean isDamengConnectionError(Throwable error) {
for (Throwable current = error; current != null; current = current.getCause()) {
@@ -1159,6 +1159,37 @@ class DamengAgentMetadataTest {
Assertions.assertTrue(source.getSource().contains("系统字典视图"), source.getSource());
}
@Test
void fallsBackToTriggerCatalogWhenDbmsMetadataPermissionDenied() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
String sql = (String) args[0];
sqls.add(sql);
if (sql.contains("DBMS_METADATA.GET_DDL")) {
return failingMetadataStatement(new SQLException("权限不足", "28000", -5669));
}
if (sql.contains("ALL_TRIGGERS")) {
return metadataStatement(List.of(List.of("BEGIN NULL; END;")));
}
}
if ("close".equals(method.getName())) {
return null;
}
if ("isClosed".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
}));
ObjectSource source = agent.getObjectSource("APP", "TRG_AUDIT", "TRIGGER");
Assertions.assertTrue(source.getSource().contains("BEGIN NULL; END;"), source.getSource());
Assertions.assertTrue(source.getSource().contains("系统字典视图"), source.getSource());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("ALL_TRIGGERS")), String.join("\n", sqls));
}
@Test
void tableDdlFallsBackToViewAndMaterializedViewCatalogDefinitions() {
DamengAgent agent = new DamengAgent();
+1 -1
View File
@@ -1,5 +1,5 @@
dependencies {
implementation 'com.databend:databend-jdbc:0.4.6'
implementation 'com.databend:databend-jdbc:0.4.8'
}
tasks.named('shadowJar') {
+74 -12
View File
@@ -160,6 +160,12 @@ dependencies = [
"rustversion",
]
[[package]]
name = "arcstr"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "argon2"
version = "0.5.3"
@@ -236,7 +242,7 @@ checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5"
dependencies = [
"bytes",
"half",
"num-bigint",
"num-bigint 0.4.8",
"num-traits",
]
@@ -341,6 +347,17 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-trait"
version = "0.1.91"
@@ -794,9 +811,9 @@ dependencies = [
[[package]]
name = "combine"
version = "4.6.7"
version = "4.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"futures-core",
@@ -2060,6 +2077,26 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.2.0"
@@ -3547,7 +3584,7 @@ dependencies = [
"darling",
"heck",
"manyhow",
"num-bigint",
"num-bigint 0.4.8",
"proc-macro-crate",
"proc-macro2",
"quote",
@@ -3602,7 +3639,7 @@ dependencies = [
"getrandom 0.3.4",
"hex",
"mysql-common-derive",
"num-bigint",
"num-bigint 0.4.8",
"num-traits",
"regex",
"rust_decimal",
@@ -3688,6 +3725,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -3921,6 +3968,12 @@ dependencies = [
"windows-strings",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -4614,22 +4667,24 @@ dependencies = [
[[package]]
name = "redis"
version = "0.32.7"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
dependencies = [
"arcstr",
"async-lock",
"bytes",
"cfg-if",
"combine",
"crc16",
"futures-sink",
"futures-channel",
"futures-util",
"itoa",
"log",
"num-bigint",
"num-bigint 0.5.1",
"percent-encoding",
"pin-project-lite",
"rand 0.9.5",
"rand 0.10.2",
"rustls 0.23.42",
"rustls-native-certs 0.8.4",
"ryu",
@@ -4639,6 +4694,7 @@ dependencies = [
"tokio-rustls 0.26.4",
"tokio-util",
"url",
"xxhash-rust",
]
[[package]]
@@ -4870,7 +4926,7 @@ dependencies = [
"md5",
"ml-kem",
"module-lattice",
"num-bigint",
"num-bigint 0.4.8",
"p256",
"p384",
"p521",
@@ -5512,7 +5568,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-bigint 0.4.8",
"num-traits",
"thiserror 2.0.19",
"time",
@@ -6993,6 +7049,12 @@ dependencies = [
"rustix 1.1.4",
]
[[package]]
name = "xxhash-rust"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yoke"
version = "0.8.3"
+1 -1
View File
@@ -1,6 +1,6 @@
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.exasol:exasol-jdbc:26.2.7'
implementation 'com.exasol:exasol-jdbc:26.2.9'
}
tasks.named('shadowJar') {
@@ -9,18 +9,94 @@ import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.ObjectSource;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
import java.nio.charset.Charset;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
public final class FirebirdAgent extends ConfiguredJdbcAgent {
private static final String COMMON_OBJECTS_SQL = """
SELECT TRIM(RDB$GENERATOR_NAME) AS OBJECT_NAME, 'SEQUENCE' AS OBJECT_TYPE, NULL AS OBJECT_COMMENT
FROM RDB$GENERATORS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
UNION ALL
SELECT TRIM(RDB$TRIGGER_NAME), 'TRIGGER', TRIM(RDB$RELATION_NAME)
FROM RDB$TRIGGERS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
UNION ALL
SELECT TRIM(RDB$INDEX_NAME), 'INDEX', TRIM(RDB$RELATION_NAME)
FROM RDB$INDICES WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
""".stripIndent().trim();
static String catalogObjectsSql(int majorVersion) {
if (majorVersion < 3) {
return COMMON_OBJECTS_SQL + " UNION ALL SELECT TRIM(RDB$FUNCTION_NAME), 'FUNCTION_UDF', TRIM(RDB$MODULE_NAME) FROM RDB$FUNCTIONS WHERE COALESCE(RDB$SYSTEM_FLAG,0)=0";
}
return COMMON_OBJECTS_SQL + " UNION ALL " + """
SELECT TRIM(RDB$PACKAGE_NAME), 'PACKAGE', NULL
FROM RDB$PACKAGES WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
UNION ALL
SELECT CASE WHEN RDB$PACKAGE_NAME IS NULL THEN TRIM(RDB$FUNCTION_NAME)
ELSE TRIM(RDB$PACKAGE_NAME) || '.' || TRIM(RDB$FUNCTION_NAME) END,
CASE WHEN NULLIF(TRIM(RDB$MODULE_NAME),'') IS NULL AND NULLIF(TRIM(RDB$ENGINE_NAME),'') IS NULL
THEN 'FUNCTION_INTERNAL' ELSE 'FUNCTION_UDF' END,
COALESCE(NULLIF(TRIM(RDB$MODULE_NAME), ''), NULLIF(TRIM(RDB$ENGINE_NAME), ''))
FROM RDB$FUNCTIONS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
""".stripIndent().trim();
}
private Charset dataCharset;
@Override
public List<TriggerInfo> listTriggers(String schema, String table) {
if (table == null || table.isBlank()) {
return Collections.emptyList();
}
String normalizedTable = table.trim();
if (normalizedTable.startsWith("\"") && normalizedTable.endsWith("\"") && normalizedTable.length() > 1) {
normalizedTable = normalizedTable.substring(1, normalizedTable.length() - 1);
}
final String searchTable = normalizedTable;
return unchecked(() -> {
List<TriggerInfo> triggers = new ArrayList<>();
try (PreparedStatement statement = requireConnection().prepareStatement(
"SELECT TRIM(RDB$TRIGGER_NAME), RDB$TRIGGER_TYPE FROM RDB$TRIGGERS WHERE (TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)) AND COALESCE(RDB$SYSTEM_FLAG,0)=0 ORDER BY RDB$TRIGGER_NAME")) {
statement.setString(1, searchTable);
statement.setString(2, searchTable);
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
int type = result.getInt(2);
triggers.add(new TriggerInfo(result.getString(1), triggerEvent(type), type % 2 == 1 ? "BEFORE" : "AFTER"));
}
}
}
return triggers;
});
}
static String triggerEvent(int type) {
return switch (type) {
case 1, 2 -> "INSERT";
case 3, 4 -> "UPDATE";
case 5, 6 -> "DELETE";
case 17, 18 -> "INSERT OR UPDATE";
case 25, 26 -> "INSERT OR DELETE";
case 27, 28 -> "UPDATE OR DELETE";
case 113, 114 -> "INSERT OR UPDATE OR DELETE";
case 8192 -> "CONNECT";
case 8193 -> "DISCONNECT";
case 8194 -> "TRANSACTION START";
case 8195 -> "TRANSACTION COMMIT";
case 8196 -> "TRANSACTION ROLLBACK";
default -> Integer.toString(type);
};
}
@Override
protected String buildJdbcUrl(ConnectParams params) {
String url = super.buildJdbcUrl(params);
@@ -75,9 +151,44 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
@Override
public List<ObjectInfo> listObjects(String schema, MetadataListConstraints constraints) {
List<ObjectInfo> objects = super.listObjects(schema, constraints);
List<ObjectInfo> objects = new ArrayList<>(super.listObjects(schema, MetadataListConstraints.NONE));
// JDBC metadata flattens all Firebird functions into FUNCTION and does
// not expose generators, packages, database triggers, or indexes. Use
// the Firebird catalogs so the object browser can keep those categories
// distinct without changing the table-scoped metadata APIs.
for (Iterator<ObjectInfo> iterator = objects.iterator(); iterator.hasNext();) {
ObjectInfo object = iterator.next();
if ("FUNCTION".equalsIgnoreCase(object.getObject_type())) iterator.remove();
}
unchecked(() -> {
try (PreparedStatement statement = requireConnection().prepareStatement(catalogObjectsSql(requireConnection().getMetaData().getDatabaseMajorVersion()));
ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
String name = trimToNull(resultSet.getString("OBJECT_NAME"));
String type = trimToNull(resultSet.getString("OBJECT_TYPE"));
if (name == null || type == null) continue;
String comment = decodeLegacyText(trimToNull(resultSet.getString("OBJECT_COMMENT")));
ObjectInfo object = new ObjectInfo(name, type, schema, comment);
if ("INDEX".equals(type) || "TRIGGER".equals(type)) {
object.setParent_name(comment);
object.setComment(null);
}
objects.add(object);
}
}
return null;
});
for (ObjectInfo object : objects) object.setComment(decodeLegacyText(object.getComment()));
return objects;
MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints);
// Preserve the existing FUNCTION metadata request used by older clients
// while exposing the two precise categories to the new object browser.
if (normalized.getObjectTypes() != null && normalized.getObjectTypes().contains("FUNCTION")) {
for (ObjectInfo object : objects) {
if (object.getObject_type().startsWith("FUNCTION_")) object.setObject_type("FUNCTION");
}
}
objects.sort(Comparator.comparing(ObjectInfo::getName).thenComparing(ObjectInfo::getObject_type));
return normalized.filterObjects(objects);
}
static final String PROCEDURE_SOURCE_SQL = """
SELECT
@@ -137,6 +248,10 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
@Override
public ObjectSource getObjectSource(String schema, String name, String objectType) {
String requestedType = normalizeObjectSourceType(objectType);
if (!"PROCEDURE".equals(requestedType)) {
return unchecked(() -> FirebirdObjectSource.read(requireConnection(), schema, name, requestedType, this::decodeLegacyText));
}
String normalizedType = normalizeObjectSourceType(objectType);
return unchecked(() -> {
String body = null;
@@ -191,7 +306,7 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
throw new IllegalArgumentException("Unsupported object type: null");
}
String normalized = objectType.trim().toUpperCase(Locale.ROOT);
if (!"PROCEDURE".equals(normalized)) {
if (!List.of("PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "PACKAGE", "PACKAGE_BODY", "VIEW").contains(normalized)) {
throw new IllegalArgumentException("Unsupported object type: " + objectType);
}
return normalized;
@@ -0,0 +1,117 @@
package com.dbx.agent.firebird;
import com.dbx.agent.ObjectSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.function.UnaryOperator;
/** Read-only catalog source: stored routine bodies are never advertised as executable DDL. */
final class FirebirdObjectSource {
static ObjectSource read(Connection connection, String schema, String name, String type,
UnaryOperator<String> decode) throws SQLException {
String targetName = name != null ? name.trim() : "";
if (targetName.startsWith("\"") && targetName.endsWith("\"") && targetName.length() > 1) {
targetName = targetName.substring(1, targetName.length() - 1);
}
String sql = switch (type) {
case "FUNCTION" -> "SELECT RDB$FUNCTION_SOURCE, RDB$MODULE_NAME, RDB$ENTRYPOINT, RDB$ENGINE_NAME FROM RDB$FUNCTIONS WHERE CASE WHEN RDB$PACKAGE_NAME IS NULL THEN TRIM(RDB$FUNCTION_NAME) ELSE TRIM(RDB$PACKAGE_NAME) || '.' || TRIM(RDB$FUNCTION_NAME) END = ?";
case "TRIGGER" -> "SELECT RDB$TRIGGER_SOURCE, TRIM(RDB$RELATION_NAME), RDB$TRIGGER_TYPE, RDB$TRIGGER_SEQUENCE, RDB$TRIGGER_INACTIVE FROM RDB$TRIGGERS WHERE (TRIM(RDB$TRIGGER_NAME) = ? OR TRIM(RDB$TRIGGER_NAME) = UPPER(?))";
case "PACKAGE", "PACKAGE_BODY" -> "SELECT RDB$PACKAGE_HEADER_SOURCE, RDB$PACKAGE_BODY_SOURCE FROM RDB$PACKAGES WHERE (TRIM(RDB$PACKAGE_NAME) = ? OR TRIM(RDB$PACKAGE_NAME) = UPPER(?))";
case "SEQUENCE" -> "SELECT RDB$GENERATOR_NAME, RDB$INITIAL_VALUE, RDB$GENERATOR_INCREMENT FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = ?";
case "VIEW" -> "SELECT RDB$VIEW_SOURCE FROM RDB$RELATIONS WHERE (TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)) AND RDB$VIEW_BLR IS NOT NULL";
default -> throw new IllegalArgumentException("Unsupported Firebird object type: " + type);
};
boolean legacy = connection.getMetaData().getDatabaseMajorVersion() < 3;
if (legacy && "FUNCTION".equals(type)) {
sql = "SELECT CAST(NULL AS VARCHAR(1)), RDB$MODULE_NAME, RDB$ENTRYPOINT, CAST(NULL AS VARCHAR(1)) FROM RDB$FUNCTIONS WHERE (TRIM(RDB$FUNCTION_NAME) = ? OR TRIM(RDB$FUNCTION_NAME) = UPPER(?))";
} else if (legacy && "SEQUENCE".equals(type)) {
sql = "SELECT RDB$GENERATOR_NAME, 0, 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = ?";
}
String source = "";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, targetName);
if (sql.contains("UPPER(?)")) {
statement.setString(2, targetName);
}
try (ResultSet result = statement.executeQuery()) {
if (result.next()) {
String quoted = "\"" + targetName.replace("\"", "\"\"") + "\"";
source = switch (type) {
case "FUNCTION" -> functionSource(result, decode);
case "TRIGGER" -> triggerSource(result, targetName, decode);
case "PACKAGE", "PACKAGE_BODY" -> packageSource(result, quoted, decode);
case "SEQUENCE" -> legacy ? "-- Generator: " + quoted
: "CREATE SEQUENCE " + quoted + " START WITH " + result.getLong(2)
+ " INCREMENT BY " + result.getLong(3) + ";";
case "VIEW" -> "-- Stored view query\n" + text(decode.apply(result.getString(1)));
default -> "";
};
}
}
}
return new ObjectSource(targetName, type, schema, source, false);
}
private static String triggerSource(ResultSet result, String name, UnaryOperator<String> decode) throws SQLException {
String relation = result.getString(2);
int type = result.getInt(3);
int position = result.getInt(4);
int inactive = result.getInt(5);
String body = text(decode.apply(result.getString(1)));
String rel = relation != null ? relation.trim() : "";
String trimmedBody = body.stripLeading();
if ((trimmedBody.regionMatches(true, 0, "CREATE", 0, 6) && (trimmedBody.length() == 6 || Character.isWhitespace(trimmedBody.charAt(6))))
|| (trimmedBody.regionMatches(true, 0, "ALTER", 0, 5) && (trimmedBody.length() == 5 || Character.isWhitespace(trimmedBody.charAt(5))))) {
return body;
}
String quoted = "\"" + name.replace("\"", "\"\"") + "\"";
StringBuilder ddl = new StringBuilder("CREATE OR ALTER TRIGGER ").append(quoted);
if (!rel.isEmpty()) {
ddl.append(" FOR \"").append(rel.replace("\"", "\"\"")).append("\"");
}
ddl.append(inactive == 1 ? " INACTIVE\n" : " ACTIVE\n");
if (type >= 8192) {
ddl.append("ON ").append(FirebirdAgent.triggerEvent(type));
} else {
String timing = (type % 2 == 1) ? "BEFORE" : "AFTER";
String event = FirebirdAgent.triggerEvent(type);
ddl.append(timing).append(" ").append(event);
}
if (position > 0) {
ddl.append(" POSITION ").append(position);
}
ddl.append("\n");
if (trimmedBody.regionMatches(true, 0, "AS", 0, 2) && (trimmedBody.length() == 2 || Character.isWhitespace(trimmedBody.charAt(2)))) {
ddl.append(trimmedBody);
} else if (!trimmedBody.isEmpty()) {
ddl.append("AS\n").append(trimmedBody);
} else {
ddl.append("AS\nBEGIN\nEND");
}
return ddl.toString().stripTrailing();
}
private static String functionSource(ResultSet result, UnaryOperator<String> decode) throws SQLException {
String body = decode.apply(result.getString(1));
if (body != null && !body.isBlank()) return "-- Stored function body\n" + body;
if (text(result.getString(2)).isBlank() && text(result.getString(4)).isBlank()) {
return "-- No stored function body is available.";
}
return "-- External function\n-- Module: " + text(result.getString(2))
+ "\n-- Entry point: " + text(result.getString(3))
+ "\n-- Engine: " + text(result.getString(4));
}
private static String packageSource(ResultSet result, String name, UnaryOperator<String> decode) throws SQLException {
String header = decode.apply(result.getString(1));
String body = decode.apply(result.getString(2));
return "CREATE OR ALTER PACKAGE " + name + " AS\n" + text(header)
+ (body == null ? "" : "\n\nCREATE OR ALTER PACKAGE BODY " + name + " AS\n" + body);
}
private static String text(String value) {
return value == null ? "" : value.stripTrailing();
}
}
@@ -1,6 +1,7 @@
package com.dbx.agent.firebird;
import com.dbx.agent.ObjectSource;
import com.dbx.agent.TriggerInfo;
import com.dbx.agent.test.TestSupport;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
@@ -333,10 +334,46 @@ class FirebirdAgentTest {
IllegalArgumentException error = Assertions.assertThrows(
IllegalArgumentException.class,
() -> agent.getObjectSource(null, "P_6141", "VIEW")
() -> agent.getObjectSource(null, "P_6141", "UNKNOWN")
);
Assertions.assertEquals("Unsupported object type: VIEW", error.getMessage());
Assertions.assertEquals("Unsupported object type: UNKNOWN", error.getMessage());
}
@Test
void listTriggersNormalizesTableNameAndMapsTriggerTypes() {
List<String> sql = new ArrayList<>();
List<String> parameters = new ArrayList<>();
FirebirdAgent agent = agentWithRows(
sql,
parameters,
row(
"1", "TR_ORDERS_BI",
"2", 1
),
row(
"1", "TR_ORDERS_AD",
"2", 6
)
);
List<TriggerInfo> triggers = agent.listTriggers(null, "\"orders\"");
Assertions.assertEquals(2, triggers.size());
Assertions.assertEquals("TR_ORDERS_BI", triggers.get(0).getName());
Assertions.assertEquals("INSERT", triggers.get(0).getEvent());
Assertions.assertEquals("BEFORE", triggers.get(0).getTiming());
Assertions.assertEquals("TR_ORDERS_AD", triggers.get(1).getName());
Assertions.assertEquals("DELETE", triggers.get(1).getEvent());
Assertions.assertEquals("AFTER", triggers.get(1).getTiming());
Assertions.assertEquals(List.of("1=orders", "2=orders"), parameters);
Assertions.assertTrue(sql.get(0).contains("TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)"));
}
@Test
void listTriggersReturnsEmptyForNullOrBlankTable() {
FirebirdAgent agent = new FirebirdAgent();
Assertions.assertTrue(agent.listTriggers(null, null).isEmpty());
Assertions.assertTrue(agent.listTriggers(null, " ").isEmpty());
}
@SafeVarargs
@@ -0,0 +1,107 @@
package com.dbx.agent.firebird;
import com.dbx.agent.ObjectSource;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class FirebirdObjectSourceTest {
@Test
void generatorSourceNeverReadsOrIncrementsCurrentValue() throws Exception {
List<String> calls = new ArrayList<>();
ObjectSource source = FirebirdObjectSource.read(connection(calls, "G\"ID", 10L, 5L), "", "G\"ID", "SEQUENCE", value -> value);
assertEquals("CREATE SEQUENCE \"G\"\"ID\" START WITH 10 INCREMENT BY 5;", source.getSource());
assertFalse(source.isEditable());
assertEquals("G\"ID", calls.get(1));
assertFalse(calls.get(0).contains("GEN_ID"));
}
@Test
void externalFunctionsShowModuleEntrypointAndEngineWithoutCallingUdf() throws Exception {
List<String> calls = new ArrayList<>();
ObjectSource source = FirebirdObjectSource.read(connection(calls, null, "FreeAdhocUDF", "f_abs", null), "", "F_ABS", "FUNCTION", value -> value);
assertTrue(source.getSource().contains("Module: FreeAdhocUDF"));
assertTrue(source.getSource().contains("Entry point: f_abs"));
assertTrue(calls.get(0).startsWith("SELECT "));
assertFalse(source.isEditable());
}
@Test
void packagedFunctionsUseQualifiedCatalogIdentityAndDecodeOnlyBody() throws Exception {
List<String> calls = new ArrayList<>();
ObjectSource source = FirebirdObjectSource.read(connection(calls, "²Ý¸å", null, null, null), "", "MYUDR2.FN", "FUNCTION",
value -> LegacyTextDecoder.decode(value, java.nio.charset.Charset.forName("GBK")));
assertEquals("MYUDR2.FN", calls.get(1));
assertTrue(calls.get(0).contains("RDB$PACKAGE_NAME"));
assertTrue(source.getSource().contains("草稿"));
}
@Test
void tableTriggerSourceBuildsStandardCreateOrAlterTriggerDdl() throws Exception {
List<String> calls = new ArrayList<>();
ObjectSource source = FirebirdObjectSource.read(
connection(calls, "AS\nBEGIN\n NEW.ID = 1;\nEND", "ORDERS", 1, 0, 0),
"", "TR_ORDERS_BI", "TRIGGER", value -> value
);
assertEquals(
"CREATE OR ALTER TRIGGER \"TR_ORDERS_BI\" FOR \"ORDERS\" ACTIVE\nBEFORE INSERT\nAS\nBEGIN\n NEW.ID = 1;\nEND",
source.getSource()
);
assertEquals("TR_ORDERS_BI", source.getName());
assertEquals("TRIGGER", source.getObject_type());
}
@Test
void databaseTriggerSourceFormatsEventAndTiming() throws Exception {
List<String> calls = new ArrayList<>();
ObjectSource source = FirebirdObjectSource.read(
connection(calls, "BEGIN\n EXECUTE PROCEDURE LOG_CONN;\nEND", null, 8192, 1, 1),
"", "TR_CONN", "TRIGGER", value -> value
);
assertEquals(
"CREATE OR ALTER TRIGGER \"TR_CONN\" INACTIVE\nON CONNECT POSITION 1\nAS\nBEGIN\n EXECUTE PROCEDURE LOG_CONN;\nEND",
source.getSource()
);
}
private static Connection connection(List<String> calls, Object... values) {
ResultSet result = (ResultSet) Proxy.newProxyInstance(ResultSet.class.getClassLoader(), new Class<?>[] {ResultSet.class},
new java.lang.reflect.InvocationHandler() {
boolean first = true;
public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
return switch (method.getName()) {
case "next" -> { boolean value = first; first = false; yield value; }
case "getString" -> { Object value = values[(Integer) args[0] - 1]; yield value == null ? null : value.toString(); }
case "getInt" -> ((Number) values[(Integer) args[0] - 1]).intValue();
case "getLong" -> ((Number) values[(Integer) args[0] - 1]).longValue();
case "close" -> null;
default -> throw new UnsupportedOperationException(method.getName());
};
}
});
PreparedStatement statement = (PreparedStatement) Proxy.newProxyInstance(PreparedStatement.class.getClassLoader(), new Class<?>[] {PreparedStatement.class},
(proxy, method, args) -> switch (method.getName()) {
case "setString" -> { calls.add((String) args[1]); yield null; }
case "executeQuery" -> result;
case "close" -> null;
default -> throw new UnsupportedOperationException(method.getName());
});
return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[] {Connection.class},
(proxy, method, args) -> {
if (method.getName().equals("getMetaData")) {
return Proxy.newProxyInstance(java.sql.DatabaseMetaData.class.getClassLoader(), new Class<?>[] {java.sql.DatabaseMetaData.class},
(metadata, operation, arguments) -> {
if (operation.getName().equals("getDatabaseMajorVersion")) return 5;
throw new UnsupportedOperationException(operation.getName());
});
}
if (method.getName().equals("prepareStatement")) { calls.add((String) args[0]); return statement; }
throw new UnsupportedOperationException(method.getName());
});
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.highgo:HgdbJdbc:6.2.4'
implementation 'com.highgo:HgdbJdbc:6.2.5'
// PostGIS JDBC is optional: EwkbWktDecoder reflectively probes for
// org.postgis.PGgeometry / net.postgis.jdbc.PGgeometry and falls back to
// its own EWKB parser when the class is absent. compileOnly keeps the
+20 -11
View File
@@ -436,6 +436,7 @@ func parseHiveAssignments(raw string, lowercaseKeys bool) map[string]string {
if decoded, err := url.QueryUnescape(key); err == nil {
key = decoded
}
key = strings.TrimLeft(strings.TrimSpace(key), "?#")
if lowercaseKeys {
key = strings.ToLower(key)
}
@@ -691,22 +692,27 @@ 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)
cleanKey := strings.TrimPrefix(key, "#")
lowerKey := strings.ToLower(cleanKey)
switch {
case strings.HasPrefix(lowerKey, "hiveconf:"):
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(key[len("hiveconf:"):])] = value
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(cleanKey[len("hiveconf:"):])] = value
case strings.HasPrefix(lowerKey, "hivevar:"):
config.HiveConfiguration["set:hivevar:"+key[len("hivevar:"):]] = value
config.HiveConfiguration["set:hivevar:"+cleanKey[len("hivevar:"):]] = value
case strings.HasPrefix(lowerKey, "kyuubi."):
config.HiveConfiguration["set:hivevar:"+cleanKey] = value
}
}
for key, value := range hiveConfs {
if strings.EqualFold(key, "hive.server2.transport.mode") || strings.EqualFold(key, "hive.server2.thrift.http.path") {
cleanKey := strings.TrimPrefix(key, "#")
if strings.EqualFold(cleanKey, "hive.server2.transport.mode") || strings.EqualFold(cleanKey, "hive.server2.thrift.http.path") {
continue
}
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(key)] = value
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(cleanKey)] = value
}
for key, value := range hiveVars {
config.HiveConfiguration["set:hivevar:"+key] = value
cleanKey := strings.TrimPrefix(key, "#")
config.HiveConfiguration["set:hivevar:"+cleanKey] = value
}
if proxyUser := firstNonEmpty(parameter(values, "proxyuser"), parameter(values, "hive.server2.proxy.user")); proxyUser != "" {
config.HiveConfiguration["hive.server2.proxy.user"] = proxyUser
@@ -753,11 +759,14 @@ func openSessionConfigurationValue(values map[string]string, key string) string
// Hive JDBC-style URL fragments are sent as hive variables, while values
// after '?' are sent as hive confs. Kyuubi strips either prefix before it
// resolves session-scoped settings such as kyuubi.engine.type.
for _, wanted := range []string{"set:hivevar:" + key, "set:hiveconf:" + key, key} {
for candidate, value := range values {
if strings.EqualFold(strings.TrimSpace(candidate), wanted) {
return value
}
normalizedKey := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(key), "#"))
for candidate, value := range values {
candidateKey := strings.ToLower(strings.TrimSpace(candidate))
candidateKey = strings.TrimPrefix(candidateKey, "set:hivevar:")
candidateKey = strings.TrimPrefix(candidateKey, "set:hiveconf:")
candidateKey = strings.TrimPrefix(candidateKey, "#")
if candidateKey == normalizedKey {
return value
}
}
return ""
+30
View File
@@ -432,6 +432,36 @@ func TestKyuubiIdentifierQuoteFollowsPreservedEngineType(t *testing.T) {
configuration: "set:hiveconf:kyuubi.engine.type",
identifierQuote: trinoIdentifierQuote,
},
{
name: "URL param without hash selects Trino",
params: connectParams{
Host: "kyuubi.example.com",
DatabaseType: "kyuubi",
URLParams: "kyuubi.engine.type=TRINO",
},
configuration: "set:hivevar:kyuubi.engine.type",
identifierQuote: trinoIdentifierQuote,
},
{
name: "Semicolon with hash selects Trino",
params: connectParams{
Host: "kyuubi.example.com",
DatabaseType: "kyuubi",
URLParams: ";#kyuubi.engine.type=trino",
},
configuration: "set:hivevar:kyuubi.engine.type",
identifierQuote: trinoIdentifierQuote,
},
{
name: "Session params with auth selects Trino",
params: connectParams{
Host: "kyuubi.example.com",
DatabaseType: "kyuubi",
URLParams: "auth=NOSASL;kyuubi.engine.type=TRINO",
},
configuration: "set:hivevar:kyuubi.engine.type",
identifierQuote: trinoIdentifierQuote,
},
{
name: "Kyuubi Spark keeps Hive quote",
params: connectParams{
+1
View File
@@ -90,6 +90,7 @@ func newDiscoveryConnector(config connectionConfig) *discoveryConnector {
BrowserResponseTimeout: config.BrowserResponseTimeout,
BrowserDisableSSLCheck: config.BrowserDisableSSLCheck,
WaitForNonQueryCompletion: waitsForNonQueryCompletion(config.DatabaseType),
IdentifierQuote: config.identifierQuote(),
})
},
driver: &gohive.Driver{},
+1 -1
View File
@@ -1,6 +1,6 @@
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.ibm.informix:jdbc:4.50.10'
implementation 'com.ibm.informix:jdbc:4.50.14'
}
tasks.named('shadowJar') {
+1 -1
View File
@@ -8,7 +8,7 @@ repositories {
}
dependencies {
implementation 'org.apache.iotdb:iotdb-jdbc:2.0.8'
implementation 'org.apache.iotdb:iotdb-jdbc:2.0.11'
implementation 'org.slf4j:slf4j-nop:2.0.16'
}
@@ -137,10 +137,62 @@ public final class IrisAgent extends ConfiguredJdbcAgent {
if (!hasPrimaryKey(columns)) {
markPrimaryKeys(columns, irisPrimaryKeys(conn, schema, table));
}
markIdentityColumns(columns, irisIdentityColumns(conn, schema, table));
return columns;
});
}
/**
* IRIS RowId / IDENTITY columns are system-assigned: their values cannot be
* user-specified on INSERT, so the grid must omit them when saving new rows
* (mirrors how the Dameng agent reports IDENTITY columns). IRIS exposes this
* through INFORMATION_SCHEMA.COLUMNS extension columns (IS_ROWID / IS_IDENTITY,
* not part of the SQL standard). The probe is defensive on purpose: when a
* server build lacks the extension columns the query fails and column listing
* keeps working exactly as before.
*/
private static Set<String> irisIdentityColumns(Connection conn, String schema, String table) {
Set<String> names = new LinkedHashSet<>();
try (PreparedStatement stmt = conn.prepareStatement(identityColumnSql(schema))) {
int index = 1;
if (!isBlank(schema)) {
stmt.setString(index++, schema.trim());
}
stmt.setString(index, table);
try (ResultSet rs = stmt.executeQuery()) {
addPrimaryKeys(names, rs);
}
} catch (Exception ignored) {
// Identity metadata is optional; older builds report it through
// neither extension column and must not break column listing.
}
return names;
}
private static String identityColumnSql(String schema) {
StringBuilder sql = new StringBuilder(
"SELECT COLUMN_NAME "
+ "FROM INFORMATION_SCHEMA.COLUMNS WHERE "
+ "(UPPER(IS_ROWID) = 'YES' OR UPPER(IS_IDENTITY) = 'YES') AND "
);
if (!isBlank(schema)) {
sql.append("UPPER(TABLE_SCHEMA) = UPPER(?) AND ");
}
sql.append("UPPER(TABLE_NAME) = UPPER(?)");
return sql.toString();
}
private static void markIdentityColumns(List<ColumnInfo> columns, Set<String> identityColumns) {
if (identityColumns.isEmpty()) {
return;
}
for (ColumnInfo column : columns) {
if (identityColumns.contains(column.getName().toUpperCase(Locale.ROOT))) {
column.setExtra("identity");
}
}
}
static List<String> dedupeCaseInsensitiveSchemas(List<String> schemas) {
Map<String, String> byNormalizedName = new LinkedHashMap<>();
for (String schema : schemas) {
@@ -219,7 +219,7 @@ class IrisAgentTest {
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());
assertEquals(2, calls.size());
assertFalse(calls.get(0).contains("LIMIT"));
assertTrue(calls.get(0).contains("PRIMARY_KEY"));
assertTrue(calls.get(0).contains("DESCRIPTION"));
@@ -242,9 +242,55 @@ class IrisAgentTest {
assertEquals("identifier", columns.get(0).getComment());
assertNull(columns.get(1).getComment());
assertEquals(1, calls.size());
assertEquals(2, calls.size());
assertTrue(calls.get(0).contains("DESCRIPTION"));
assertFalse(calls.get(0).contains("TABLE_SCHEMA"));
assertTrue(calls.get(1).contains("IS_ROWID"));
assertFalse(calls.get(1).contains("TABLE_SCHEMA"));
}
@Test
void marksRowIdColumnsAsIdentityExtra() {
List<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
true,
Collections.emptyList(),
null,
Collections.emptyList(),
List.of(Map.of("COLUMN_NAME", "ID")),
null
);
List<ColumnInfo> columns = IrisAgent.irisColumns(conn, "SQLUser", "People");
assertEquals("identity", columns.get(0).getExtra());
assertNull(columns.get(1).getExtra());
assertEquals(2, calls.size());
assertTrue(calls.get(1).contains("IS_ROWID"));
assertTrue(calls.get(1).contains("IS_IDENTITY"));
assertTrue(calls.get(1).contains("UPPER(TABLE_SCHEMA) = UPPER(?)"));
}
@Test
void keepsColumnListingWhenIdentityColumnsAreUnavailable() {
List<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
true,
Collections.emptyList(),
null,
Collections.emptyList(),
null,
new SQLException("Field 'IS_ROWID' not found")
);
List<ColumnInfo> columns = IrisAgent.irisColumns(conn, "SQLUser", "People");
assertEquals(2, columns.size());
assertNull(columns.get(0).getExtra());
assertNull(columns.get(1).getExtra());
assertEquals(2, calls.size());
}
@Test
@@ -261,9 +307,10 @@ class IrisAgentTest {
List<ColumnInfo> columns = IrisAgent.irisColumns(conn, "SQLUser", "People");
assertTrue(columns.get(0).getIs_primary_key());
assertEquals(2, calls.size());
assertEquals(3, calls.size());
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
assertEquals("JDBC_PRIMARY_KEYS", calls.get(1));
assertTrue(calls.get(2).contains("IS_ROWID"));
}
@Test
@@ -280,9 +327,11 @@ class IrisAgentTest {
List<ColumnInfo> columns = IrisAgent.irisColumns(conn, "SQLUser", "People");
assertTrue(columns.get(0).getIs_primary_key());
assertEquals(4, calls.size());
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
assertEquals("JDBC_PRIMARY_KEYS", calls.get(1));
assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE"));
assertTrue(calls.get(3).contains("IS_ROWID"));
}
@Test
@@ -300,7 +349,9 @@ class IrisAgentTest {
assertFalse(columns.get(0).getIs_primary_key());
assertFalse(columns.get(1).getIs_primary_key());
assertEquals(4, calls.size());
assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE"));
assertTrue(calls.get(3).contains("IS_ROWID"));
}
private static Connection fakeConnection(
@@ -309,12 +360,26 @@ class IrisAgentTest {
List<Map<String, Object>> jdbcPrimaryKeys,
SQLException jdbcPrimaryKeyFailure,
List<Map<String, Object>> nativePrimaryKeys
) {
return fakeConnection(
callLog, inlinePrimaryKey, jdbcPrimaryKeys, jdbcPrimaryKeyFailure, nativePrimaryKeys, null, null
);
}
private static Connection fakeConnection(
List<String> callLog,
boolean inlinePrimaryKey,
List<Map<String, Object>> jdbcPrimaryKeys,
SQLException jdbcPrimaryKeyFailure,
List<Map<String, Object>> nativePrimaryKeys,
List<Map<String, Object>> identityColumns,
SQLException identityQueryFailure
) {
return proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
String sql = (String) args[0];
callLog.add(sql);
return fakeStatement(sql, inlinePrimaryKey, nativePrimaryKeys);
return fakeStatement(sql, inlinePrimaryKey, nativePrimaryKeys, identityColumns, identityQueryFailure);
}
if ("getMetaData".equals(method.getName())) {
return fakeMetadata(callLog, jdbcPrimaryKeys, jdbcPrimaryKeyFailure);
@@ -349,10 +414,18 @@ class IrisAgentTest {
private static PreparedStatement fakeStatement(
String sql,
boolean inlinePrimaryKey,
List<Map<String, Object>> nativePrimaryKeys
List<Map<String, Object>> nativePrimaryKeys,
List<Map<String, Object>> identityColumns,
SQLException identityQueryFailure
) {
return proxy(PreparedStatement.class, (method, args) -> {
if ("executeQuery".equals(method.getName())) {
if (sql.contains("IS_ROWID")) {
if (identityQueryFailure != null) {
throw identityQueryFailure;
}
return fakeResultSet(identityColumns == null ? Collections.emptyList() : identityColumns);
}
if (sql.contains("KEY_COLUMN_USAGE")) {
return fakeResultSet(nativePrimaryKeys);
}
+1 -1
View File
@@ -1,6 +1,6 @@
dependencies {
implementation 'com.google.code.gson:gson:2.12.1'
implementation 'org.apache.kafka:kafka-clients:3.9.1'
implementation 'org.apache.kafka:kafka-clients:3.9.2'
implementation('org.apache.zookeeper:zookeeper:3.8.4') {
exclude group: 'log4j', module: 'log4j'
exclude group: 'org.slf4j', module: 'slf4j-log4j12'
@@ -10,6 +10,42 @@ import (
"time"
)
func TestKingbaseQueryMessagesIntegration(t *testing.T) {
host := os.Getenv("KINGBASE_TEST_HOST")
portText := os.Getenv("KINGBASE_TEST_PORT")
username := os.Getenv("KINGBASE_TEST_USERNAME")
password := os.Getenv("KINGBASE_TEST_PASSWORD")
if host == "" || portText == "" || username == "" || password == "" {
t.Skip("Kingbase integration environment is not configured")
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatal(err)
}
database := os.Getenv("KINGBASE_TEST_DATABASE")
if database == "" {
database = "test"
}
server := newServer()
if err := server.connect(connectParams{Host: host, Port: port, Database: database, Username: username, Password: password}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.disconnect() })
sql := "DO $$ BEGIN RAISE NOTICE '当前用户: %, 时间: %', current_user, now(); END $$"
result, err := server.executeQuery(queryOptions{SQL: sql})
if err != nil || len(result.Messages) != 1 || !strings.Contains(result.Messages[0].Message, "当前用户:") || result.Messages[0].Code != "00000" {
t.Fatalf("query notice missing: %+v, err=%v", result.Messages, err)
}
page, err := server.executeQueryPage(queryOptions{SQL: sql}, 10)
if err != nil || len(page.Messages) != 1 {
t.Fatalf("paged command lost notices: %+v, err=%v", page.Messages, err)
}
result, err = server.executeQuery(queryOptions{SQL: "SELECT 1"})
if err != nil || len(result.Messages) != 0 {
t.Fatalf("previous command notices leaked: %+v, err=%v", result.Messages, err)
}
}
func TestKingbaseIntegration(t *testing.T) {
host := os.Getenv("KINGBASE_TEST_HOST")
portText := os.Getenv("KINGBASE_TEST_PORT")
@@ -624,12 +624,16 @@ type customTypeCatalogQueries struct {
// for built-in pg_catalog types.
func qualifiedCatalogTypeExpression(typeAlias, namespaceAlias, elementAlias, elementNamespaceAlias, oidExpression, typmodExpression string) string {
return fmt.Sprintf(`CASE
WHEN %s.typelem <> 0 AND %s.nspname <> 'pg_catalog'
WHEN %s.typelem <> 0 AND %s.nspname NOT IN ('pg_catalog', 'sys_catalog')
THEN quote_ident(%s.nspname) || '.' || quote_ident(%s.typname) || '[]'
WHEN %s.nspname <> 'pg_catalog'
WHEN %s.nspname NOT IN ('pg_catalog', 'sys_catalog')
THEN quote_ident(%s.nspname) || '.' || quote_ident(%s.typname)
WHEN %s.typname = 'json'
THEN 'json'
WHEN %s.typname = '_json'
THEN 'json[]'
ELSE format_type(%s, %s)
END`, typeAlias, elementNamespaceAlias, elementNamespaceAlias, elementAlias, namespaceAlias, namespaceAlias, typeAlias, oidExpression, typmodExpression)
END`, typeAlias, elementNamespaceAlias, elementNamespaceAlias, elementAlias, namespaceAlias, namespaceAlias, typeAlias, typeAlias, typeAlias, oidExpression, typmodExpression)
}
func customTypeCatalogQueriesFor(catalog, prefix, schema, name string) customTypeCatalogQueries {
@@ -1893,7 +1897,12 @@ func (s *server) queryCatalogColumns(
visibilityFunction := kingbaseCatalogFunction(catalog, "sys_table_is_visible", "pg_table_is_visible")
relationPredicate = fmt.Sprintf("c.relname = %s AND %s(c.oid)", quoteLiteral(table), visibilityFunction)
}
query := fmt.Sprintf(`SELECT n.nspname, a.attname, format_type(a.atttypid, a.atttypmod), NOT a.attnotnull,
query := fmt.Sprintf(`SELECT n.nspname, a.attname,
CASE
WHEN t.typname = 'json' THEN 'json'
WHEN t.typname = '_json' THEN 'json[]'
ELSE format_type(a.atttypid, a.atttypmod)
END, NOT a.attnotnull,
%s(ad.adbin, ad.adrelid), col_description(a.attrelid, a.attnum),
CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 THEN ((a.atttypmod - 4) >> 16) & 65535 END,
CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 THEN (a.atttypmod - 4) & 65535 END,
@@ -2738,6 +2747,14 @@ func (s *server) getTableDDL(schema, table string) (string, error) {
if err != nil {
return "", err
}
if len(columns) == 0 {
if visibleColumns, fallbackErr := s.getColumns("", table); fallbackErr == nil && len(visibleColumns) > 0 {
columns = visibleColumns
if columns[0].ResolvedSchema != nil && *columns[0].ResolvedSchema != "" {
effective = *columns[0].ResolvedSchema
}
}
}
tableComment, _ := s.getTableComment(effective, table)
ddl := s.renderTableDDL(effective, table, columns, tableComment)
ddl, err = s.appendTableIndexDDL(effective, table, ddl)
+86 -29
View File
@@ -100,33 +100,37 @@ type completionAssistantResponse struct {
}
type queryResult struct {
Columns []string `json:"columns"`
ColumnTypes []string `json:"column_types"`
Rows [][]any `json:"rows"`
AffectedRows int64 `json:"affected_rows"`
ExecutionTimeMS int64 `json:"execution_time_ms"`
Truncated bool `json:"truncated"`
Columns []string `json:"columns"`
ColumnTypes []string `json:"column_types"`
Rows [][]any `json:"rows"`
AffectedRows int64 `json:"affected_rows"`
ExecutionTimeMS int64 `json:"execution_time_ms"`
Truncated bool `json:"truncated"`
Messages []queryMessage `json:"messages,omitempty"`
}
type queryPageResult struct {
Columns []string `json:"columns"`
ColumnTypes []string `json:"column_types"`
Rows [][]any `json:"rows"`
AffectedRows int64 `json:"affected_rows"`
ExecutionTimeMS int64 `json:"execution_time_ms"`
Truncated bool `json:"truncated"`
SessionID *string `json:"session_id"`
HasMore bool `json:"has_more"`
Columns []string `json:"columns"`
ColumnTypes []string `json:"column_types"`
Rows [][]any `json:"rows"`
AffectedRows int64 `json:"affected_rows"`
ExecutionTimeMS int64 `json:"execution_time_ms"`
Truncated bool `json:"truncated"`
SessionID *string `json:"session_id"`
HasMore bool `json:"has_more"`
Messages []queryMessage `json:"messages,omitempty"`
}
type querySession struct {
rows *sql.Rows
conn *sql.Conn
columns []string
columnTypes []string
pending []any
remaining int
cancel context.CancelFunc
rows *sql.Rows
conn *sql.Conn
columns []string
columnTypes []string
pending []any
remaining int
messages *queryMessageBuffer
restoreMessages func()
cancel context.CancelFunc
}
type server struct {
@@ -750,16 +754,18 @@ func (s *server) cancelActiveQuery() {
}
}
func (s *server) executeQuery(opts queryOptions) (queryResult, error) {
func (s *server) executeQuery(opts queryOptions) (result queryResult, resultErr error) {
start := time.Now()
sqlText := trimStatementSQL(opts.SQL)
if isQuerySQL(sqlText) {
rows, conn, cancel, err := s.queryRows(sqlText, opts.Schema, opts.TimeoutSecs)
rows, conn, cancel, messages, restore, err := s.queryRowsWithMessages(sqlText, opts.Schema, opts.TimeoutSecs)
if err != nil {
return queryResult{}, err
}
defer func() {
_ = rows.Close()
result.Messages = messages.drain()
restore()
_ = conn.Close()
s.endOperation(cancel)
}()
@@ -767,7 +773,7 @@ func (s *server) executeQuery(opts queryOptions) (queryResult, error) {
if maxRows <= 0 {
maxRows = defaultMaxRows
}
result, err := readRows(rows, maxRows)
result, err = readRows(rows, maxRows)
result.ExecutionTimeMS = time.Since(start).Milliseconds()
return result, err
}
@@ -775,7 +781,15 @@ func (s *server) executeQuery(opts queryOptions) (queryResult, error) {
if err != nil {
return queryResult{}, err
}
messages, restore, err := captureQueryMessages(conn)
if err != nil {
_ = conn.Close()
s.endOperation(cancel)
return queryResult{}, err
}
defer func() {
result.Messages = messages.drain()
restore()
_ = conn.Close()
s.endOperation(cancel)
}()
@@ -801,20 +815,42 @@ func (s *server) queryRows(sqlText string, schema string, timeoutSecs int) (*sql
return rows, conn, cancel, nil
}
func (s *server) queryRowsWithMessages(sqlText string, schema string, timeoutSecs int) (*sql.Rows, *sql.Conn, context.CancelFunc, *queryMessageBuffer, func(), error) {
conn, ctx, cancel, err := s.operationConn(schema, timeoutSecs)
if err != nil {
return nil, nil, nil, nil, nil, err
}
messages, restore, err := captureQueryMessages(conn)
if err != nil {
_ = conn.Close()
s.endOperation(cancel)
return nil, nil, nil, nil, nil, err
}
rows, err := conn.QueryContext(ctx, sqlText)
if err != nil {
restore()
_ = conn.Close()
s.endOperation(cancel)
return nil, nil, nil, nil, nil, err
}
return rows, conn, cancel, messages, restore, nil
}
func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageResult, error) {
start := time.Now()
sqlText := trimStatementSQL(opts.SQL)
if !isQuerySQL(sqlText) {
result, err := s.executeQuery(opts)
return queryPageResult{Columns: result.Columns, ColumnTypes: result.ColumnTypes, Rows: result.Rows, AffectedRows: result.AffectedRows, ExecutionTimeMS: result.ExecutionTimeMS, Truncated: result.Truncated}, err
return queryPageResult{Columns: result.Columns, ColumnTypes: result.ColumnTypes, Rows: result.Rows, AffectedRows: result.AffectedRows, ExecutionTimeMS: result.ExecutionTimeMS, Truncated: result.Truncated, Messages: result.Messages}, err
}
rows, conn, cancel, err := s.queryRows(sqlText, opts.Schema, opts.TimeoutSecs)
rows, conn, cancel, messages, restore, err := s.queryRowsWithMessages(sqlText, opts.Schema, opts.TimeoutSecs)
if err != nil {
return queryPageResult{}, err
}
columns, err := rows.Columns()
if err != nil {
_ = rows.Close()
restore()
_ = conn.Close()
s.endOperation(cancel)
return queryPageResult{}, err
@@ -824,11 +860,13 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes
if maxRows <= 0 {
maxRows = defaultMaxRows
}
session := &querySession{rows: rows, conn: conn, columns: columns, columnTypes: columnTypeNames(rows), remaining: maxRows, cancel: cancel}
session := &querySession{rows: rows, conn: conn, columns: columns, columnTypes: columnTypeNames(rows), remaining: maxRows, cancel: cancel, messages: messages, restoreMessages: restore}
result, err := readQuerySessionPage(session, pageSize)
result.Messages = messages.drain()
result.ExecutionTimeMS = time.Since(start).Milliseconds()
if err != nil {
_ = rows.Close()
restore()
_ = conn.Close()
s.endOperation(cancel)
return queryPageResult{}, err
@@ -840,6 +878,7 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes
result.SessionID = &id
} else {
_ = rows.Close()
restore()
_ = conn.Close()
s.endOperation(cancel)
}
@@ -852,6 +891,9 @@ func (s *server) fetchQueryPage(id string, pageSize int) (queryPageResult, error
return queryPageResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}}, nil
}
result, err := readQuerySessionPage(session, pageSize)
if session.messages != nil {
result.Messages = session.messages.drain()
}
if err != nil {
s.closeQuerySession(id)
return queryPageResult{}, err
@@ -870,6 +912,9 @@ func (s *server) closeQuerySession(id string) bool {
return false
}
_ = session.rows.Close()
if session.restoreMessages != nil {
session.restoreMessages()
}
if session.conn != nil {
_ = session.conn.Close()
}
@@ -997,13 +1042,21 @@ func columnTypeNames(rows *sql.Rows) []string {
return result
}
func (s *server) executeTransaction(params map[string]json.RawMessage) (queryResult, error) {
func (s *server) executeTransaction(params map[string]json.RawMessage) (result queryResult, resultErr error) {
statements := stringSliceParam(params, "statements")
conn, ctx, cancel, err := s.operationConn(stringParam(params, "schema"), intParam(params, "timeoutSecs"))
if err != nil {
return queryResult{}, err
}
messages, restore, err := captureQueryMessages(conn)
if err != nil {
_ = conn.Close()
s.endOperation(cancel)
return queryResult{}, err
}
defer func() {
result.Messages = messages.drain()
restore()
_ = conn.Close()
s.endOperation(cancel)
}()
@@ -1031,14 +1084,18 @@ func (s *server) executeTransaction(params map[string]json.RawMessage) (queryRes
func (s *server) executeBatch(params map[string]json.RawMessage) (queryResult, error) {
start := time.Now()
var affected int64
messages := &queryMessageBuffer{}
for _, statement := range stringSliceParam(params, "statements") {
result, err := s.executeQuery(queryOptions{SQL: statement, Schema: stringParam(params, "schema")})
if err != nil {
return queryResult{}, err
}
affected += result.AffectedRows
for _, message := range result.Messages {
messages.addMessage(message)
}
}
return queryResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}, AffectedRows: affected, ExecutionTimeMS: time.Since(start).Milliseconds()}, nil
return queryResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}, AffectedRows: affected, ExecutionTimeMS: time.Since(start).Milliseconds(), Messages: messages.drain()}, nil
}
func (s *server) operationConn(schema string, timeoutSecs int) (*sql.Conn, context.Context, context.CancelFunc, error) {
+63
View File
@@ -3917,6 +3917,14 @@ func TestTableDDLIncludesIdentityIndexesTriggersAndComments(t *testing.T) {
t.Fatalf("table DDL missing %q:\n%s", expected, ddl)
}
}
ddlUnqualified, err := server.getTableDDL("", "orders")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(ddlUnqualified, `"id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL`) {
t.Fatalf("identity clause missing from unqualified table DDL: %s", ddlUnqualified)
}
}
func TestRenderTableDDLIncludesEscapedComments(t *testing.T) {
@@ -4958,3 +4966,58 @@ func TestNormalizeValueKingbaseTimezoneLessDateTime(t *testing.T) {
t.Fatalf("TIMESTAMPTZ column: got %#v, want RFC3339Nano-encoded UTC instant", tzAware)
}
}
func TestCatalogColumnsPreservesJsonAndJsonbTypes(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
if !strings.Contains(query, "WHEN t.typname = 'json' THEN 'json'") ||
!strings.Contains(query, "WHEN t.typname = '_json' THEN 'json[]'") {
return nil, fmt.Errorf("columns query must distinguish json from jsonb: %s", query)
}
return &valueRows{
columns: []string{"nspname", "attname", "data_type", "nullable", "default", "comment", "precision", "scale", "length", "identity"},
rows: [][]driver.Value{
{"public", "data_json", "json", true, nil, nil, nil, nil, nil, nil},
{"public", "data_jsonb", "jsonb", true, nil, nil, nil, nil, nil, nil},
{"public", "data_json_arr", "json[]", true, nil, nil, nil, nil, nil, nil},
},
}, nil
}}
server := newServer()
server.db = openMetadataDB(t, state)
columns, err := server.getColumns("public", "test_json_table")
if err != nil {
t.Fatal(err)
}
if len(columns) != 3 {
t.Fatalf("expected 3 columns, got %d", len(columns))
}
if columns[0].DataType != "json" {
t.Fatalf("expected DataType 'json', got %q", columns[0].DataType)
}
if columns[1].DataType != "jsonb" {
t.Fatalf("expected DataType 'jsonb', got %q", columns[1].DataType)
}
if columns[2].DataType != "json[]" {
t.Fatalf("expected DataType 'json[]', got %q", columns[2].DataType)
}
ddl := server.columnDDLDefinition(columns[0])
if ddl != `"data_json" json` {
t.Fatalf("expected column DDL '\"data_json\" json', got %q", ddl)
}
ddlB := server.columnDDLDefinition(columns[1])
if ddlB != `"data_jsonb" jsonb` {
t.Fatalf("expected column DDL '\"data_jsonb\" jsonb', got %q", ddlB)
}
}
func TestQualifiedCatalogTypeExpressionHandlesJsonTypes(t *testing.T) {
expr := qualifiedCatalogTypeExpression("t", "n", "elem", "elem_n", "t.oid", "t.typtypmod")
if !strings.Contains(expr, "WHEN t.typname = 'json'") ||
!strings.Contains(expr, "THEN 'json'") ||
!strings.Contains(expr, "WHEN t.typname = '_json'") ||
!strings.Contains(expr, "THEN 'json[]'") {
t.Fatalf("qualifiedCatalogTypeExpression missing json handling: %s", expr)
}
}
@@ -0,0 +1,79 @@
package main
import (
"database/sql"
"database/sql/driver"
"reflect"
"sync"
"gitea.com/kingbase/gokb"
)
const maxQueryMessages = 1000
const maxQueryMessageBytes = 1024 * 1024
type queryMessage struct {
Severity string `json:"severity"`
Message string `json:"message"`
Code string `json:"code,omitempty"`
Detail string `json:"detail,omitempty"`
Hint string `json:"hint,omitempty"`
}
type queryMessageBuffer struct {
mu sync.Mutex
messages []queryMessage
count int
bytes int
truncated bool
}
func (buffer *queryMessageBuffer) add(notice *gokb.Error) {
buffer.addMessage(queryMessage{Severity: notice.Severity, Message: notice.Message, Code: string(notice.Code), Detail: notice.Detail, Hint: notice.Hint})
}
func (buffer *queryMessageBuffer) addMessage(message queryMessage) {
buffer.mu.Lock()
defer buffer.mu.Unlock()
if buffer.truncated {
return
}
size := len(message.Severity) + len(message.Message) + len(message.Code) + len(message.Detail) + len(message.Hint)
if buffer.count >= maxQueryMessages || size > maxQueryMessageBytes-buffer.bytes {
buffer.truncated = true
buffer.messages = append(buffer.messages, queryMessage{Severity: "WARNING", Message: "Server output limit reached; further messages are not retained."})
return
}
buffer.messages = append(buffer.messages, message)
buffer.count++
buffer.bytes += size
}
func (buffer *queryMessageBuffer) drain() []queryMessage {
buffer.mu.Lock()
defer buffer.mu.Unlock()
messages := buffer.messages
buffer.messages = nil
return messages
}
func captureQueryMessages(conn *sql.Conn) (*queryMessageBuffer, func(), error) {
buffer := &queryMessageBuffer{}
var restore func(driver.Conn)
err := conn.Raw(func(raw any) error {
driverConn := raw.(driver.Conn)
connectionType := reflect.TypeOf(driverConn)
if connectionType.Kind() != reflect.Pointer || connectionType.Elem().PkgPath() != "gitea.com/kingbase/gokb" {
return nil
}
previous := gokb.NoticeHandler(driverConn)
gokb.SetNoticeHandler(driverConn, buffer.add)
restore = func(driverConn driver.Conn) { gokb.SetNoticeHandler(driverConn, previous) }
return nil
})
return buffer, func() {
if restore != nil {
_ = conn.Raw(func(raw any) error { restore(raw.(driver.Conn)); return nil })
}
}, err
}
@@ -0,0 +1,40 @@
package main
import (
"strings"
"testing"
"gitea.com/kingbase/gokb"
)
func TestQueryMessagesPreserveNoticeFieldsAndRepeatedOutput(t *testing.T) {
buffer := &queryMessageBuffer{}
notice := &gokb.Error{Severity: "NOTICE", Message: "当前用户", Code: "00000", Detail: "detail", Hint: "hint"}
buffer.add(notice)
buffer.add(notice)
messages := buffer.drain()
if len(messages) != 2 || messages[0].Message != notice.Message || messages[0].Code != "00000" || messages[0].Detail != "detail" || messages[0].Hint != "hint" {
t.Fatalf("missing notice fields: %+v", messages)
}
if len(buffer.drain()) != 0 {
t.Fatal("page messages were delivered twice")
}
}
func TestQueryMessagesBoundCountAndBytesAcrossPages(t *testing.T) {
buffer := &queryMessageBuffer{}
for index := 0; index < maxQueryMessages; index++ {
buffer.add(&gokb.Error{Severity: "NOTICE", Message: "same"})
buffer.drain()
}
buffer.add(&gokb.Error{Message: "overflow"})
buffer.add(&gokb.Error{Message: "overflow again"})
if messages := buffer.drain(); len(messages) != 1 || messages[0].Severity != "WARNING" {
t.Fatalf("missing output cap: %+v", messages)
}
buffer = &queryMessageBuffer{}
buffer.add(&gokb.Error{Message: strings.Repeat("x", maxQueryMessageBytes+1)})
if messages := buffer.drain(); len(messages) != 1 || messages[0].Severity != "WARNING" {
t.Fatal("oversized notice was retained")
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.apache.kylin:kylin-jdbc:5.0.0'
implementation 'org.apache.kylin:kylin-jdbc:5.0.4'
}
tasks.named('shadowJar') {
+1 -1
View File
@@ -1,6 +1,6 @@
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.oceanbase:oceanbase-client:2.4.18'
implementation 'com.oceanbase:oceanbase-client:2.4.19'
testImplementation 'com.google.code.gson:gson:2.12.1'
}
@@ -0,0 +1,56 @@
package com.dbx.agent.oceanbaseoracle;
import com.oceanbase.jdbc.OceanBaseStatement;
import com.dbx.agent.BlobBoundExecutor;
import java.sql.SQLException;
import java.sql.Statement;
final class OceanBaseLobStatements {
private OceanBaseLobStatements() { }
@FunctionalInterface
interface ConnectionDiscarder {
void discard(java.sql.Connection connection) throws SQLException;
}
static final BlobBoundExecutor.PreparedStatementConfigurer BINDING = binding(java.sql.Connection::close);
static BlobBoundExecutor.PreparedStatementConfigurer binding(ConnectionDiscarder discard) {
return new BlobBoundExecutor.PreparedStatementConfigurer() {
@Override public void discardConnection(java.sql.Connection connection) throws SQLException {
discard.discard(connection);
}
@Override public void configure(java.sql.PreparedStatement statement) throws SQLException {
OceanBaseLobStatements.configure(statement);
}
@Override public boolean isRollbackConfirmedBusinessError(SQLException error) {
if (!(error instanceof java.sql.SQLTransientConnectionException)
|| error.getErrorCode() != 20001 || !"HY000".equals(error.getSQLState())) return false;
for (Throwable cause = error.getCause(); cause != null; cause = cause.getCause()) {
if (cause instanceof java.sql.SQLRecoverableException || cause instanceof java.sql.SQLTimeoutException
|| cause instanceof SQLException sql && sql.getSQLState() != null && sql.getSQLState().startsWith("08")) return false;
}
return true;
}
@Override public boolean isUnsupportedSavepointRelease(SQLException error) {
// OB Oracle mode rejects release locally; the transaction end releases its savepoints.
return error.getErrorCode() == 17023 && "99999".equals(error.getSQLState())
&& !(error instanceof java.sql.SQLTimeoutException)
&& !(error instanceof java.sql.SQLRecoverableException);
}
};
}
static void configure(Statement statement) throws SQLException {
OceanBaseStatement vendor;
try {
vendor = statement instanceof OceanBaseStatement ? (OceanBaseStatement) statement
: statement.unwrap(OceanBaseStatement.class);
} catch (SQLException error) {
throw new SQLException("Unsupported OceanBase LOB statement", error);
}
if (vendor == null) throw new SQLException("Unsupported OceanBase LOB statement");
// Set only the native LOB mode; binding, cancellation and close retain the pool proxy.
vendor.setInternal();
}
}
@@ -0,0 +1,265 @@
package com.dbx.agent.oceanbaseoracle;
import com.dbx.agent.JdbcExecutor;
import com.oceanbase.jdbc.DbxLobResourceBytes;
import java.nio.ByteBuffer;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.sql.CallableStatement;
import java.sql.Clob;
import java.sql.Blob;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
/** Captured locators belong to this agent's pinned connection, never to a new SELECT. */
final class OceanBaseLobValues {
static final String MARKER_PREFIX = "__DBX_LARGE_VALUE_BYTES_";
static final int PREVIEW_CHARACTERS = 256;
static final int MAX_CHUNK_CHARACTERS = 4096;
private static final int MAX_REFS = 4096;
private static final long MAX_RETAINED_BYTES = 64L * 1024 * 1024;
private static final long IDLE_MILLIS = 5 * 60_000L;
private static final long MAX_AGE_MILLIS = 30 * 60_000L;
private final Map<String, Entry> entries = new LinkedHashMap<>();
private final BlockReader reader;
private final java.util.function.ToLongFunction<Object> retainedBytes;
private long retainedByteCount;
@FunctionalInterface
interface BlockReader {
Chunk read(Connection connection, Object locator, long offset, int limit) throws SQLException;
}
OceanBaseLobValues() { this(OceanBaseLobValues::read, DbxLobResourceBytes::retainedBytes); }
OceanBaseLobValues(BlockReader reader) { this(reader, ignored -> 0); }
OceanBaseLobValues(BlockReader reader, java.util.function.ToLongFunction<Object> retainedBytes) { this.reader = reader; this.retainedBytes = retainedBytes; }
record Preview(String text, String ref, String kind) {
Preview(String text, String ref) { this(text, ref, "clob"); }
}
record Chunk(String status, String data, long next_offset, boolean eof, String value_kind) {}
record MarkedRows(List<String> columns, List<String> types, List<List<Object>> rows) {}
private static final class Entry {
final Connection connection;
final Object locator;
final long retainedBytes;
final long created = System.currentTimeMillis();
long accessed = created;
Entry(Connection connection, Object locator, long retainedBytes) {
this.connection = connection;
this.locator = locator;
this.retainedBytes = retainedBytes;
}
}
synchronized Object preview(ResultSet rs, int index, int sqlType, String typeName) throws SQLException {
boolean binary = sqlType == Types.BLOB || "BLOB".equalsIgnoreCase(typeName);
if ((!binary && !isCharacterLob(sqlType, typeName)) || hasMarkerCollision(rs)) return null;
// NCLOB's charset form must be verified separately; retain the complete existing read.
if (sqlType == Types.NCLOB || "NCLOB".equalsIgnoreCase(typeName)) return null;
Object locator = binary ? rs.getBlob(index) : rs.getClob(index);
String kind = binary ? "blob" : "clob";
if (locator == null || rs.wasNull()) return new Preview(null, null, kind);
Connection connection = rs.getStatement().getConnection();
prune();
long bytes = retainedBytes.applyAsLong(locator);
if (bytes < 0 || bytes > MAX_RETAINED_BYTES - retainedByteCount || entries.size() >= MAX_REFS) {
freeLocator(locator);
throw new SQLException("LOB result resource limit reached; close older results and execute again", "HY001");
}
boolean retained = false;
try {
Chunk chunk = reader.read(connection, locator, 0, PREVIEW_CHARACTERS + 1);
if (chunk.eof()) return new Preview(binary ? "0x" + chunk.data() : chunk.data(), null, kind);
String text = chunk.data();
int count = binary ? text.length() / 2 : text.codePointCount(0, text.length());
if (count <= PREVIEW_CHARACTERS) return new Preview(binary ? "0x" + text : text, null, kind);
String ref = UUID.randomUUID().toString();
entries.put(ref, new Entry(connection, locator, bytes));
retainedByteCount += bytes;
retained = true;
String preview = binary ? "0x" + text.substring(0, PREVIEW_CHARACTERS * 2)
: text.substring(0, text.offsetByCodePoints(0, PREVIEW_CHARACTERS));
return new Preview(preview, ref, kind);
} finally {
if (!retained) freeLocator(locator);
}
}
synchronized Chunk fetch(Connection connection, String ref, long offset, int limit) throws SQLException {
if (offset < 0 || offset == Long.MAX_VALUE || limit < 1 || limit > MAX_CHUNK_CHARACTERS) {
throw new IllegalArgumentException("Invalid LOB chunk offset or limit");
}
prune();
Entry entry = entries.get(ref);
if (entry == null || entry.connection != connection || connection.isClosed()) {
release(ref);
return new Chunk("expired", "", offset, false, "text");
}
entry.accessed = System.currentTimeMillis();
try {
return reader.read(connection, entry.locator, offset, limit);
} catch (SQLException error) {
release(ref);
throw error;
}
}
synchronized boolean release(String ref) {
Entry entry = entries.remove(ref);
if (entry == null) return false;
retainedByteCount -= entry.retainedBytes;
free(entry);
return true;
}
synchronized boolean hasValues() {
prune();
return !entries.isEmpty();
}
synchronized void clear() {
entries.values().forEach(OceanBaseLobValues::free);
entries.clear();
retainedByteCount = 0;
}
synchronized java.util.Set<String> checkpoint() {
return java.util.Set.copyOf(entries.keySet());
}
synchronized void releaseSince(java.util.Set<String> checkpoint) {
for (String ref : List.copyOf(entries.keySet())) {
if (!checkpoint.contains(ref)) release(ref);
}
}
private void prune() {
long now = System.currentTimeMillis();
Iterator<Entry> iterator = entries.values().iterator();
while (iterator.hasNext()) {
Entry entry = iterator.next();
if (now - entry.accessed >= IDLE_MILLIS || now - entry.created >= MAX_AGE_MILLIS) {
iterator.remove();
retainedByteCount -= entry.retainedBytes;
free(entry);
}
}
}
private static void free(Entry entry) {
try { freeLocator(entry.locator); } catch (SQLException ignored) { }
}
private static void freeLocator(Object locator) throws SQLException {
if (locator instanceof Blob) ((Blob) locator).free();
else ((Clob) locator).free();
}
static Chunk read(Connection connection, Object locator, long offset, int limit) throws SQLException {
boolean binary = locator instanceof Blob;
// OceanBase READ raises ORA-06502 for empty LOBs. Only zero is safe here:
// JDBC CLOB length counts UTF-16 units while READ offsets count code points.
long length = binary ? ((Blob) locator).length() : ((Clob) locator).length();
if (length == 0) return new Chunk("ok", "", offset, true, binary ? "binary" : "text");
try (CallableStatement call = connection.prepareCall(
"DECLARE v_lob " + (binary ? "BLOB" : "CLOB") + " := ?; v_amount INTEGER := ?; v_offset INTEGER := ?; "
+ "v_buffer " + (binary ? "RAW(32767)" : "VARCHAR2(32767)") + "; v_length INTEGER; BEGIN v_length := DBMS_LOB.GETLENGTH(v_lob); "
+ "IF v_offset > v_length THEN v_amount := 0; ELSE DBMS_LOB.READ(v_lob,v_amount,v_offset,v_buffer); END IF; "
+ "? := v_amount; ? := v_buffer; ? := v_length; END;")) {
OceanBaseLobStatements.configure(call);
if (binary) call.setBlob(1, (Blob) locator);
else call.setClob(1, (Clob) locator);
call.setInt(2, limit);
call.setLong(3, offset + 1);
call.registerOutParameter(4, Types.INTEGER);
call.registerOutParameter(5, binary ? Types.VARBINARY : Types.VARCHAR);
call.registerOutParameter(6, Types.BIGINT);
call.setQueryTimeout(20);
JdbcExecutor.current().withActiveStatement(call, () -> { call.execute(); return null; });
int amount = call.getInt(4);
byte[] bytes = call.getBytes(5);
long serverLength = call.getLong(6);
long nextOffset = Math.addExact(offset, amount);
if (amount < 0 || amount > limit || serverLength < 0 || bytes == null && amount != 0
|| offset < serverLength && (amount == 0 || nextOffset > serverLength)
|| offset >= serverLength && amount != 0) {
throw new SQLException("Invalid OceanBase LOB chunk response");
}
if (binary) {
if (bytes != null && bytes.length != amount) throw new SQLException("OceanBase BLOB byte count mismatch");
String hex = bytes == null ? "" : JdbcExecutor.bytesToHex(bytes).substring(2);
return new Chunk("ok", hex, nextOffset, nextOffset >= serverLength, "binary");
}
String text;
try {
text = bytes == null ? "" : StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes)).toString();
} catch (java.nio.charset.CharacterCodingException error) {
throw new SQLException("Invalid UTF-8 in OceanBase LOB chunk", error);
}
if (text.codePointCount(0, text.length()) != amount) throw new SQLException("OceanBase LOB character count mismatch");
return new Chunk("ok", text, nextOffset, nextOffset >= serverLength, "text");
}
}
static boolean isCharacterLob(int sqlType, String typeName) {
return sqlType == Types.CLOB || sqlType == Types.NCLOB
|| "CLOB".equalsIgnoreCase(typeName) || "NCLOB".equalsIgnoreCase(typeName);
}
private static boolean hasMarkerCollision(ResultSet rs) throws SQLException {
var metadata = rs.getMetaData();
for (int index = 1; index <= metadata.getColumnCount(); index++) {
if (metadata.getColumnLabel(index).toUpperCase(Locale.ROOT).startsWith(MARKER_PREFIX)) return true;
}
return false;
}
static MarkedRows mark(List<String> columns, List<String> types, List<List<Object>> rows) {
List<String> expandedColumns = new ArrayList<>();
List<String> expandedTypes = new ArrayList<>();
boolean[] marked = new boolean[columns.size()];
String[] kinds = new String[columns.size()];
for (List<Object> row : rows) {
for (int index = 0; index < row.size(); index++) {
if (row.get(index) instanceof Preview preview) { marked[index] = true; kinds[index] = preview.kind(); }
}
}
boolean anyMarked = false;
for (boolean value : marked) anyMarked |= value;
if (!anyMarked) return null;
for (int index = 0; index < columns.size(); index++) {
expandedColumns.add(columns.get(index));
expandedTypes.add(index < types.size() ? types.get(index) : "");
if (marked[index]) {
expandedColumns.add(MARKER_PREFIX + ("blob".equals(kinds[index]) ? "L_" : "C_") + index);
expandedTypes.add("VARCHAR");
}
}
List<List<Object>> expandedRows = new ArrayList<>();
for (List<Object> row : rows) {
List<Object> expanded = new ArrayList<>();
for (int index = 0; index < columns.size(); index++) {
Object value = row.get(index);
Preview preview = value instanceof Preview ? (Preview) value : null;
expanded.add(preview == null ? value : preview.text());
if (marked[index]) expanded.add(preview == null || preview.ref() == null ? null : "D:1:unknown:" + preview.ref());
}
expandedRows.add(expanded);
}
return new MarkedRows(expandedColumns, expandedTypes, expandedRows);
}
}
@@ -1,6 +1,8 @@
package com.dbx.agent.oceanbaseoracle;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.BlobBoundStatement;
import com.dbx.agent.BlobBoundExecutor;
import com.dbx.agent.AgentProtocol;
import com.dbx.agent.CompletionAssistantCandidate;
import com.dbx.agent.CompletionAssistantCandidateKind;
@@ -29,6 +31,7 @@ import com.dbx.agent.QueryPageOptions;
import com.dbx.agent.QueryPageResult;
import com.dbx.agent.QueryResult;
import com.dbx.agent.QueryTiming;
import com.dbx.agent.SpatialColumn;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
@@ -67,6 +70,8 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
"GGSYS", "FLOWS_FILES", "APEX_PUBLIC_USER", "GSMROOTUSER", "SYSRAC"
);
private boolean queryTimeoutChanged;
private final OceanBaseLobValues lobValues = new OceanBaseLobValues();
private boolean deferCharacterLobs;
public static final JdbcAgentProfile OCEANBASE_ORACLE_PROFILE = new JdbcAgentProfile(
"com.oceanbase.jdbc.Driver",
@@ -89,18 +94,47 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
@Override
public boolean supportsQueryTiming() { return true; }
@Override
public boolean supportsBlobBindStatements() { return true; }
@Override
public QueryResult executeBlobBoundStatements(List<String> previews, List<BlobBoundStatement> statements,
String schema, int timeoutSecs, boolean transaction) {
return BlobBoundExecutor.execute(requireConnected(), previews, statements, schema,
this::setSchemaSQL, this::resetSchemaSQL, timeoutSecs, transaction, OceanBaseLobStatements.binding(this::discardConnection));
}
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
var lobCheckpoint = lobValues.checkpoint();
try (QueryTiming timing = QueryTiming.begin()) {
prepareLobExecution(sql, options.getDeferLobs());
QueryResult result = super.executeQuery(sql, schema, options);
var marked = OceanBaseLobValues.mark(result.getColumns(), result.getColumn_types(), result.getRows());
if (marked != null) {
var spatialColumns = remapLobSpatialColumns(result.getSpatial_columns(), marked.columns());
var spatialValues = remapLobSpatialValues(result.getSpatial_values(), marked.columns());
result.setColumns(marked.columns());
result.setColumn_types(marked.types());
result.setRows(marked.rows());
result.setSpatial_columns(spatialColumns);
result.setSpatial_values(spatialValues);
}
result.setQuery_timings_ms(timing.finish());
return result;
} catch (RuntimeException error) {
lobValues.releaseSince(lobCheckpoint);
throw error;
} finally {
deferCharacterLobs = false;
}
}
@Override
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
var lobCheckpoint = lobValues.checkpoint();
try (QueryTiming timing = QueryTiming.begin()) {
prepareLobExecution(sql, options.getDeferLobs());
long prepareStarted = System.nanoTime();
Connection connection = requireConnected();
uncheckedVoid(() -> beforeQueryExecution(connection, options.getTimeoutSecs()));
@@ -108,8 +142,14 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
QueryPageResult result = JdbcExecutor.current().executeBoundedPage(
connection, sql, schema, this::setSchemaSQL, this::resetSchemaSQL, options, resultValueReader()
);
markLobPage(result);
result.setQuery_timings_ms(timing.finish());
return result;
} catch (RuntimeException error) {
lobValues.releaseSince(lobCheckpoint);
throw error;
} finally {
deferCharacterLobs = false;
}
}
@@ -117,11 +157,166 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
public QueryPageResult fetchQueryPage(String sessionId, int pageSize) {
try (QueryTiming timing = QueryTiming.begin()) {
QueryPageResult result = super.fetchQueryPage(sessionId, pageSize);
markLobPage(result);
result.setQuery_timings_ms(timing.finish());
return result;
}
}
@Override
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
var lobCheckpoint = lobValues.checkpoint();
prepareLobExecution(sql, options.getDeferLobs());
try {
QueryPageResult result = super.startTableRead(sql, schema, options);
markLobPage(result);
return result;
} catch (RuntimeException error) {
lobValues.releaseSince(lobCheckpoint);
throw error;
} finally {
deferCharacterLobs = false;
}
}
@Override
public QueryPageResult fetchTableReadPage(String sessionId, int pageSize) {
QueryPageResult result = super.fetchTableReadPage(sessionId, pageSize);
markLobPage(result);
return result;
}
private static void markLobPage(QueryPageResult result) {
var marked = OceanBaseLobValues.mark(result.getColumns(), result.getColumn_types(), result.getRows());
if (marked == null) return;
var spatialColumns = remapLobSpatialColumns(result.getSpatial_columns(), marked.columns());
var spatialValues = remapLobSpatialValues(result.getSpatial_values(), marked.columns());
result.setColumns(marked.columns());
result.setColumn_types(marked.types());
result.setRows(marked.rows());
result.setSpatial_columns(spatialColumns);
result.setSpatial_values(spatialValues);
}
private static List<SpatialColumn> remapLobSpatialColumns(List<SpatialColumn> spatial, List<String> columns) {
List<Integer> retained = new ArrayList<>();
for (int index = 0; index < columns.size(); index++) {
if (!columns.get(index).startsWith(OceanBaseLobValues.MARKER_PREFIX)) retained.add(index);
}
List<SpatialColumn> remapped = new ArrayList<>();
for (SpatialColumn column : spatial) remapped.add(new SpatialColumn(retained.get(column.getColumn_index()), column.getSrid()));
return remapped;
}
private static List<List<Integer>> remapLobSpatialValues(List<List<Integer>> spatial, List<String> columns) {
List<List<Integer>> remapped = new ArrayList<>();
for (List<Integer> row : spatial) {
List<Integer> expanded = new ArrayList<>();
int sourceIndex = 0;
for (String column : columns) {
expanded.add(column.startsWith(OceanBaseLobValues.MARKER_PREFIX) ? null : row.get(sourceIndex++));
}
remapped.add(expanded);
}
return remapped;
}
private void prepareLobExecution(String sql, boolean requested) {
// Unproven statements may end the transaction or replace an object. Fail closed.
String leading = sql.replaceFirst("(?s)^(?:\\s|--[^\\r\\n]*(?:\\r?\\n|$)|/\\*.*?\\*/)*", "");
if (!leading.matches("(?is)^SELECT\\b.*") && !isSelectWithClause(leading)) lobValues.clear();
deferCharacterLobs = requested;
}
// Recognize ordinary subquery factoring only. PL/SQL declarations and unproven
// WITH forms retain the conservative invalidation behavior.
private static boolean isSelectWithClause(String sql) {
if (!sql.matches("(?is)^WITH\\b.*")) return false;
var matcher = java.util.regex.Pattern.compile("(?s)--[^\\r\\n]*|/\\*.*?\\*/|'(?:''|[^'])*+'|\"(?:\"\"|[^\"])*+\"|[A-Za-z_][A-Za-z0-9_$#]*|\\S").matcher(sql);
List<String> tokens = new ArrayList<>();
while (matcher.find()) {
String token = matcher.group();
int end = matcher.end();
if ((token.equalsIgnoreCase("q") || token.equalsIgnoreCase("nq")) && end + 1 < sql.length() && sql.charAt(end) == '\'') {
char opening = sql.charAt(end + 1);
char closing = switch (opening) {
case '[' -> ']';
case '{' -> '}';
case '(' -> ')';
case '<' -> '>';
default -> opening;
};
int close = sql.indexOf("" + closing + '\'', end + 2);
if (Character.isWhitespace(opening) || close < 0) return false;
tokens.add("'literal'");
matcher.region(close + 2, sql.length());
continue;
}
if (!token.startsWith("--") && !token.startsWith("/*")) tokens.add(token);
}
if (tokens.isEmpty() || !tokens.get(0).equalsIgnoreCase("WITH")) return false;
int index = 1;
while (index < tokens.size()) {
String name = tokens.get(index++);
if (!name.matches("[A-Za-z_][A-Za-z0-9_$#]*|\"(?:\"\"|[^\"])++\"")) return false;
if (index < tokens.size() && tokens.get(index).equals("(")) {
index = afterLobQueryParentheses(tokens, index);
if (index < 0) return false;
}
if (index >= tokens.size() || !tokens.get(index++).equalsIgnoreCase("AS")) return false;
if (index + 1 >= tokens.size() || !tokens.get(index).equals("(") || !tokens.get(index + 1).equalsIgnoreCase("SELECT")) return false;
index = afterLobQueryParentheses(tokens, index);
if (index < 0 || index >= tokens.size()) return false;
if (tokens.get(index).equals(",")) {
index++;
continue;
}
if (!tokens.get(index).equalsIgnoreCase("SELECT")) return false;
for (int rest = index + 1; rest < tokens.size(); rest++) {
if (tokens.get(rest).equals(";") && rest != tokens.size() - 1) return false;
}
return true;
}
return false;
}
private static int afterLobQueryParentheses(List<String> tokens, int start) {
int depth = 0;
for (int index = start; index < tokens.size(); index++) {
String token = tokens.get(index);
if (token.equals(";")) return -1;
if (token.equals("(")) depth++;
else if (token.equals(")") && --depth == 0) return index + 1;
}
return -1;
}
@Override
protected JdbcExecutor.ResultValueReader resultValueReader() {
if (!deferCharacterLobs) return super.resultValueReader();
return (JdbcExecutor.ColumnAwareResultValueReader) (rs, index, sqlType, typeName) -> {
Object preview = lobValues.preview(rs, index, sqlType, typeName);
return preview == null ? resultValue(rs, index, sqlType) : preview;
};
}
@Override
protected boolean hasRetainedResultResources() { return lobValues.hasValues(); }
@Override
protected void releaseRetainedResultResources() { lobValues.clear(); }
@Override
public void invalidateLargeValues() { lobValues.clear(); }
@Override
public Object readLargeValueChunk(String valueRef, long offset, int limit) {
return unchecked(() -> lobValues.fetch(requireConnected(), valueRef, offset, limit));
}
@Override
public boolean releaseLargeValue(String valueRef) { return lobValues.release(valueRef); }
@Override
protected String buildJdbcUrl(ConnectParams params) {
return buildUrl(params);
@@ -262,7 +457,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
String.format(baseSql, placeholders(objectTypes.size())),
"OBJECT_NAME, TABLE_TYPE, COMMENTS",
"o.OBJECT_NAME",
"ORDER BY OBJECT_NAME",
"ORDER BY OBJECT_NAME, o.OBJECT_ID",
owner,
objectTypes,
constraints
@@ -319,7 +514,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
WHEN 'PACKAGE BODY' THEN 5
WHEN 'SEQUENCE' THEN 6
ELSE 7
END, OBJECT_NAME
END, OBJECT_NAME, o.OBJECT_ID
""".stripIndent().trim(),
owner,
objectTypes,
@@ -327,17 +522,13 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
);
List<ObjectInfo> result = new ArrayList<>();
String sql = query.sql;
if (constraints.hasLimit() || constraints.hasOffset()) {
sql += "\nORDER BY DBX_RN";
}
try (var stmt = requireConnection().prepareStatement(sql)) {
try (var stmt = requireConnection().prepareStatement(query.sql)) {
bind(stmt, query.args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String objectType = rs.getString(2);
result.add(new ObjectInfo(rs.getString(1),
"PACKAGE BODY".equals(objectType) ? "PACKAGE_BODY" : objectType, owner, rs.getString(3)));
objectType.replace(' ', '_'), owner, rs.getString(3)));
}
}
}
@@ -350,9 +541,196 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
if (hasTableLikeCompletionKind(request.getObject_kinds())) {
return unchecked(() -> completionAssistantTables(request));
}
if (request.getObject_kinds().stream().anyMatch(kind -> kind == CompletionAssistantObjectKind.ROUTINE
|| kind == CompletionAssistantObjectKind.PROCEDURE || kind == CompletionAssistantObjectKind.FUNCTION
|| kind == CompletionAssistantObjectKind.SEQUENCE)) {
return unchecked(() -> completionAssistantRoutines(request));
}
return super.completionAssistantSearch(request);
}
private CompletionAssistantResponse completionAssistantRoutines(CompletionAssistantRequest request) throws SQLException {
if (firstNonBlank(request.getParent_name()) != null) {
return completionAssistantPackageMembers(request);
}
int limit = boundedCompletionLimit(request.getMax_results());
String preferredSchema = preferredCompletionSchema(request);
List<CompletionAssistantObjectKind> kinds = request.getObject_kinds();
List<Object> args = new ArrayList<>();
String types = kinds.contains(CompletionAssistantObjectKind.ROUTINE)
|| (kinds.contains(CompletionAssistantObjectKind.PROCEDURE) && kinds.contains(CompletionAssistantObjectKind.FUNCTION))
? "'PROCEDURE', 'FUNCTION'"
: kinds.contains(CompletionAssistantObjectKind.PROCEDURE) ? "'PROCEDURE'"
: kinds.contains(CompletionAssistantObjectKind.FUNCTION) ? "'FUNCTION'" : "";
if (kinds.contains(CompletionAssistantObjectKind.SEQUENCE)) types += types.isEmpty() ? "'SEQUENCE'" : ", 'SEQUENCE'";
String pattern = completionLikePattern(request.getMask(), request.getMatch_mode());
args.add(request.getCase_sensitive() ? pattern : pattern.toUpperCase(Locale.ROOT));
String ownerFilter = "";
if (!request.getGlobal_search()) {
ownerFilter = " AND OWNER = ?";
args.add(firstNonBlank(request.getParent_schema(), request.getSchema(), preferredSchema));
}
args.add(preferredSchema);
args.add(limit + 1);
String sql = """
SELECT OWNER, OBJECT_NAME, OBJECT_TYPE FROM (
SELECT OWNER, OBJECT_NAME, OBJECT_TYPE FROM ALL_OBJECTS
WHERE OBJECT_TYPE IN (%s) AND %s%s
ORDER BY CASE WHEN OWNER = ? THEN 0 ELSE 1 END, OBJECT_NAME, OWNER, OBJECT_TYPE
) WHERE ROWNUM <= ?
""".formatted(types, completionNamePredicate("OBJECT_NAME", request.getCase_sensitive()), ownerFilter);
List<CompletionAssistantCandidate> candidates = new ArrayList<>();
boolean incomplete = false;
try (PreparedStatement stmt = requireConnection().prepareStatement(sql)) {
bindCompletionArgs(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
if (candidates.size() == limit) {
incomplete = true;
break;
}
String objectType = rs.getString(3);
candidates.add(new CompletionAssistantCandidate(rs.getString(2),
"PROCEDURE".equals(objectType) ? CompletionAssistantCandidateKind.PROCEDURE
: "SEQUENCE".equals(objectType) ? CompletionAssistantCandidateKind.SEQUENCE : CompletionAssistantCandidateKind.FUNCTION,
blankToNull(request.getDatabase()), rs.getString(1), null, null, null, objectType));
}
}
}
return new CompletionAssistantResponse(candidates, incomplete, false, true);
}
private CompletionAssistantResponse completionAssistantPackageMembers(CompletionAssistantRequest request) throws SQLException {
String owner = firstNonBlank(request.getParent_schema(), request.getSchema(), preferredCompletionSchema(request));
String packageName = request.getParent_name();
String objectId;
try (PreparedStatement stmt = requireConnection().prepareStatement(
"SELECT OBJECT_ID FROM ALL_OBJECTS WHERE OWNER = ? AND OBJECT_NAME = ? AND OBJECT_TYPE = 'PACKAGE'")) {
bindCompletionArgs(stmt, List.of(owner, packageName));
try (ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) throw new SQLException("Package is not visible: " + owner + "." + packageName);
objectId = rs.getString(1);
}
}
int limit = boundedCompletionLimit(request.getMax_results());
String pattern = completionLikePattern(request.getMask(), request.getMatch_mode());
List<Object> args = new ArrayList<>(List.of(owner, packageName, objectId,
request.getCase_sensitive() ? pattern : pattern.toUpperCase(Locale.ROOT)));
String kindFilter = request.getObject_kinds().contains(CompletionAssistantObjectKind.ROUTINE)
|| (request.getObject_kinds().contains(CompletionAssistantObjectKind.PROCEDURE)
&& request.getObject_kinds().contains(CompletionAssistantObjectKind.FUNCTION))
? "" : request.getObject_kinds().contains(CompletionAssistantObjectKind.FUNCTION)
? " WHERE ROUTINE_KIND = 'FUNCTION'" : " WHERE ROUTINE_KIND = 'PROCEDURE'";
args.add(limit + 1);
// Limit subprograms before loading arguments, so the last signature cannot be cut in half.
String sql = """
SELECT PROCEDURE_NAME, SUBPROGRAM_ID, ROUTINE_KIND FROM (
SELECT * FROM (
SELECT p.PROCEDURE_NAME, p.SUBPROGRAM_ID,
CASE WHEN EXISTS (SELECT 1 FROM ALL_ARGUMENTS a
WHERE a.OWNER = p.OWNER AND a.OBJECT_ID = p.OBJECT_ID
AND a.SUBPROGRAM_ID = p.SUBPROGRAM_ID AND a.DATA_LEVEL = 0 AND a.POSITION = 0)
THEN 'FUNCTION' ELSE 'PROCEDURE' END ROUTINE_KIND
FROM ALL_PROCEDURES p
WHERE p.OWNER = ? AND p.OBJECT_NAME = ? AND p.OBJECT_ID = ?
AND p.OBJECT_TYPE = 'PACKAGE' AND p.PROCEDURE_NAME IS NOT NULL AND %s
)%s ORDER BY PROCEDURE_NAME, SUBPROGRAM_ID
) WHERE ROWNUM <= ?
""".formatted(completionNamePredicate("p.PROCEDURE_NAME", request.getCase_sensitive()), kindFilter);
List<PackageCompletionMember> members = new ArrayList<>();
boolean incomplete = false;
try (PreparedStatement stmt = requireConnection().prepareStatement(sql)) {
bindCompletionArgs(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
if (members.size() == limit) { incomplete = true; break; }
members.add(new PackageCompletionMember(rs.getString(1), rs.getString(2), "FUNCTION".equals(rs.getString(3))));
}
}
}
if (!members.isEmpty()) {
String placeholders = String.join(",", Collections.nCopies(members.size(), "?"));
String argumentSql = """
SELECT a.SUBPROGRAM_ID, a.POSITION, a.ARGUMENT_NAME, a.IN_OUT, a.DATA_TYPE,
a.TYPE_OWNER, a.TYPE_NAME, a.TYPE_SUBNAME, a.DEFAULTED, type_owner_object.OWNER AS TYPE_SCHEMA
FROM ALL_ARGUMENTS a
LEFT JOIN ALL_OBJECTS type_owner_object
ON type_owner_object.OBJECT_TYPE = 'DATABASE'
AND TO_CHAR(type_owner_object.OBJECT_ID) = TRIM(a.TYPE_OWNER)
WHERE a.OWNER = ? AND a.OBJECT_ID = ? AND a.DATA_LEVEL = 0 AND a.SUBPROGRAM_ID IN (%s)
ORDER BY a.SUBPROGRAM_ID, a.POSITION, a.SEQUENCE
""".formatted(placeholders);
List<Object> argumentArgs = new ArrayList<>(List.of(owner, objectId));
Map<String, PackageCompletionMember> byId = new LinkedHashMap<>();
for (PackageCompletionMember member : members) {
argumentArgs.add(member.id);
byId.put(member.id, member);
}
try (PreparedStatement stmt = requireConnection().prepareStatement(argumentSql)) {
bindCompletionArgs(stmt, argumentArgs);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
PackageCompletionMember member = byId.get(rs.getString(1));
if (member == null) continue;
String typeName = rs.getString(7);
String typeOwner = rs.getString(6);
// OB can expose a padded database object ID instead of the schema name.
if (typeOwner != null && typeOwner.matches("[0-9]+ +")) typeOwner = rs.getString(10);
if (typeName != null && typeOwner == null) {
member.signatureKnown = false;
continue;
}
String type = typeName == null ? rs.getString(5)
: java.util.stream.Stream.of(typeOwner, typeName, rs.getString(8))
.filter(value -> value != null && !value.isBlank())
.map(OceanBaseOracleAgent::quotePackageCompletionIdentifier)
.collect(java.util.stream.Collectors.joining("."));
if (rs.getInt(2) == 0) {
member.returnType = type;
continue;
}
String name = rs.getString(3);
String direction = rs.getString(4);
if (name == null || type == null || direction == null) {
member.signatureKnown = false;
continue;
}
member.parameters.add(quotePackageCompletionIdentifier(name) + " " + direction + " " + type
+ ("Y".equals(rs.getString(9)) ? " DEFAULT" : ""));
}
}
}
}
List<CompletionAssistantCandidate> candidates = new ArrayList<>();
for (PackageCompletionMember member : members) {
String identity = owner.length() + ":" + owner + ":" + objectId + ":" + member.id;
candidates.add(new CompletionAssistantCandidate(member.name,
member.function ? CompletionAssistantCandidateKind.FUNCTION : CompletionAssistantCandidateKind.PROCEDURE,
blankToNull(request.getDatabase()), owner, owner, packageName, null, member.returnType,
member.signatureKnown ? String.join(", ", member.parameters) : null, identity));
}
return new CompletionAssistantResponse(candidates, incomplete, false, true);
}
private static String quotePackageCompletionIdentifier(String identifier) {
return identifier.matches("[A-Z][A-Z0-9_$#]*") ? identifier : "\"" + identifier.replace("\"", "\"\"") + "\"";
}
private static final class PackageCompletionMember {
final String name;
final String id;
final boolean function;
final List<String> parameters = new ArrayList<>();
boolean signatureKnown = true;
String returnType;
PackageCompletionMember(String name, String id, boolean function) {
this.name = name;
this.id = id;
this.function = function;
}
}
private CompletionAssistantResponse completionAssistantTables(CompletionAssistantRequest request) throws SQLException {
int limit = boundedCompletionLimit(request.getMax_results());
int scanLimit = Math.min(1000, Math.max(limit * 3, limit + 1));
@@ -696,7 +1074,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
sql = "SELECT " + selectList + "\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n"
+ sql
+ "\n ) DBX_Q\n WHERE ROWNUM <= ?\n)\nWHERE DBX_RN > ?";
args.add(offset + constraints.getLimit());
args.add((long) offset + constraints.getLimit());
args.add(offset);
} else if (constraints.hasOffset()) {
sql = "SELECT " + selectList + "\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n"
@@ -704,6 +1082,9 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
+ "\n ) DBX_Q\n)\nWHERE DBX_RN > ?";
args.add(constraints.getOffset());
}
if (constraints.hasLimit() || constraints.hasOffset()) {
sql += "\nORDER BY DBX_RN";
}
return new MetadataSql(sql, args);
}
@@ -722,7 +1103,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
}
private static List<String> oceanBaseObjectTypes(MetadataListConstraints constraints) {
List<String> supported = List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE BODY", "SEQUENCE", "SYNONYM");
List<String> supported = List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE BODY", "SEQUENCE", "SYNONYM", "TYPE", "TYPE BODY");
if (!constraints.hasObjectTypes()) {
return supported;
}
@@ -895,6 +1276,8 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
Object arg = args.get(index);
if (arg instanceof Integer) {
stmt.setInt(index + 1, (Integer) arg);
} else if (arg instanceof Long) {
stmt.setLong(index + 1, (Long) arg);
} else {
stmt.setString(index + 1, String.valueOf(arg));
}
@@ -913,12 +1296,148 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
return getColumnsInContext(schema, table, null);
}
@Override
public List<ColumnInfo> getColumnsInContext(String schema, String table, String currentSchema) {
return unchecked(() -> {
String owner = normalizeSchema(schema == null || schema.isBlank() ? currentSchema : schema);
String tableName = normalizeObjectName(table);
String initialOwner = owner;
String initialName = tableName;
boolean publicFallback = false;
Set<List<String>> visited = new HashSet<>();
for (int depth = 0; depth <= 32; depth++) {
if (!visited.add(List.of(owner, tableName))) {
throw new SQLException("Synonym cycle detected at " + owner + "." + tableName);
}
List<ColumnInfo> columns = getObjectColumns(owner, tableName);
if (!columns.isEmpty()) {
if (depth > 0) {
if (publicFallback) verifyPublicColumnTarget(initialOwner, initialName, owner, tableName);
String objectType = queryColumnObjectType(owner, tableName);
for (ColumnInfo column : columns) {
column.setResolved_schema(owner);
column.setResolved_table(tableName);
column.setResolved_object_type(objectType);
}
}
return columns;
}
String objectType = queryColumnObjectType(owner, tableName);
if (objectType != null && !"SYNONYM".equals(objectType)) {
throw new SQLException("Column metadata for " + owner + "." + tableName + " (" + objectType + ") is not accessible or not available");
}
SynonymTarget target = queryColumnSynonym(owner, tableName);
if (target == null && objectType == null && depth == 0 && (schema == null || schema.isBlank())) {
target = queryColumnSynonym("PUBLIC", tableName);
// OceanBase 4.2.5 exposes public synonyms under its internal dictionary owner.
if (target == null) target = queryColumnSynonym("__public", tableName);
publicFallback = target != null;
}
if (target == null) {
throw new SQLException("Object " + owner + "." + tableName + " not found or not accessible for column metadata");
}
if (target.databaseLink() != null && !target.databaseLink().isBlank()) {
throw new SQLException("Column metadata for DBLink synonyms is not supported: " + owner + "." + tableName);
}
if (target.owner() == null || target.owner().isBlank() || target.name() == null || target.name().isBlank()) {
throw new SQLException("Incomplete synonym target metadata for " + owner + "." + tableName);
}
owner = target.owner();
tableName = target.name();
}
throw new SQLException("Synonym resolution exceeded 32 links");
});
}
private record SynonymTarget(String owner, String name, String databaseLink) {}
private void verifyPublicColumnTarget(String owner, String name, String targetOwner, String targetName) throws SQLException {
// ALL_OBJECTS can hide a foreign-schema object that still shadows a public synonym.
// NAME_RESOLVE stops at DBLinks; no remote resolution is performed here.
String originalSchema = currentSchema();
if (originalSchema.isBlank()) throw new SQLException("Cannot determine current schema for public synonym resolution");
boolean switchSchema = !owner.equals(originalSchema);
Exception primaryError = null;
try {
if (switchSchema) {
try (var stmt = requireConnection().createStatement()) {
stmt.execute(setSchemaSQL(owner));
}
}
try (var stmt = requireConnection().prepareCall("BEGIN DBMS_UTILITY.NAME_RESOLVE(?, ?, ?, ?, ?, ?, ?, ?); END;")) {
stmt.setString(1, quoteIdentifier(name));
stmt.setInt(2, 0);
for (int index = 3; index <= 6; index++) stmt.registerOutParameter(index, Types.VARCHAR);
stmt.registerOutParameter(7, Types.NUMERIC);
stmt.registerOutParameter(8, Types.NUMERIC);
stmt.execute();
String databaseLink = stmt.getString(6);
if (databaseLink != null && !databaseLink.isBlank()) {
throw new SQLException("Column metadata for DBLink synonyms is not supported: " + owner + "." + name);
}
if (!targetOwner.equals(stmt.getString(3)) || !targetName.equals(stmt.getString(4))) {
throw new SQLException("Public synonym target does not match database name resolution for " + owner + "." + name
+ "; a local object may be inaccessible");
}
}
} catch (SQLException | RuntimeException error) {
primaryError = error;
throw error;
} finally {
if (switchSchema) {
try (var stmt = requireConnection().createStatement()) {
stmt.execute(setSchemaSQL(originalSchema));
} catch (SQLException restoreError) {
if (primaryError == null) throw restoreError;
primaryError.addSuppressed(restoreError);
}
}
}
}
private SynonymTarget queryColumnSynonym(String owner, String name) throws SQLException {
String sql = "SELECT TABLE_OWNER, TABLE_NAME, DB_LINK FROM ALL_SYNONYMS WHERE OWNER = ? AND SYNONYM_NAME = ?";
try (var stmt = requireConnection().prepareStatement(sql)) {
stmt.setString(1, owner);
stmt.setString(2, name);
try (ResultSet rs = stmt.executeQuery()) {
return rs.next() ? new SynonymTarget(rs.getString("TABLE_OWNER"), rs.getString("TABLE_NAME"), rs.getString("DB_LINK")) : null;
}
}
}
private String queryColumnObjectType(String owner, String name) throws SQLException {
String sql = """
SELECT OBJECT_TYPE FROM ALL_OBJECTS
WHERE OWNER = ? AND OBJECT_NAME = ?
AND OBJECT_TYPE IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW', 'SYNONYM',
'SEQUENCE', 'PROCEDURE', 'FUNCTION', 'PACKAGE', 'TYPE')
""";
try (var stmt = requireConnection().prepareStatement(sql)) {
stmt.setString(1, owner);
stmt.setString(2, name);
try (ResultSet rs = stmt.executeQuery()) {
String type = null;
while (rs.next()) {
String candidate = rs.getString("OBJECT_TYPE");
if ("MATERIALIZED VIEW".equals(candidate)) return candidate;
if (type == null || "VIEW".equals(candidate)) type = candidate;
}
return type;
}
}
}
private List<ColumnInfo> getObjectColumns(String schema, String table) {
return unchecked(() -> {
String owner = normalizeSchema(schema);
String tableName = normalizeObjectName(table);
String sql = """
SELECT c.COLUMN_NAME, c.DATA_TYPE, c.NULLABLE, c.DATA_PRECISION, c.DATA_SCALE,
c.DATA_LENGTH, c.CHAR_LENGTH, c.DATA_DEFAULT, cc.COMMENTS,
c.DATA_LENGTH, c.CHAR_LENGTH, c.CHAR_USED, c.DATA_DEFAULT, cc.COMMENTS,
CASE WHEN pk.COLUMN_NAME IS NULL THEN 0 ELSE 1 END AS IS_PK
FROM ALL_TAB_COLUMNS c
LEFT JOIN ALL_COL_COMMENTS cc
@@ -952,7 +1471,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
Integer charLen = intOrNull(rs, "CHAR_LENGTH");
result.add(new ColumnInfo(
name,
formatDataType(baseType, numPrec, numScale, dataLen, charLen),
formatDataType(baseType, numPrec, numScale, dataLen, charLen, rs.getString("CHAR_USED")),
"Y".equalsIgnoreCase(rs.getString("NULLABLE")),
defaultValue,
rs.getInt("IS_PK") == 1,
@@ -1532,17 +2051,23 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
return false;
}
private static String formatDataType(String base, Integer numPrec, Integer numScale, Integer dataLen, Integer charLen) {
private static String formatDataType(String base, Integer numPrec, Integer numScale, Integer dataLen, Integer charLen, String charUsed) {
if (base == null || base.isBlank()) {
return "";
}
return switch (base.toUpperCase(Locale.ROOT)) {
case "VARCHAR2", "NVARCHAR2", "CHAR", "NCHAR" -> {
case "VARCHAR2", "CHAR" -> {
if ("B".equalsIgnoreCase(charUsed) && dataLen != null) yield base + "(" + dataLen + " BYTE)";
if ("C".equalsIgnoreCase(charUsed) && charLen != null) yield base + "(" + charLen + " CHAR)";
Integer len = charLen == null ? dataLen : charLen;
yield len == null ? base : base + "(" + len + ")";
}
case "NVARCHAR2", "NCHAR" -> {
Integer len = charLen == null ? dataLen : charLen;
yield len == null ? base : base + "(" + len + ")";
}
case "NUMBER" -> {
if (numPrec != null && numScale != null && numScale > 0) {
if (numPrec != null && numScale != null && numScale != 0) {
yield base + "(" + numPrec + "," + numScale + ")";
}
if (numPrec != null && numPrec > 0) {
@@ -1561,6 +2086,6 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new MultiSessionJsonRpcServer(OceanBaseOracleAgent::new).run();
MultiSessionJsonRpcServer.withBlobBindings(OceanBaseOracleAgent::new).run();
}
}
@@ -0,0 +1,22 @@
package com.oceanbase.jdbc;
/** Connector/J 2.4.18 payload accounting only; never reads or exposes LOB bytes. */
public final class DbxLobResourceBytes {
private DbxLobResourceBytes() {}
public static long retainedBytes(Object value) {
if (!(value instanceof Lob lob)) return -1;
long bytes = 256; // conservative entry/locator metadata allowance
if (lob.data != null) bytes += lob.data.length;
if (lob.charData != null) bytes += 2L * lob.charData.length();
ObLobLocator locator = lob.getLocator();
if (locator != null) {
if (locator.binaryData != null) bytes += locator.binaryData.length;
if (locator instanceof ObLobLocatorV1 v1) {
if (v1.rowId != null) bytes += v1.rowId.length;
if (v1.tableId != null) bytes += v1.tableId.length;
}
}
return bytes;
}
}
@@ -0,0 +1,102 @@
package com.dbx.agent.oceanbaseoracle;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.Proxy;
import java.sql.Blob;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.Statement;
import java.sql.Types;
import java.util.HexFormat;
import java.util.List;
import java.util.Random;
import java.util.concurrent.atomic.AtomicBoolean;
class OceanBaseBlobValuesTest {
@Test
void preservesAllByteValuesAndLargeRandomContentAcrossBoundedChunks() throws Exception {
byte[] original = new byte[128 * 1024 + 7];
new Random(11438).nextBytes(original);
for (int index = 0; index < 256; index++) original[index] = (byte) index;
Fixture fixture = new Fixture();
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> {
assertSame(fixture.blob, locator);
assertTrue(limit <= 4096);
int start = (int) Math.min(original.length, offset);
int end = Math.min(original.length, start + limit);
return new OceanBaseLobValues.Chunk("ok", HexFormat.of().formatHex(original, start, end),
offset + end - start, end - start < limit, "binary");
});
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.BLOB, "BLOB");
assertEquals("0x" + HexFormat.of().formatHex(original, 0, 256), preview.text());
assertEquals("blob", preview.kind());
StringBuilder complete = new StringBuilder();
long offset = 0;
while (true) {
var chunk = values.fetch(fixture.connection, preview.ref(), offset, 4096);
assertEquals("binary", chunk.value_kind());
complete.append(chunk.data());
if (chunk.eof()) break;
offset = chunk.next_offset();
}
byte[] fetched = HexFormat.of().parseHex(complete.toString());
assertArrayEquals(original, fetched);
assertArrayEquals(java.security.MessageDigest.getInstance("SHA-256").digest(original),
java.security.MessageDigest.getInstance("SHA-256").digest(fetched));
var marked = OceanBaseLobValues.mark(List.of("PAYLOAD"), List.of("BLOB"), List.of(List.of(preview)));
assertEquals("__DBX_LARGE_VALUE_BYTES_L_0", marked.columns().get(1));
assertEquals(preview.text(), marked.rows().get(0).get(0));
values.clear();
assertTrue(fixture.freed.get());
assertEquals("expired", values.fetch(fixture.connection, preview.ref(), 0, 4096).status());
}
@Test
void distinguishesEmptyBinaryZeroByteAndNullWithoutDeferredRefs() throws Exception {
for (byte[] bytes : List.of(new byte[0], new byte[]{0}, new byte[]{0, (byte) 255})) {
Fixture fixture = new Fixture();
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) ->
new OceanBaseLobValues.Chunk("ok", HexFormat.of().formatHex(bytes), bytes.length, true, "binary"));
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.BLOB, "BLOB");
assertEquals("0x" + HexFormat.of().formatHex(bytes), preview.text());
assertNull(preview.ref());
assertTrue(fixture.freed.get());
}
Fixture fixture = new Fixture();
fixture.nullValue = true;
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> { fail("NULL must not be read"); return null; });
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.BLOB, "BLOB");
assertNull(preview.text());
assertNull(preview.ref());
}
private static final class Fixture {
final AtomicBoolean freed = new AtomicBoolean();
boolean nullValue;
final Connection connection = proxy(Connection.class, (method, args) -> method.equals("isClosed") ? false : null);
final Blob blob = proxy(Blob.class, (method, args) -> {
if (method.equals("free")) { freed.set(true); return null; }
throw new AssertionError("Unexpected full BLOB API: " + method);
});
final Statement statement = proxy(Statement.class, (method, args) -> method.equals("getConnection") ? connection : null);
final ResultSetMetaData metadata = proxy(ResultSetMetaData.class, (method, args) -> {
if (method.equals("getColumnCount")) return 1;
if (method.equals("getColumnLabel")) return "PAYLOAD";
return null;
});
final ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if (method.equals("getMetaData")) return metadata;
if (method.equals("getStatement")) return statement;
if (method.equals("getBlob")) return nullValue ? null : blob;
if (method.equals("wasNull")) return nullValue;
throw new AssertionError("Unexpected result read: " + method);
});
}
@FunctionalInterface interface Handler { Object call(String method, Object[] args) throws Throwable; }
private static <T> T proxy(Class<T> type, Handler handler) {
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type},
(object, method, args) -> handler.call(method.getName(), args)));
}
}
@@ -0,0 +1,122 @@
package com.dbx.agent.oceanbaseoracle;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assumptions;
import static org.junit.jupiter.api.Assertions.*;
import java.sql.DriverManager;
import java.sql.Types;
import java.util.UUID;
/** Opt-in isolated Oracle tenant only. Run during the unified acceptance phase. */
class OceanBaseLobSnapshotIntegrationTest {
@Test
void capturesOriginalBlobBytesAcrossCursorCloseAndConcurrentUpdate() throws Exception {
String url = System.getenv("OB_LOB_INTEGRATION_URL");
Assumptions.assumeTrue(url != null && !url.isBlank(), "Isolated OB LOB integration environment not configured");
String user = System.getenv("OB_LOB_INTEGRATION_USER");
String password = System.getenv("OB_LOB_INTEGRATION_PASSWORD");
String table = "DBX_O13_" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase();
OceanBaseLobValues values = new OceanBaseLobValues();
try (var connection = DriverManager.getConnection(url, user, password);
var other = DriverManager.getConnection(url, user, password)) {
try (var statement = connection.createStatement(); var tenant = statement.executeQuery("SELECT SYS_CONTEXT('USERENV','CON_NAME') FROM DUAL")) {
assertTrue(tenant.next());
assertEquals("oracletest", tenant.getString(1).toLowerCase(), "Refuse writes outside the authorized isolated tenant");
}
boolean created = false;
try {
try (var statement = connection.createStatement()) {
statement.execute("CREATE TABLE " + table + " (ID NUMBER PRIMARY KEY, PAYLOAD BLOB)");
created = true;
}
byte[] original = new byte[1024 * 1024 + 7];
new java.util.Random(11438).nextBytes(original);
for (int index = 0; index < 256; index++) original[index] = (byte) index;
try (var insert = connection.prepareStatement("INSERT INTO " + table + " VALUES (1, ?)")) {
insert.setBytes(1, original);
insert.executeUpdate();
}
OceanBaseLobValues.Preview preview;
try (var statement = connection.createStatement(); var result = statement.executeQuery("SELECT PAYLOAD FROM " + table + " WHERE ID = 1")) {
assertTrue(result.next());
preview = (OceanBaseLobValues.Preview) values.preview(result, 1, Types.BLOB, "BLOB");
}
assertNotNull(preview.ref());
assertEquals("0x" + java.util.HexFormat.of().formatHex(original, 0, 256), preview.text());
try (var update = other.prepareStatement("UPDATE " + table + " SET PAYLOAD = ? WHERE ID = 1")) {
update.setBytes(1, new byte[]{1, 2, 3});
assertEquals(1, update.executeUpdate());
}
StringBuilder complete = new StringBuilder();
long offset = 0;
while (true) {
var chunk = values.fetch(connection, preview.ref(), offset, 4096);
assertEquals("binary", chunk.value_kind());
complete.append(chunk.data());
if (chunk.eof()) break;
offset = chunk.next_offset();
}
assertArrayEquals(original, java.util.HexFormat.of().parseHex(complete.toString()));
} finally {
values.clear();
if (created) try (var statement = connection.createStatement()) { statement.execute("DROP TABLE " + table + " PURGE"); }
}
}
}
@Test
void capturesOriginalClobAcrossCursorCloseAndConcurrentUpdateAndDelete() throws Exception {
String url = System.getenv("OB_LOB_INTEGRATION_URL");
Assumptions.assumeTrue(url != null && !url.isBlank(), "Isolated OB LOB integration environment not configured");
String user = System.getenv("OB_LOB_INTEGRATION_USER");
String password = System.getenv("OB_LOB_INTEGRATION_PASSWORD");
String table = "DBX_O13_" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase();
OceanBaseLobValues values = new OceanBaseLobValues();
try (var connection = DriverManager.getConnection(url, user, password);
var other = DriverManager.getConnection(url, user, password)) {
try (var statement = connection.createStatement(); var tenant = statement.executeQuery("SELECT SYS_CONTEXT('USERENV','CON_NAME') FROM DUAL")) {
assertTrue(tenant.next());
assertEquals("oracletest", tenant.getString(1).toLowerCase(), "Refuse writes outside the authorized isolated tenant");
}
boolean created = false;
try {
try (var statement = connection.createStatement()) {
statement.execute("CREATE TABLE " + table + " (ID NUMBER PRIMARY KEY, PAYLOAD CLOB)");
created = true;
}
String original = "中文😀完整原值".repeat(12_000);
try (var insert = connection.prepareStatement("INSERT INTO " + table + " VALUES (1, ?)")) {
insert.setString(1, original);
insert.executeUpdate();
}
OceanBaseLobValues.Preview preview;
try (var statement = connection.createStatement(); var result = statement.executeQuery("SELECT PAYLOAD FROM " + table + " WHERE ID = 1")) {
assertTrue(result.next());
preview = (OceanBaseLobValues.Preview) values.preview(result, 1, Types.CLOB, "CLOB");
}
assertNotNull(preview.ref());
assertEquals(256, preview.text().codePointCount(0, preview.text().length()));
try (var update = other.prepareStatement("UPDATE " + table + " SET PAYLOAD = ? WHERE ID = 1")) {
update.setString(1, "已经变更😀".repeat(12_000));
assertEquals(1, update.executeUpdate());
}
try (var statement = other.createStatement()) { assertEquals(1, statement.executeUpdate("DELETE FROM " + table + " WHERE ID = 1")); }
StringBuilder complete = new StringBuilder();
long offset = 0;
while (true) {
var chunk = values.fetch(connection, preview.ref(), offset, 4096);
assertEquals("ok", chunk.status());
complete.append(chunk.data());
if (chunk.eof()) break;
offset = chunk.next_offset();
}
assertEquals(original, complete.toString());
values.clear();
assertEquals("expired", values.fetch(connection, preview.ref(), 0, 4096).status());
} finally {
values.clear();
if (created) try (var statement = connection.createStatement()) { statement.execute("DROP TABLE " + table + " PURGE"); }
}
}
}
}
@@ -0,0 +1,268 @@
package com.dbx.agent.oceanbaseoracle;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.BlobBoundExecutor;
import com.dbx.agent.BlobBoundStatement;
import com.oceanbase.jdbc.OceanBaseConnection;
import com.oceanbase.jdbc.OceanBaseStatement;
import com.oceanbase.jdbc.internal.protocol.Protocol;
import com.oceanbase.jdbc.util.Options;
import com.zaxxer.hikari.HikariDataSource;
import java.lang.reflect.Proxy;
import java.sql.Blob;
import java.sql.CallableStatement;
import java.sql.Clob;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class OceanBaseLobStatementTest {
@Test void onlyConfirmedObBusinessErrorIsEligibleForSavepointKeep() {
assertTrue(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLTransientConnectionException("ORA-20001: stale", "HY000", 20001)));
assertFalse(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLTransientConnectionException("connection lost", "08006", 20001)));
assertFalse(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLTransientConnectionException("other error", "HY000", 600)));
assertFalse(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLRecoverableException("lost", "HY000", 20001)));
assertFalse(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLTimeoutException("timeout", "HY000", 20001)));
assertFalse(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLTransientConnectionException("stale", "HY000", 20001, new SQLException("lost", "08006"))));
assertTrue(OceanBaseLobStatements.BINDING.isUnsupportedSavepointRelease(new SQLException("unsupported", "99999", 17023)));
assertFalse(OceanBaseLobStatements.BINDING.isUnsupportedSavepointRelease(new java.sql.SQLTransientConnectionException("lost", "08006", 17023)));
assertFalse(OceanBaseLobStatements.BINDING.isUnsupportedSavepointRelease(new java.sql.SQLTimeoutException("timeout", "99999", 17023)));
}
@Test
void boundPreparedAndCallableInitializeNativeModeBeforeStreamBindingThroughPool() throws Exception {
for (boolean callable : new boolean[]{false, true}) {
List<String> calls = new ArrayList<>();
Options options = new Options();
Protocol protocol = proxy(Protocol.class, (object, method, args) -> {
if (method.getName().equals("getOptions")) return options;
if (method.getName().equals("getLock")) return new ReentrantLock();
return defaultValue(method.getReturnType());
});
OceanBaseStatement vendor = new OceanBaseStatement(new OceanBaseConnection(protocol), 1003, 1007, null);
java.lang.reflect.InvocationHandler handler = (object, method, args) -> {
switch (method.getName()) {
case "unwrap": return ((Class<?>) args[0]).cast(vendor);
case "isWrapperFor": return ((Class<?>) args[0]).isInstance(vendor);
case "setBlob":
assertTrue(vendor.isInternal(), "native mode must precede the first stream binding");
assertEquals(3L, args[2]);
assertArrayEquals(new byte[]{0, (byte)255, (byte)128}, ((java.io.InputStream) args[1]).readAllBytes());
calls.add("bind"); return null;
case "execute": assertTrue(JdbcExecutor.current().hasActiveStatements()); calls.add("execute"); return false;
case "getUpdateCount": return 1;
case "close": calls.add("close"); return null;
default: return defaultValue(method.getReturnType());
}
};
PreparedStatement delegate = callable ? proxy(CallableStatement.class, handler) : proxy(PreparedStatement.class, handler);
Connection physical = proxy(Connection.class, (object, method, args) -> {
if (method.getName().equals(callable ? "prepareCall" : "prepareStatement")) return delegate;
if (method.getName().equals("getMetaData")) return proxy(java.sql.DatabaseMetaData.class, (o,m,a) -> m.getName().equals("supportsTransactions") ? true : defaultValue(m.getReturnType()));
if (method.getName().equals("getAutoCommit") || method.getName().equals("isValid")) return true;
if (method.getName().equals("getTransactionIsolation")) return Connection.TRANSACTION_READ_COMMITTED;
return defaultValue(method.getReturnType());
});
DataSource source = proxy(DataSource.class, (object, method, args) -> method.getName().equals("getConnection") ? physical : defaultValue(method.getReturnType()));
String sql = callable ? "BEGIN UPDATE t SET b = ?; END;" : "UPDATE t SET b = ?";
try (HikariDataSource pool = new HikariDataSource()) {
pool.setDataSource(source); pool.setMaximumPoolSize(1); pool.setMinimumIdle(0);
try (Connection connection = pool.getConnection()) {
BlobBoundExecutor.execute(connection, List.of("preview"),
List.of(new BlobBoundStatement("preview", sql, List.of("00ff80"))),
null, ignored -> null, () -> null, 20, false, OceanBaseLobStatements::configure);
}
}
assertEquals(List.of("bind", "execute", "close"), calls);
assertFalse(JdbcExecutor.current().hasActiveStatements());
}
}
@Test
void boundNativeInitializationFailureClosesEntirePreparedBatchBeforeAnyWrite() {
List<String> calls = new ArrayList<>();
Options options = new Options();
Protocol protocol = proxy(Protocol.class, (object, method, args) -> {
if (method.getName().equals("getOptions")) return options;
if (method.getName().equals("getLock")) return new ReentrantLock();
return defaultValue(method.getReturnType());
});
OceanBaseStatement vendor = new OceanBaseStatement(new OceanBaseConnection(protocol), 1003, 1007, null);
int[] prepared = {0};
Connection connection = proxy(Connection.class, (object, method, args) -> {
if (method.getName().equals("getAutoCommit")) return true;
if (method.getName().equals("getMetaData")) return proxy(java.sql.DatabaseMetaData.class, (o,m,a) -> m.getName().equals("supportsTransactions") ? true : defaultValue(m.getReturnType()));
if (method.getName().equals("prepareStatement")) {
int index = ++prepared[0];
return proxy(PreparedStatement.class, (o, m, a) -> {
if (m.getName().equals("unwrap")) {
if (index == 2) throw new SQLException("unsupported native wrapper");
return vendor;
}
if (m.getName().equals("close")) calls.add("close" + index);
if (m.getName().equals("execute")) calls.add("write" + index);
return defaultValue(m.getReturnType());
});
}
return defaultValue(method.getReturnType());
});
RuntimeException error = assertThrows(RuntimeException.class, () -> BlobBoundExecutor.execute(connection,
List.of("first", "second"), List.of(new BlobBoundStatement("first", "UPDATE t SET b = ?", List.of("00")),
new BlobBoundStatement("second", "UPDATE t SET b = ?", List.of("01"))),
null, ignored -> null, () -> null, 20, false, OceanBaseLobStatements::configure));
assertEquals("Unsupported OceanBase LOB statement", error.getCause().getMessage());
assertEquals(2, calls.size());
assertTrue(calls.containsAll(List.of("close1", "close2")));
assertFalse(JdbcExecutor.current().hasActiveStatements());
}
@Test
void serverLengthMarksExactLastChunkAndPastEndWithoutHidingReadErrors() throws Exception {
for (boolean binary : new boolean[]{false, true}) {
for (String scenario : List.of("exact", "past-end", "beyond-end", "full-interior", "read-error")) {
Options options = new Options();
Protocol protocol = proxy(Protocol.class, (object, method, args) -> {
if (method.getName().equals("getOptions")) return options;
if (method.getName().equals("getLock")) return new ReentrantLock();
return defaultValue(method.getReturnType());
});
OceanBaseStatement vendor = new OceanBaseStatement(new OceanBaseConnection(protocol), 1003, 1007, null);
// JDBC UTF-16 length deliberately differs from the server's code-point length.
Object locator = binary ? proxy(Blob.class, (o, m, a) -> m.getName().equals("length") ? 10L : defaultValue(m.getReturnType()))
: proxy(Clob.class, (o, m, a) -> m.getName().equals("length") ? 10L : defaultValue(m.getReturnType()));
SQLException failure = new SQLException("ORA-06502: real read failure", "HY000", 6502);
CallableStatement call = proxy(CallableStatement.class, (object, method, args) -> {
switch (method.getName()) {
case "unwrap": return vendor;
case "execute": if (scenario.equals("read-error")) throw failure; return false;
case "getInt": return scenario.equals("past-end") || scenario.equals("beyond-end") ? 0 : 3;
case "getLong": return scenario.equals("full-interior") ? 9L : 8L;
case "getBytes": return scenario.equals("past-end") || scenario.equals("beyond-end") ? null : binary ? new byte[]{0, (byte) 255, 1} : "中😀末".getBytes(java.nio.charset.StandardCharsets.UTF_8);
default: return defaultValue(method.getReturnType());
}
});
Connection connection = proxy(Connection.class, (object, method, args) -> method.getName().equals("prepareCall") ? call : defaultValue(method.getReturnType()));
if (scenario.equals("read-error")) {
assertSame(failure, assertThrows(SQLException.class, () -> OceanBaseLobValues.read(connection, locator, 5, 3)));
} else {
var chunk = OceanBaseLobValues.read(connection, locator, scenario.equals("beyond-end") ? 9 : scenario.equals("past-end") ? 8 : 5, 3);
assertEquals(scenario.equals("past-end") || scenario.equals("beyond-end") ? "" : binary ? "00ff01" : "中😀末", chunk.data());
assertEquals(scenario.equals("beyond-end") ? 9 : 8, chunk.next_offset());
assertEquals(!scenario.equals("full-interior"), chunk.eof(), "EOF must use server length, not JDBC UTF-16 length");
}
assertFalse(JdbcExecutor.current().hasActiveStatements());
}
}
}
@Test
void emptyLocatorReturnsEmptyEofWithoutCallingDbmsLobRead() throws Exception {
for (boolean binary : new boolean[]{false, true}) {
java.lang.reflect.InvocationHandler handler = (object, method, args) -> {
if (method.getName().equals("length")) return 0L;
throw new AssertionError("Empty LOB must not be materialized");
};
Object locator = binary ? proxy(Blob.class, handler) : proxy(Clob.class, handler);
Connection connection = proxy(Connection.class, (object, method, args) -> {
if (method.getName().equals("prepareCall")) throw new SQLException("ORA-06502: no data found", "HY000", 6502);
throw new AssertionError("Empty LOB must not execute a statement");
});
var chunk = OceanBaseLobValues.read(connection, locator, 0, 257);
assertEquals("", chunk.data());
assertEquals(0, chunk.next_offset());
assertTrue(chunk.eof());
assertEquals(binary ? "binary" : "text", chunk.value_kind());
assertFalse(JdbcExecutor.current().hasActiveStatements());
}
}
@Test
void pooledCallableUnwrapsVendorOnlyForInternalFlagAndKeepsLifecycleOnProxy() throws Exception {
for (boolean binary : new boolean[]{false, true}) {
List<String> calls = new ArrayList<>();
Options options = new Options();
Protocol protocol = proxy(Protocol.class, (object, method, args) -> {
if (method.getName().equals("getOptions")) return options;
if (method.getName().equals("getLock")) return new ReentrantLock();
return defaultValue(method.getReturnType());
});
OceanBaseStatement vendor = new OceanBaseStatement(new OceanBaseConnection(protocol), 1003, 1007, null);
Object locator = binary ? proxy(Blob.class, (o, m, a) -> m.getName().equals("length") ? 8L : defaultValue(m.getReturnType()))
: proxy(Clob.class, (o, m, a) -> m.getName().equals("length") ? 8L : defaultValue(m.getReturnType()));
CallableStatement delegate = proxy(CallableStatement.class, (object, method, args) -> {
switch (method.getName()) {
case "unwrap": return ((Class<?>) args[0]).cast(vendor);
case "isWrapperFor": return ((Class<?>) args[0]).isInstance(vendor);
case "setBlob", "setClob": assertSame(locator, args[1]); calls.add(method.getName()); return null;
case "setInt": assertEquals(3, args[1]); return null;
case "setLong": assertEquals(6L, args[1]); return null;
case "setQueryTimeout": assertEquals(20, args[0]); return null;
case "registerOutParameter": assertEquals((int) args[0] == 4 ? Types.INTEGER : (int) args[0] == 5 ? (binary ? Types.VARBINARY : Types.VARCHAR) : Types.BIGINT, args[1]); return null;
case "execute":
assertTrue(vendor.isInternal());
assertTrue(JdbcExecutor.current().hasActiveStatements());
JdbcExecutor.current().cancelActiveStatements();
calls.add("execute"); return false;
case "cancel", "close": calls.add(method.getName()); return null;
case "getInt": return 2;
case "getLong": return 7L;
case "getBytes": return binary ? new byte[]{0, (byte) 255} : "中😀".getBytes(java.nio.charset.StandardCharsets.UTF_8);
default: return defaultValue(method.getReturnType());
}
});
Connection physical = proxy(Connection.class, (object, method, args) -> {
if (method.getName().equals("prepareCall")) return delegate;
if (method.getName().equals("getAutoCommit") || method.getName().equals("isValid")) return true;
if (method.getName().equals("getTransactionIsolation")) return Connection.TRANSACTION_READ_COMMITTED;
return defaultValue(method.getReturnType());
});
DataSource source = proxy(DataSource.class, (object, method, args) -> method.getName().equals("getConnection") ? physical : defaultValue(method.getReturnType()));
try (HikariDataSource pool = new HikariDataSource()) {
pool.setDataSource(source);
pool.setMaximumPoolSize(1);
pool.setMinimumIdle(0);
try (Connection connection = pool.getConnection()) {
var chunk = OceanBaseLobValues.read(connection, locator, 5, 3);
assertEquals(binary ? "00ff" : "中😀", chunk.data());
assertEquals(7, chunk.next_offset());
assertTrue(chunk.eof());
assertEquals(binary ? "binary" : "text", chunk.value_kind());
}
}
assertEquals(List.of(binary ? "setBlob" : "setClob", "cancel", "execute", "close"), calls);
assertFalse(JdbcExecutor.current().hasActiveStatements());
}
}
@Test
void nonOceanBaseCallableIsRejectedAndClosedBeforeExecuting() {
List<String> calls = new ArrayList<>();
CallableStatement call = proxy(CallableStatement.class, (object, method, args) -> {
if (method.getName().equals("unwrap")) throw new SQLException("Not a vendor wrapper");
if (method.getName().equals("close") || method.getName().equals("execute")) calls.add(method.getName());
return defaultValue(method.getReturnType());
});
Connection connection = proxy(Connection.class, (object, method, args) -> method.getName().equals("prepareCall") ? call : defaultValue(method.getReturnType()));
SQLException error = assertThrows(SQLException.class, () -> OceanBaseLobValues.read(connection, proxy(Clob.class, (o, m, a) -> m.getName().equals("length") ? 1L : null), 0, 1));
assertEquals("Unsupported OceanBase LOB statement", error.getMessage());
assertEquals(List.of("close"), calls);
assertFalse(JdbcExecutor.current().hasActiveStatements());
}
private static Object defaultValue(Class<?> type) {
if (type == boolean.class) return false;
if (type == int.class) return 0;
if (type == long.class) return 0L;
return null;
}
private static <T> T proxy(Class<T> type, java.lang.reflect.InvocationHandler handler) {
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
}
}
@@ -0,0 +1,223 @@
package com.dbx.agent.oceanbaseoracle;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.QueryPageOptions;
import com.dbx.agent.test.TestSupport;
import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.Proxy;
import java.sql.Clob;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.Statement;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
class OceanBaseLobValuesTest {
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"query", "page", "table"})
void longCteLiteralReachesJdbcAndRetainsPreviousLocator(String entryPoint) throws Exception {
checkFailedStatement(entryPoint, false, "WITH x AS (SELECT '" + "a".repeat(4000) + "' AS s FROM dual) SELECT * FROM x", true);
}
@ParameterizedTest
@CsvSource({"query,false", "query,true", "page,false", "page,true", "table,false", "table,true", "cte-query,false", "cte-page,true", "cte-table,true"})
void failedReadReleasesOnlyItsNewLocators(String entryPoint, boolean capturePartial) throws Exception {
String sql = entryPoint.startsWith("cte-") ? "WITH x AS (SELECT 1 AS n FROM dual) SELECT * FROM x" : "SELECT INVALID FROM DUAL";
checkFailedStatement(entryPoint.replace("cte-", ""), capturePartial, sql, true);
}
@ParameterizedTest
@CsvSource(delimiter = '|', quoteCharacter = '`', value = {
"WITH x(n) AS (SELECT 1 FROM dual), y AS (SELECT n FROM x) SELECT * FROM y|true",
"WITH c AS (SELECT q'[it's (]' txt FROM dual) SELECT txt FROM c|true",
"WITH c AS (SELECT q'[it's ;]' txt FROM dual) SELECT txt FROM c|true",
"WITH c AS (SELECT Q'!it's ; (!' txt FROM dual) SELECT txt FROM c|true",
"WITH \"x\" AS (SELECT ') ; UPDATE' FROM dual /* ) */) SELECT * FROM \"x\"|true",
"WITH x AS (SELECT 1 FROM dual) UPDATE target SET n = 1|false",
"WITH x AS (DELETE FROM target RETURNING n) SELECT * FROM x|false",
"WITH x AS (SELECT 1 FROM dual) SELECT * FROM x; COMMIT|false",
"WITH FUNCTION f RETURN NUMBER IS BEGIN RETURN 1; END; SELECT f FROM dual|false",
"COMMIT|false",
"ROLLBACK|false"
})
void onlyConfirmedCteQueriesRetainOldLocators(String sql, boolean retain) throws Exception {
checkFailedStatement("query", false, sql, retain);
}
private void checkFailedStatement(String entryPoint, boolean capturePartial, String sql, boolean retain) throws Exception {
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> chunk("x".repeat(1000), offset, limit));
Fixture old = new Fixture();
var retained = (OceanBaseLobValues.Preview) values.preview(old.resultSet, 1, Types.CLOB, "CLOB");
Fixture partial = new Fixture();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
var field = OceanBaseOracleAgent.class.getDeclaredField("lobValues");
field.setAccessible(true);
field.set(agent, values);
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
if (method.equals("isClosed")) return false;
if (method.equals("createStatement")) {
if (capturePartial) values.preview(partial.resultSet, 1, Types.CLOB, "CLOB");
throw new java.sql.SQLException("failed request", "42000");
}
return null;
}));
assertThrows(RuntimeException.class, () -> {
switch (entryPoint) {
case "query" -> agent.executeQuery(sql, null, new ExecuteQueryOptions(10, null, 5));
case "page" -> agent.executeQueryPage(sql, null, new QueryPageOptions(10, null, 10, 5));
default -> agent.startTableRead(sql, null, new QueryPageOptions(10, null, 10, 5));
}
});
assertEquals(!retain, old.freed.get());
assertEquals(capturePartial, partial.freed.get());
if (retain) assertEquals("ok", values.fetch(old.connection, retained.ref(), 0, 10).status());
agent.invalidateLargeValues();
assertTrue(old.freed.get());
}
@Test
void refusesNewLocatorsWhenRetainedPayloadBudgetIsFullAndRestoresCapacityOnRelease() throws Exception {
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> chunk("x".repeat(1000), offset, limit), ignored -> 40L * 1024 * 1024);
Fixture first = new Fixture();
var preview = (OceanBaseLobValues.Preview) values.preview(first.resultSet, 1, Types.CLOB, "CLOB");
Fixture second = new Fixture();
assertThrows(java.sql.SQLException.class, () -> values.preview(second.resultSet, 1, Types.CLOB, "CLOB"));
assertTrue(second.freed.get());
assertTrue(values.release(preview.ref()));
Fixture third = new Fixture();
assertNotNull(((OceanBaseLobValues.Preview) values.preview(third.resultSet, 1, Types.CLOB, "CLOB")).ref());
values.clear();
Fixture fourth = new Fixture();
assertNotNull(((OceanBaseLobValues.Preview) values.preview(fourth.resultSet, 1, Types.CLOB, "CLOB")).ref());
values.clear();
}
@Test
void boundsPreviewAndReadsTheSameCapturedLocatorWithoutMaterializingItsStreams() throws Exception {
String original = "中文😀".repeat(1000);
List<Integer> amounts = new ArrayList<>();
Fixture fixture = new Fixture();
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> {
assertSame(fixture.connection, connection);
assertSame(fixture.clob, locator);
amounts.add(limit);
return chunk(original, offset, limit);
});
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.CLOB, "CLOB");
assertEquals(256, preview.text().codePointCount(0, preview.text().length()));
assertFalse(Character.isHighSurrogate(preview.text().charAt(preview.text().length() - 1)));
assertNotNull(preview.ref());
assertEquals(List.of(257), amounts);
StringBuilder complete = new StringBuilder();
long offset = 0;
while (true) {
var next = values.fetch(fixture.connection, preview.ref(), offset, 31);
complete.append(next.data());
if (next.eof()) break;
assertTrue(next.next_offset() > offset);
offset = next.next_offset();
}
assertEquals(original, complete.toString());
assertFalse(fixture.freed.get());
assertTrue(values.release(preview.ref()));
assertTrue(fixture.freed.get());
assertEquals("expired", values.fetch(fixture.connection, preview.ref(), 0, 31).status());
}
@Test
void distinguishesNullEmptyShortAndDeferredValues() throws Exception {
for (String text : List.of("", "短😀值", "x".repeat(257))) {
Fixture fixture = new Fixture();
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> chunk(text, offset, limit));
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.CLOB, "CLOB");
// A value exactly filling the look-ahead chunk remains deferred until EOF is known.
if (text.length() < 257) {
assertEquals(text, preview.text());
assertNull(preview.ref());
assertTrue(fixture.freed.get());
} else assertNotNull(preview.ref());
values.clear();
}
Fixture fixture = new Fixture();
fixture.nullValue = true;
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> { fail("NULL must not be read"); return null; });
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.CLOB, "CLOB");
assertNull(preview.text());
assertNull(preview.ref());
}
@Test
void rejectsWrongConnectionAndInvalidChunkBoundsAndInvalidatesOnClear() throws Exception {
Fixture fixture = new Fixture();
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> chunk("原值".repeat(1000), offset, limit));
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.CLOB, "CLOB");
assertThrows(IllegalArgumentException.class, () -> values.fetch(fixture.connection, preview.ref(), -1, 1));
assertThrows(IllegalArgumentException.class, () -> values.fetch(fixture.connection, preview.ref(), 0, 4097));
assertEquals("expired", values.fetch(new Fixture().connection, preview.ref(), 0, 1).status());
Fixture nextFixture = new Fixture();
var nextPreview = (OceanBaseLobValues.Preview) values.preview(nextFixture.resultSet, 1, Types.CLOB, "CLOB");
values.clear();
assertEquals("expired", values.fetch(nextFixture.connection, nextPreview.ref(), 0, 1).status());
assertTrue(fixture.freed.get());
assertTrue(nextFixture.freed.get());
}
@Test
void markerCollisionFallsBackBeforeReadingOrTruncatingAndRefsStayOutOfValues() throws Exception {
Fixture fixture = new Fixture();
fixture.label = "__dbx_large_value_bytes_c_0";
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> { fail("Collision must keep complete ordinary read"); return null; });
assertNull(values.preview(fixture.resultSet, 1, Types.CLOB, "CLOB"));
var marked = OceanBaseLobValues.mark(List.of("ID", "Payload"), List.of("NUMBER", "CLOB"),
List.of(List.of(1, new OceanBaseLobValues.Preview("原始😀", "opaque-ref")),
java.util.Arrays.asList(2, new OceanBaseLobValues.Preview(null, null))));
assertEquals(List.of("ID", "Payload", "__DBX_LARGE_VALUE_BYTES_C_1"), marked.columns());
assertEquals("原始😀", marked.rows().get(0).get(1));
assertEquals("D:1:unknown:opaque-ref", marked.rows().get(0).get(2));
assertNull(marked.rows().get(1).get(1));
assertNull(marked.rows().get(1).get(2));
}
private static OceanBaseLobValues.Chunk chunk(String text, long offset, int limit) {
int count = text.codePointCount(0, text.length());
int start = Math.min(count, (int) offset);
int end = Math.min(count, start + limit);
String data = text.substring(text.offsetByCodePoints(0, start), text.offsetByCodePoints(0, end));
return new OceanBaseLobValues.Chunk("ok", data, offset + end - start, end - start < limit, "text");
}
private static final class Fixture {
final AtomicBoolean freed = new AtomicBoolean();
boolean nullValue;
String label = "PAYLOAD";
final Connection connection = proxy(Connection.class, (method, args) -> method.equals("isClosed") ? false : null);
final Clob clob = proxy(Clob.class, (method, args) -> {
if (method.equals("free")) { freed.set(true); return null; }
throw new AssertionError("Unexpected full LOB API: " + method);
});
final Statement statement = proxy(Statement.class, (method, args) -> method.equals("getConnection") ? connection : null);
final ResultSetMetaData metadata = proxy(ResultSetMetaData.class, (method, args) -> {
if (method.equals("getColumnCount")) return 1;
if (method.equals("getColumnLabel")) return label;
return null;
});
final ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
if (method.equals("getMetaData")) return metadata;
if (method.equals("getStatement")) return statement;
if (method.equals("getClob")) return nullValue ? null : clob;
if (method.equals("wasNull")) return nullValue;
throw new AssertionError("Unexpected result read: " + method);
});
}
@FunctionalInterface interface Handler { Object call(String method, Object[] args) throws Throwable; }
private static <T> T proxy(Class<T> type, Handler handler) {
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type},
(object, method, args) -> handler.call(method.getName(), args)));
}
}
@@ -1,6 +1,7 @@
package com.dbx.agent.oceanbaseoracle;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.CompletionAssistantCandidateKind;
import com.dbx.agent.CompletionAssistantMatchMode;
import com.dbx.agent.CompletionAssistantObjectKind;
import com.dbx.agent.CompletionAssistantRequest;
@@ -485,6 +486,220 @@ class OceanBaseOracleAgentTest {
Assertions.assertEquals(List.of("ORD%", "STAGING", "ORD%", "STAGING", "DWD", "ORD", 21), query.args);
}
@Test
void completionAssistantSearchReturnsBoundedStandaloneRoutines() {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
new Object[][]{{"APP", "CALC", "PROCEDURE"}, {"APP", "CALCULATE", "FUNCTION"}, {"OTHER", "CALCULATE", "FUNCTION"}}
)));
CompletionAssistantRequest request = completionRequest("APP", null, "CAL", true);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "max_results", 2);
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
Assertions.assertEquals(List.of("CALC", "CALCULATE"), response.getCandidates().stream().map(candidate -> candidate.getName()).toList());
Assertions.assertEquals(List.of("procedure", "function"), response.getCandidates().stream().map(candidate -> candidate.getKind().name().toLowerCase(Locale.ROOT)).toList());
Assertions.assertEquals("APP", response.getCandidates().get(0).getSchema());
Assertions.assertNull(response.getCandidates().get(0).getParent_name());
Assertions.assertTrue(response.getIncomplete());
Assertions.assertFalse(response.getFallback_used());
Assertions.assertTrue(sql.get(0).contains("ROWNUM <= ?"));
Assertions.assertTrue(new com.google.gson.Gson().toJsonTree(response).getAsJsonObject().has("routine_search_supported"));
}
@Test
void mixedRoutineSearchPreservesRequestedSequences() {
for (CompletionAssistantObjectKind routineKind : List.of(CompletionAssistantObjectKind.ROUTINE, CompletionAssistantObjectKind.FUNCTION)) {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
new Object[][]{{"APP", "ORDER_SEQ", "SEQUENCE"}}
)));
CompletionAssistantRequest request = completionRequest("APP", null, "ORDER", false);
setField(request, "object_kinds", List.of(routineKind, CompletionAssistantObjectKind.SEQUENCE));
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
Assertions.assertTrue(sql.get(0).contains("'SEQUENCE'"));
Assertions.assertEquals(CompletionAssistantCandidateKind.SEQUENCE, response.getCandidates().get(0).getKind());
}
}
@Test
void sequenceOnlySearchUsesOceanBaseFilteringWithoutAddingFunctions() {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
new Object[][]{{"APP", "ORDER_SEQ", "SEQUENCE"}}
)));
CompletionAssistantRequest request = completionRequest("APP", null, "ORDER", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.SEQUENCE));
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
Assertions.assertEquals(1, response.getCandidates().size());
Assertions.assertEquals(CompletionAssistantCandidateKind.SEQUENCE, response.getCandidates().get(0).getKind());
Assertions.assertTrue(sql.get(0).contains("OBJECT_TYPE IN ('SEQUENCE')"));
Assertions.assertFalse(sql.get(0).contains("'FUNCTION'"));
Assertions.assertFalse(response.getFallback_used());
}
@Test
void routineSearchPreservesQuotedOwnersAndEscapesLiteralMasks() {
List<String> sql = new ArrayList<>();
List<String> params = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, params, resultSet(
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
new Object[][]{{"Mixed.Owner", "中文_%", "PROCEDURE"}}
)));
CompletionAssistantRequest request = completionRequest("APP", "Mixed.Owner", "中文_%", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.PROCEDURE));
setField(request, "case_sensitive", true);
setField(request, "match_mode", CompletionAssistantMatchMode.CONTAINS);
setField(request, "max_results", 1);
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
Assertions.assertEquals(List.of("%中文\\_\\%%", "Mixed.Owner", "APP"), params);
Assertions.assertTrue(sql.get(0).contains("OBJECT_TYPE IN ('PROCEDURE')"));
Assertions.assertTrue(sql.get(0).contains("OWNER = ?"));
Assertions.assertFalse(sql.get(0).contains("UPPER(OBJECT_NAME)"));
Assertions.assertEquals("中文_%", response.getCandidates().get(0).getName());
Assertions.assertFalse(response.getIncomplete(), "Exactly the requested count is complete");
}
@Test
void routineSearchDoesNotReturnStandaloneCandidatesForAPackageRequest() {
CompletionAssistantRequest request = completionRequest("APP", null, "P", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
Assertions.assertThrows(RuntimeException.class, () -> new OceanBaseOracleAgent().completionAssistantSearch(request));
}
@Test
void packageCompletionKeepsNoArgumentMembersAndBoundedOverloadsWithCompleteSignatures() {
List<String> sql = new ArrayList<>();
List<String> params = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, params,
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"},
new Object[][]{{"RUN", 1, "PROCEDURE"}, {"RUN", 2, "FUNCTION"}, {"RUN", 3, "FUNCTION"}}),
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED"},
new Object[][]{
{2, 0, null, "OUT", "NUMBER", null, null, null, "N"},
{2, 1, "INPUT", "IN", "NUMBER", null, null, null, "Y"},
{2, 2, "RESULT", "IN/OUT", "PL/SQL RECORD", "Mixed.Owner", "Types", "Payload", "N"}
})
));
CompletionAssistantRequest request = completionRequest("APP", "Mixed.Owner", "Ru_%", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "Mixed.Package");
setField(request, "case_sensitive", true);
setField(request, "max_results", 2);
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
Assertions.assertTrue(response.getIncomplete());
Assertions.assertEquals(2, response.getCandidates().size());
var procedure = response.getCandidates().get(0);
var function = response.getCandidates().get(1);
Assertions.assertEquals("", procedure.getSignature());
Assertions.assertEquals("INPUT IN NUMBER DEFAULT, RESULT IN/OUT \"Mixed.Owner\".\"Types\".\"Payload\"", function.getSignature());
Assertions.assertEquals("NUMBER", function.getData_type());
Assertions.assertEquals("Mixed.Owner", function.getParent_schema());
Assertions.assertEquals("Mixed.Package", function.getParent_name());
Assertions.assertNotEquals(procedure.getRoutine_id(), function.getRoutine_id());
Assertions.assertTrue(sql.get(0).contains("OBJECT_TYPE = 'PACKAGE'"));
Assertions.assertTrue(sql.get(1).contains("p.OBJECT_ID = ?"));
Assertions.assertTrue(sql.get(1).contains("ROWNUM <= ?"));
Assertions.assertTrue(sql.get(2).contains("DATA_LEVEL = 0"));
Assertions.assertTrue(sql.get(2).contains("SUBPROGRAM_ID IN (?,?)"));
Assertions.assertFalse(sql.get(2).contains("ROWNUM"));
Assertions.assertTrue(params.contains("Ru\\_\\%%"));
var wire = new com.google.gson.Gson().toJsonTree(response).getAsJsonObject().getAsJsonArray("candidates");
Assertions.assertEquals("", wire.get(0).getAsJsonObject().get("signature").getAsString());
Assertions.assertEquals(function.getRoutine_id(), wire.get(1).getAsJsonObject().get("routine_id").getAsString());
}
@Test
void packageCompletionResolvesInternalTypeOwnerThroughDatabaseIdentity() {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql,
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"OBJ", 6, "PROCEDURE"}}),
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED", "TYPE_SCHEMA"},
new Object[][]{{6, 1, "X", "IN", "EXT", "514099 ", "Base.Type", null, "N", " Actual.Owner "}})
));
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
Assertions.assertEquals("X IN \" Actual.Owner \".\"Base.Type\"", agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
Assertions.assertTrue(sql.get(2).contains("type_owner_object.OBJECT_TYPE = 'DATABASE'"));
Assertions.assertTrue(sql.get(2).contains("TO_CHAR(type_owner_object.OBJECT_ID) = TRIM(a.TYPE_OWNER)"));
}
@Test
void packageCompletionKeepsLiteralNumericTypeOwner() {
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"OBJ", 6, "PROCEDURE"}}),
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED"},
new Object[][]{{6, 1, "X", "IN", "EXT", "514099", "Base.Type", null, "N"}})
));
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
Assertions.assertEquals("X IN \"514099\".\"Base.Type\"", agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
}
@Test
void packageCompletionDoesNotInventUnresolvedInternalTypeOwner() {
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"OBJ", 6, "PROCEDURE"}}),
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED", "TYPE_SCHEMA"},
new Object[][]{{6, 1, "X", "IN", "EXT", "514099 ", "Base.Type", null, "N", null}})
));
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
Assertions.assertNull(agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
}
@Test
void packageCompletionDoesNotInventMissingParameterMetadata() {
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"RUN", 1, "PROCEDURE"}}),
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED"},
new Object[][]{{1, 1, "P", "IN", null, null, null, null, "N"}})
));
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
Assertions.assertNull(agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
}
@Test
void packageCompletionDistinguishesVisibleEmptyPackageFromInvisiblePackage() {
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
setField(request, "parent_name", "PKG");
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{})));
Assertions.assertTrue(agent.completionAssistantSearch(request).getCandidates().isEmpty());
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{})));
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> agent.completionAssistantSearch(request));
Assertions.assertTrue(error.toString().contains("Package is not visible"));
}
@Test
void completionAssistantSearchReturnsGlobalTableCandidates() {
List<String> sql = new ArrayList<>();
@@ -785,6 +1000,28 @@ class OceanBaseOracleAgentTest {
Assertions.assertTrue(sql.get(0).contains("c.DATA_DEFAULT"), sql.get(0));
}
@Test
void getColumnsPreservesCharacterUnitsAndNegativeScale() {
List<String> sql = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, preparedConnection(sql, columnResultSet(new Object[][]{
{"VB", "VARCHAR2", "Y", null, null, 8, 8, null, null, 0, "B"},
{"VC", "VARCHAR2", "Y", null, null, 32, 8, null, null, 0, "C"},
{"NC", "NVARCHAR2", "Y", null, null, 32, 8, null, null, 0, "C"},
{"CB", "CHAR", "Y", null, null, 12, 4, null, null, 0, "B"},
{"CC", "CHAR", "Y", null, null, 32, 8, null, null, 0, "C"},
{"NR", "NUMBER", "Y", 10, -2, 22, 0, null, null, 0, null},
{"NZ", "NUMBER", "Y", 10, 0, 22, 0, null, null, 0, null},
{"NP", "NUMBER", "Y", 10, 2, 22, 0, null, null, 0, null},
{"NU", "NUMBER", "Y", null, null, 22, 0, null, null, 0, null},
{"VU", "VARCHAR2", "Y", null, null, 32, 8, null, null, 0, null}
})));
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
Assertions.assertEquals(List.of("VARCHAR2(8 BYTE)", "VARCHAR2(8 CHAR)", "NVARCHAR2(8)", "CHAR(12 BYTE)", "CHAR(8 CHAR)", "NUMBER(10,-2)", "NUMBER(10)", "NUMBER(10,2)", "NUMBER", "VARCHAR2(8)"),
columns.stream().map(ColumnInfo::getData_type).toList());
Assertions.assertTrue(sql.get(0).contains("c.CHAR_USED"));
}
@Test
void getColumnsResolvesTheCurrentSchemaWhenSchemaIsEmpty() {
List<String> sql = new ArrayList<>();
@@ -1054,9 +1291,10 @@ class OceanBaseOracleAgentTest {
"CHAR_LENGTH",
"DATA_DEFAULT",
"COMMENTS",
"IS_PK"
"IS_PK",
"CHAR_USED"
},
rows
java.util.Arrays.stream(rows).map(row -> java.util.Arrays.copyOf(row, 11)).toArray(Object[][]::new)
);
}
@@ -57,7 +57,7 @@ class OceanBaseOracleObjectListTest {
case "_" -> "%\\_%";
default -> "%\\\\%";
};
assertEquals(List.of("Mixed.Owner", "TABLE", pattern, pattern, 3, 2), call.args);
assertEquals(List.of("Mixed.Owner", "TABLE", pattern, pattern, 3L, 2), call.args);
call.assertClosed();
}
@@ -78,13 +78,13 @@ class OceanBaseOracleObjectListTest {
};
assertEquals(List.of("APP", "TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE",
"PACKAGE BODY", "SEQUENCE", "SYNONYM"), call.args);
"PACKAGE BODY", "SEQUENCE", "SYNONYM", "TYPE", "TYPE BODY"), call.args);
assertEquals(List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE",
"PACKAGE_BODY", "SEQUENCE", "SYNONYM"), objects.stream().map(ObjectInfo::getObject_type).toList());
assertEquals(8, objects.size());
assertTrue(objects.stream().allMatch(object -> "APP".equals(object.getSchema())));
assertTrue(objects.stream().allMatch(object -> object.getValid() == null));
assertTrue(call.sql.contains("OBJECT_TYPE IN (?, ?, ?, ?, ?, ?, ?, ?)"), call.sql);
assertTrue(call.sql.contains("OBJECT_TYPE IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), call.sql);
assertFalse(call.sql.contains("ROWNUM"), call.sql);
assertObjectOrder(call.sql);
call.assertClosed();
@@ -134,6 +134,17 @@ class OceanBaseOracleObjectListTest {
call.assertClosed();
}
@Test
void typeFilterRequestsSpecificationsAndBodiesBeforePaging() {
JdbcFixture jdbc = new JdbcFixture();
JdbcCall call = jdbc.rows(row("CUSTOM_TYPE", "TYPE"), row("CUSTOM_TYPE", "TYPE BODY"));
assertEquals(List.of(new ObjectInfo("CUSTOM_TYPE", "TYPE", "APP", null),
new ObjectInfo("CUSTOM_TYPE", "TYPE_BODY", "APP", null)),
jdbc.agent.listObjects("APP", constraints(null, 3, 0, "TYPE", "TYPE_BODY")));
assertEquals(List.of("APP", "TYPE", "TYPE BODY", 3L, 0), call.args);
call.assertClosed();
}
@Test
void filteredPagesKeepSameNameSpecAndBodyAndDoNotApplyOffsetTwice() {
JdbcFixture jdbc = new JdbcFixture();
@@ -143,7 +154,7 @@ class OceanBaseOracleObjectListTest {
pages.addAll(jdbc.agent.listObjects("APP",
constraints("k_'%", 1, offset, "PACKAGE", "PACKAGE_BODY")));
assertEquals(List.of("APP", "PACKAGE", "PACKAGE BODY", "%K%\\_%'%\\%%", "%K%\\_%'%\\%%", offset + 1, offset), call.args);
assertEquals(List.of("APP", "PACKAGE", "PACKAGE BODY", "%K%\\_%'%\\%%", "%K%\\_%'%\\%%", offset + 1L, offset), call.args);
assertTrue(call.sql.contains("UPPER(OBJECT_NAME) LIKE ? ESCAPE '\\'"), call.sql);
assertTrue(call.sql.contains("ROWNUM <= ?"), call.sql);
assertTrue(call.sql.contains("WHERE DBX_RN > ?"), call.sql);
@@ -169,6 +180,27 @@ class OceanBaseOracleObjectListTest {
call.assertClosed();
}
@Test
void tablePagesKeepTheOuterOrder() {
JdbcFixture jdbc = new JdbcFixture();
JdbcCall call = jdbc.rows(row("Z_LATE", "TABLE", "订单"));
assertEquals("Z_LATE", jdbc.agent.listTables("APP", constraints("订单", 1, 2, "TABLE")).get(0).getName());
assertTrue(call.sql.endsWith("ORDER BY DBX_RN"), call.sql);
assertTrue(call.sql.contains("ORDER BY OBJECT_NAME, o.OBJECT_ID"), call.sql);
call.assertClosed();
}
@Test
void pageUpperBoundDoesNotOverflowTheProtocolIntegerRange() {
JdbcFixture jdbc = new JdbcFixture();
JdbcCall call = jdbc.rows();
assertEquals(List.of(), jdbc.agent.listObjects("APP", constraints(null, 2, Integer.MAX_VALUE, "TABLE")));
assertEquals(List.of("APP", "TABLE", 2147483649L, Integer.MAX_VALUE), call.args);
call.assertClosed();
}
@ParameterizedTest
@ValueSource(strings = {"TABLE", "VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM"})
void unrelatedTypeRequestsKeepTheirParametersAndMapping(String type) {
@@ -185,7 +217,7 @@ class OceanBaseOracleObjectListTest {
void unsupportedTypesDoNotQueryTheDatabase() {
JdbcFixture jdbc = new JdbcFixture();
assertEquals(List.of(), jdbc.agent.listObjects("APP", constraints(null, null, null, "TRIGGER", "TYPE_BODY")));
assertEquals(List.of(), jdbc.agent.listObjects("APP", constraints(null, null, null, "TRIGGER", "EVENT")));
assertEquals(0, jdbc.prepared);
}
@@ -318,7 +350,7 @@ class OceanBaseOracleObjectListTest {
for (int rank = 0; rank < types.size(); rank++) {
assertTrue(sql.contains("WHEN '" + types.get(rank) + "' THEN " + rank), sql);
}
assertTrue(sql.contains("ELSE 7\nEND, OBJECT_NAME"), sql);
assertTrue(sql.contains("ELSE 7\nEND, OBJECT_NAME, o.OBJECT_ID"), sql);
}
private static final class JdbcFixture {
@@ -363,7 +395,7 @@ class OceanBaseOracleObjectListTest {
PreparedStatement statement() {
return proxy(PreparedStatement.class, (statement, method, values) -> switch (method.getName()) {
case "setString", "setInt", "setObject" -> {
case "setString", "setInt", "setLong", "setObject" -> {
assertEquals(args.size() + 1, values[0]);
args.add(values[1]);
yield null;
@@ -0,0 +1,489 @@
package com.dbx.agent.oceanbaseoracle;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.test.TestSupport;
import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class OceanBaseOracleSynonymColumnsTest {
@Test
void privateSynonymReturnsTargetColumnMetadataAcrossSchemas() {
var catalog = new Catalog();
catalog.synonym("APP", "ORDER_ALIAS", "SALES", "ORDERS", null);
catalog.object("SALES", "ORDERS", "TABLE");
catalog.columns("SALES", "ORDERS", amountColumn());
List<ColumnInfo> columns = catalog.agent.getColumns("APP", "ORDER_ALIAS");
assertEquals(1, columns.size());
ColumnInfo column = columns.getFirst();
assertEquals("AMOUNT", column.getName());
assertEquals("NUMBER(19,4)", column.getData_type());
assertEquals(19, column.getNumeric_precision());
assertEquals(4, column.getNumeric_scale());
assertFalse(column.getIs_nullable());
assertTrue(column.getIs_primary_key());
assertEquals("0", column.getColumn_default());
assertEquals("Order amount", column.getComment());
}
private static Map<String, Object> amountColumn() {
return Map.of("COLUMN_NAME", "AMOUNT", "DATA_TYPE", "NUMBER", "NULLABLE", "N",
"DATA_PRECISION", 19, "DATA_SCALE", 4, "DATA_LENGTH", 22,
"DATA_DEFAULT", "0", "COMMENTS", "Order amount", "IS_PK", 1);
}
@Test
void synonymChainRetainsQuotedTargetNamesAndVerifiedViewIdentity() {
var catalog = new Catalog();
catalog.synonym("APP", "ORDER_ALIAS", "Bridge.Owner", "Next.Alias", null);
catalog.synonym("Bridge.Owner", "Next.Alias", "Sales.Owner", "Order\"View", null);
catalog.object("Sales.Owner", "Order\"View", "VIEW");
catalog.columns("Sales.Owner", "Order\"View", amountColumn());
ColumnInfo column = catalog.agent.getColumns("APP", "ORDER_ALIAS").getFirst();
assertEquals("Sales.Owner", column.getResolved_schema());
assertEquals("Order\"View", column.getResolved_table());
assertEquals("VIEW", column.getResolved_object_type());
assertEquals("NUMBER(19,4)", column.getData_type());
assertFalse(column.getIs_nullable());
assertTrue(column.getIs_primary_key());
}
private record Name(String owner, String name) {}
@Test
void publicSynonymResolvesOnlyWhenSchemaIsNotExplicit() {
var catalog = new Catalog();
catalog.synonym("PUBLIC", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
catalog.object("SALES", "ORDERS", "TABLE");
catalog.columns("SALES", "ORDERS", amountColumn());
catalog.nativeResult("APP", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
ColumnInfo column = catalog.agent.getColumns("", "PUBLIC_ORDERS").getFirst();
assertEquals("SALES", column.getResolved_schema());
assertEquals("ORDERS", column.getResolved_table());
assertEquals("TABLE", column.getResolved_object_type());
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.startsWith("ALTER SESSION")));
RuntimeException error = assertThrows(RuntimeException.class,
() -> catalog.agent.getColumns("APP", "PUBLIC_ORDERS"));
assertTrue(error.getMessage().contains("not found or not accessible"), error.getMessage());
}
@Test
void publicSynonymSupportsTheOceanBaseDictionaryPublicOwner() {
var catalog = new Catalog();
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
catalog.object("SALES", "ORDERS", "TABLE");
catalog.columns("SALES", "ORDERS", amountColumn());
catalog.nativeResult("APP", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
ColumnInfo column = catalog.agent.getColumns("", "PUBLIC_ORDERS").getFirst();
assertEquals("SALES", column.getResolved_schema());
assertEquals("ORDERS", column.getResolved_table());
assertEquals("TABLE", column.getResolved_object_type());
assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "PUBLIC_ORDERS"));
}
@Test
void selectedSchemaContextResolvesLocalNamesAndStillAllowsPublicFallback() {
var catalog = new Catalog();
catalog.columns("Reporting", "LOCAL_ORDERS", amountColumn());
catalog.synonym("PUBLIC", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
catalog.object("SALES", "ORDERS", "TABLE");
catalog.columns("SALES", "ORDERS", amountColumn());
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
assertEquals("AMOUNT", catalog.agent.getColumnsInContext("", "LOCAL_ORDERS", "Reporting").getFirst().getName());
assertEquals("SALES", catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting").getFirst().getResolved_schema());
assertEquals("APP", catalog.currentSchema);
assertThrows(RuntimeException.class, () -> catalog.agent.getColumnsInContext("Reporting", "PUBLIC_ORDERS", "APP"));
}
@Test
void localNamespaceObjectsShadowPublicSynonymsEvenWhenTheyHaveNoColumns() {
for (String type : List.of("TABLE", "VIEW", "SEQUENCE", "PROCEDURE", "FUNCTION", "PACKAGE", "TYPE")) {
var catalog = new Catalog();
catalog.object("APP", "ORDERS", type);
catalog.synonym("PUBLIC", "ORDERS", "SALES", "ORDERS", null);
catalog.object("SALES", "ORDERS", "TABLE");
catalog.columns("SALES", "ORDERS", amountColumn());
assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("", "ORDERS"), type);
assertFalse(catalog.columnReads.contains(new Name("SALES", "ORDERS")), type);
}
}
@Test
void remoteSynonymIsRejectedBeforeAnyTargetQuery() {
var catalog = new Catalog();
catalog.synonym("APP", "REMOTE_ORDERS", "SALES", "ORDERS", "REMOTE.EXAMPLE");
catalog.columns("SALES", "ORDERS", amountColumn());
RuntimeException error = assertThrows(RuntimeException.class,
() -> catalog.agent.getColumns("APP", "REMOTE_ORDERS"));
assertTrue(error.getMessage().contains("DBLink"), error.getMessage());
assertTrue(error.getMessage().contains("not supported"), error.getMessage());
assertEquals(List.of(new Name("APP", "REMOTE_ORDERS")), catalog.columnReads);
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.contains("@")));
}
@Test
void cyclicSynonymsFailWithAnExplicitCycleError() {
var catalog = new Catalog();
catalog.synonym("APP", "A", "OTHER", "B", null);
catalog.synonym("OTHER", "B", "APP", "A", null);
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "A"));
assertTrue(error.getMessage().contains("cycle"), error.getMessage());
assertEquals(List.of(new Name("APP", "A"), new Name("OTHER", "B")), catalog.columnReads);
}
@Test
void incompleteSynonymTargetMetadataNeverFallsBackToTheSessionOwner() {
for (String[] target : new String[][]{{null, "ORDERS"}, {"SALES", null}, {"", "ORDERS"}, {"SALES", ""}}) {
var catalog = new Catalog();
catalog.synonym("APP", "BROKEN", target[0], target[1], null);
catalog.columns("APP", "ORDERS", amountColumn());
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "BROKEN"));
assertTrue(error.getMessage() != null && error.getMessage().contains("Incomplete synonym target metadata"), String.valueOf(error));
assertEquals(List.of(new Name("APP", "BROKEN")), catalog.columnReads);
}
}
@Test
void materializedViewIdentityTakesPrecedenceOverItsTableEntry() {
var catalog = new Catalog();
catalog.synonym("APP", "MV_ALIAS", "SALES", "ORDER_TOTALS", null);
catalog.objects.put(new Name("SALES", "ORDER_TOTALS"), List.of(
Map.of("OBJECT_TYPE", "TABLE"), Map.of("OBJECT_TYPE", "MATERIALIZED VIEW")));
catalog.columns("SALES", "ORDER_TOTALS", amountColumn());
assertEquals("MATERIALIZED VIEW", catalog.agent.getColumns("APP", "MV_ALIAS").getFirst().getResolved_object_type());
}
@Test
void unknownTargetTypeIsNotPromotedToTable() {
var catalog = new Catalog();
catalog.synonym("APP", "ORDER_ALIAS", "SALES", "ORDERS", null);
catalog.columns("SALES", "ORDERS", amountColumn());
ColumnInfo column = catalog.agent.getColumns("APP", "ORDER_ALIAS").getFirst();
assertEquals("SALES", column.getResolved_schema());
assertEquals("ORDERS", column.getResolved_table());
assertNull(column.getResolved_object_type());
}
@Test
void localTableAndPrivateSynonymTakePrecedenceOverPublicSynonym() {
var catalog = new Catalog();
catalog.synonym("PUBLIC", "ORDERS", "OTHER", "ORDERS", null);
catalog.synonym("PUBLIC", "ORDER_ALIAS", "OTHER", "ORDERS", null);
catalog.synonym("APP", "ORDER_ALIAS", "APP", "ORDERS", null);
catalog.object("APP", "ORDERS", "TABLE");
catalog.columns("APP", "ORDERS", amountColumn());
assertEquals("AMOUNT", catalog.agent.getColumns("", "ORDERS").getFirst().getName());
assertEquals("APP", catalog.agent.getColumns("", "ORDER_ALIAS").getFirst().getResolved_schema());
assertFalse(catalog.columnReads.contains(new Name("OTHER", "ORDERS")));
}
@Test
void brokenPrivateTargetDoesNotFallThroughToPublicSynonyms() {
var catalog = new Catalog();
catalog.synonym("APP", "ORDERS", "SALES", "MISSING", null);
catalog.synonym("PUBLIC", "ORDERS", "OTHER", "ORDERS", null);
catalog.synonym("PUBLIC", "MISSING", "OTHER", "ORDERS", null);
catalog.columns("OTHER", "ORDERS", amountColumn());
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("", "ORDERS"));
assertTrue(error.getMessage().contains("SALES.MISSING not found or not accessible"), error.getMessage());
assertFalse(catalog.columnReads.contains(new Name("OTHER", "ORDERS")));
}
@Test
void missingAndInaccessibleTargetsAreNotSuccessfulEmptyColumns() {
var catalog = new Catalog();
catalog.synonym("APP", "INACCESSIBLE", "SALES", "HIDDEN", null);
for (String name : List.of("MISSING", "INACCESSIBLE")) {
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", name));
assertTrue(error.getMessage().contains("not found or not accessible"), error.getMessage());
}
}
@Test
void dictionaryFailuresPreserveTheOriginalSqlException() {
for (String view : List.of("ALL_TAB_COLUMNS", "ALL_OBJECTS", "ALL_SYNONYMS")) {
var catalog = new Catalog();
catalog.failure = new SQLException("insufficient privileges", "42000", 1031);
catalog.failureView = view;
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "ORDERS"));
assertSame(catalog.failure, error.getCause(), view);
}
}
@Test
void longAcyclicChainHasABoundedResolutionLimit() {
var catalog = new Catalog();
for (int index = 0; index < 40; index++) {
catalog.synonym("APP", "S" + index, "APP", "S" + (index + 1), null);
}
catalog.columns("APP", "S40", amountColumn());
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "S0"));
assertTrue(error.getMessage().contains("exceeded 32 links"), error.getMessage());
assertTrue(catalog.columnReads.size() <= 33);
assertFalse(catalog.columnReads.contains(new Name("APP", "S40")));
}
@Test
void invisibleForeignLocalObjectCannotBeReplacedByAPublicTarget() {
var catalog = new Catalog();
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
catalog.object("SALES", "ORDERS", "TABLE");
catalog.columns("SALES", "ORDERS", amountColumn());
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "Reporting", "PUBLIC_ORDERS", null);
RuntimeException error = assertThrows(RuntimeException.class,
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
assertTrue(error.getMessage().contains("does not match"), error.getMessage());
assertEquals("APP", catalog.currentSchema);
}
private record NativeTarget(String owner, String name, String databaseLink) {}
@Test
void nativePublicResolutionFailureRestoresTheExactOriginalSchema() {
var catalog = publicCatalog();
catalog.currentSchema = "Original.Owner";
catalog.nativeFailure = new SQLException("NAME_RESOLVE not available", "42000", 6550);
RuntimeException error = assertThrows(RuntimeException.class,
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
assertSame(catalog.nativeFailure, error.getCause());
assertEquals("Original.Owner", catalog.currentSchema);
}
@Test
void nativePublicResolutionRejectsRemoteTargetsWithoutRemoteQueries() {
var catalog = publicCatalog();
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", "REMOTE.EXAMPLE");
RuntimeException error = assertThrows(RuntimeException.class,
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
assertTrue(error.getMessage().contains("DBLink"), error.getMessage());
assertEquals("APP", catalog.currentSchema);
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.contains("@")));
}
@Test
void schemaRestoreFailureCannotReturnSuccessfulColumns() {
var catalog = publicCatalog();
catalog.restoreFailure = new SQLException("restore failed");
RuntimeException error = assertThrows(RuntimeException.class,
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
assertSame(catalog.restoreFailure, error.getCause());
}
@Test
void schemaRestoreFailureIsSuppressedOnTheOriginalResolutionError() {
var catalog = publicCatalog();
catalog.nativeFailure = new SQLException("resolution failed");
catalog.restoreFailure = new SQLException("restore failed");
RuntimeException error = assertThrows(RuntimeException.class,
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
assertSame(catalog.nativeFailure, error.getCause());
assertArrayEquals(new Throwable[]{catalog.restoreFailure}, error.getCause().getSuppressed());
}
private static Catalog publicCatalog() {
var catalog = new Catalog();
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
catalog.object("SALES", "ORDERS", "TABLE");
catalog.columns("SALES", "ORDERS", amountColumn());
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
return catalog;
}
private static final class Catalog {
final OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
final Map<Name, List<Map<String, Object>>> columns = new HashMap<>();
final Map<Name, List<Map<String, Object>>> synonyms = new HashMap<>();
final Map<Name, List<Map<String, Object>>> objects = new HashMap<>();
final Map<Name, NativeTarget> nativeTargets = new HashMap<>();
final List<String> sql = new ArrayList<>();
final List<Name> columnReads = new ArrayList<>();
String currentSchema = "APP";
SQLException failure;
SQLException nativeFailure;
SQLException restoreFailure;
String failureView;
Catalog() {
Connection connection = proxy(Connection.class, (ignored, method, args) -> {
if (method.getName().equals("prepareStatement")) return statement((String) args[0]);
if (method.getName().equals("prepareCall")) return nativeStatement((String) args[0]);
if (method.getName().equals("createStatement")) {
return proxy(Statement.class, (stmt, call, params) -> {
if (call.getName().equals("executeQuery")) {
sql.add((String) params[0]);
return rows(List.of(Map.of("CURRENT_SCHEMA", currentSchema)));
}
if (call.getName().equals("execute")) {
String query = (String) params[0];
sql.add(query);
String prefix = "ALTER SESSION SET CURRENT_SCHEMA = ";
assertTrue(query.startsWith(prefix), query);
String identifier = query.substring(prefix.length());
if (restoreFailure != null && identifier.equals("\"APP\"")) throw restoreFailure;
currentSchema = identifier.substring(1, identifier.length() - 1).replace("\"\"", "\"");
return false;
}
return defaultValue(call.getReturnType());
});
}
return defaultValue(method.getReturnType());
});
TestSupport.setPrivateConnection(agent, connection);
}
void synonym(String owner, String name, String targetOwner, String targetName, String link) {
var row = new HashMap<String, Object>();
row.put("TABLE_OWNER", targetOwner);
row.put("TABLE_NAME", targetName);
row.put("DB_LINK", link);
synonyms.put(new Name(owner, name), List.of(row));
object(owner, name, "SYNONYM");
}
void object(String owner, String name, String type) {
objects.put(new Name(owner, name), List.of(Map.of("OBJECT_TYPE", type)));
}
void columns(String owner, String name, Map<String, Object> column) {
columns.put(new Name(owner, name), List.of(column));
}
void nativeResult(String owner, String name, String targetOwner, String targetName, String link) {
nativeTargets.put(new Name(owner, name), new NativeTarget(targetOwner, targetName, link));
}
private CallableStatement nativeStatement(String query) {
sql.add(query);
Map<Integer, Object> parameters = new HashMap<>();
NativeTarget[] result = {null};
return proxy(CallableStatement.class, (ignored, method, args) -> {
if (method.getName().equals("setString") || method.getName().equals("setInt")) {
parameters.put((Integer) args[0], args[1]);
return null;
}
if (method.getName().equals("execute")) {
assertEquals(0, parameters.get(2), "OceanBase table resolution requires context 0");
if (nativeFailure != null) throw nativeFailure;
String identifier = (String) parameters.get(1);
assertTrue(identifier.startsWith("\"") && identifier.endsWith("\""), identifier);
String name = identifier.substring(1, identifier.length() - 1).replace("\"\"", "\"");
result[0] = nativeTargets.get(new Name(currentSchema, name));
assertNotNull(result[0], "Unexpected native resolution for " + currentSchema + "." + name);
return false;
}
if (method.getName().equals("getString")) {
return switch ((Integer) args[0]) {
case 3 -> result[0].owner();
case 4 -> result[0].name();
case 6 -> result[0].databaseLink();
default -> null;
};
}
return defaultValue(method.getReturnType());
});
}
private PreparedStatement statement(String query) {
sql.add(query);
Map<Integer, String> parameters = new HashMap<>();
return proxy(PreparedStatement.class, (ignored, method, args) -> {
if (method.getName().equals("setString")) {
parameters.put((Integer) args[0], (String) args[1]);
return null;
}
if (method.getName().equals("executeQuery")) {
if (failure != null && query.contains(failureView)) throw failure;
Name name = new Name(parameters.get(1), parameters.get(2));
if (query.contains("FROM ALL_TAB_COLUMNS")) {
assertEquals(name, new Name(parameters.get(3), parameters.get(4)), "PK and column owners must agree");
columnReads.add(name);
return rows(columns.getOrDefault(name, List.of()));
}
if (query.contains("FROM ALL_SYNONYMS")) return rows(synonyms.getOrDefault(name, List.of()));
if (query.contains("FROM ALL_OBJECTS")) return rows(objects.getOrDefault(name, List.of()));
throw new AssertionError("Unexpected metadata query: " + query);
}
return defaultValue(method.getReturnType());
});
}
}
private static ResultSet rows(List<Map<String, Object>> data) {
int[] index = {-1};
boolean[] wasNull = {false};
return proxy(ResultSet.class, (ignored, method, args) -> {
if (method.getName().equals("next")) return ++index[0] < data.size();
if (method.getName().equals("wasNull")) return wasNull[0];
if (method.getName().equals("getString") || method.getName().equals("getInt") || method.getName().equals("getObject")) {
Object value = args[0] instanceof Integer
? data.get(index[0]).values().iterator().next() : data.get(index[0]).get(args[0]);
wasNull[0] = value == null;
if (method.getName().equals("getObject")) return value;
if (method.getName().equals("getInt")) return value == null ? 0 : ((Number) value).intValue();
return value == null ? null : value.toString();
}
return defaultValue(method.getReturnType());
});
}
@SuppressWarnings("unchecked")
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
return (T) 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;
return null;
}
}
@@ -0,0 +1,23 @@
package com.oceanbase.jdbc;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DbxLobResourceBytesTest {
@Test
void countsExistingDriverPayloadAndLocatorWithoutReadingTheServer() {
Clob clob = new Clob("中文🙂".repeat(100), null);
ObLobLocatorV1 locator = new ObLobLocatorV1();
locator.binaryData = new byte[1200];
locator.rowId = new byte[20];
locator.tableId = new byte[8];
clob.locator = locator;
assertEquals(256 + 800 + 1200 + 20 + 8, DbxLobResourceBytes.retainedBytes(clob));
locator.binaryData = new byte[96];
clob.charData = null;
clob.data = null;
locator.payloadSize = 1024L * 1024 * 1024; // out-row length is not retained payload
assertEquals(256 + 96 + 20 + 8, DbxLobResourceBytes.retainedBytes(clob));
assertEquals(-1, DbxLobResourceBytes.retainedBytes(new Object()));
}
}
+57 -5
View File
@@ -1698,12 +1698,23 @@ func oracleListSQLWithVisibleSchemas(baseSQL string, visibleSchemas []string) (s
}
func (s *server) currentSchema() (string, error) {
const sqlText = "SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"
var schema string
// CURRENT_SCHEMA is per Oracle session. A manual transaction sets it only on
// its pinned connection and skips per-statement setSchema, so a pooled
// session would report the login user's schema and resolve unqualified
// tables against the wrong owner (#11258).
if s.manualTx != nil {
if err := s.manualTx.QueryRow(sqlText).Scan(&schema); err != nil {
return "", err
}
return strings.ToUpper(schema), nil
}
db, err := s.requireDB()
if err != nil {
return "", err
}
var schema string
if err := db.QueryRow("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL").Scan(&schema); err != nil {
if err := db.QueryRow(sqlText).Scan(&schema); err != nil {
return "", err
}
return strings.ToUpper(schema), nil
@@ -1993,7 +2004,7 @@ func completionRequestHasTableLikeKind(kinds []string) bool {
func completionRequestHasRoutineKind(kinds []string) bool {
for _, kind := range kinds {
switch strings.ToLower(strings.TrimSpace(kind)) {
case "routine", "procedure", "function":
case "routine", "procedure", "function", "sequence":
return true
}
}
@@ -2439,13 +2450,54 @@ func oracleCompletionSynonymTargetsQuery(targets []oracleCompletionSynonymTarget
}
}
func oracleCompletionRoutineObjectTypes(kinds []string) []string {
if len(kinds) == 0 {
return []string{"'FUNCTION'", "'PROCEDURE'", "'PACKAGE'", "'SEQUENCE'"}
}
hasRoutine := false
hasProc := false
hasFunc := false
hasSeq := false
for _, kind := range kinds {
switch strings.ToLower(strings.TrimSpace(kind)) {
case "routine":
hasRoutine = true
case "procedure":
hasProc = true
case "function":
hasFunc = true
case "sequence":
hasSeq = true
}
}
types := make([]string, 0, 4)
if hasRoutine {
types = append(types, "'FUNCTION'", "'PROCEDURE'", "'PACKAGE'")
} else {
if hasProc {
types = append(types, "'PROCEDURE'")
}
if hasFunc {
types = append(types, "'FUNCTION'")
}
}
if hasSeq {
types = append(types, "'SEQUENCE'")
}
if len(types) == 0 {
return []string{"'FUNCTION'", "'PROCEDURE'", "'PACKAGE'"}
}
return types
}
func oracleCompletionRoutinesQuery(request completionAssistantRequest, preferredSchema string, limit int) oracleMetadataListQuery {
pattern := oracleCompletionLikePattern(request.Mask, request.MatchMode)
args := make([]any, 0, 5)
baseSQL := `
objectTypes := oracleCompletionRoutineObjectTypes(request.ObjectKinds)
baseSQL := fmt.Sprintf(`
SELECT o.OWNER, o.OBJECT_NAME, o.OBJECT_TYPE, CAST(NULL AS VARCHAR2(128)) AS PARENT_NAME
FROM ALL_OBJECTS o
WHERE o.OBJECT_TYPE IN ('FUNCTION', 'PROCEDURE', 'PACKAGE')`
WHERE o.OBJECT_TYPE IN (%s)`, strings.Join(objectTypes, ", "))
args = append(args, pattern)
nameParam := len(args)
+61
View File
@@ -2347,6 +2347,30 @@ func TestOracleCompletionRoutinesQueryUsesPublicPackageMetadata(t *testing.T) {
}
}
func TestOracleCompletionRoutinesQueryIncludesSequences(t *testing.T) {
query := oracleCompletionRoutinesQuery(completionAssistantRequest{
Schema: "HR",
ObjectKinds: []string{"routine", "sequence"},
Mask: "SEQ",
GlobalSearch: false,
}, "HR", 100)
sqlText := strings.ToUpper(query.SQL)
if !strings.Contains(sqlText, "'SEQUENCE'") {
t.Fatalf("routines query should include SEQUENCE when requested: %s", query.SQL)
}
seqOnlyQuery := oracleCompletionRoutinesQuery(completionAssistantRequest{
Schema: "HR",
ObjectKinds: []string{"sequence"},
Mask: "SEQ",
GlobalSearch: false,
}, "HR", 100)
seqSQL := strings.ToUpper(seqOnlyQuery.SQL)
if !strings.Contains(seqSQL, "IN ('SEQUENCE')") {
t.Fatalf("sequence-only query should only include SEQUENCE: %s", seqOnlyQuery.SQL)
}
}
func TestOracleCompletionPackageCandidatesPreserveOverloadsAndReturnTypes(t *testing.T) {
rows := []oraclePackageRoutineRow{
{
@@ -3901,6 +3925,29 @@ func TestManualTransactionSkipsPerStatementSetSchema(t *testing.T) {
}
}
func TestManualTransactionCurrentSchemaUsesPinnedSession(t *testing.T) {
db, _ := openOracleManualTxTestDB(t)
// A second pooled session that never ran ALTER SESSION SET CURRENT_SCHEMA
// still reports the login user's schema.
db.SetMaxOpenConns(2)
s := newServer()
s.db = db
if err := s.beginManualTransaction("APP_TEST"); err != nil {
t.Fatalf("begin: %v", err)
}
schema, err := s.normalizeSchema("")
if err != nil {
t.Fatalf("normalizeSchema: %v", err)
}
if schema != "APP_TEST" {
t.Fatalf("normalizeSchema(\"\") during manual tx = %q, want APP_TEST", schema)
}
if err := s.rollbackManualTransaction(); err != nil {
t.Fatalf("rollback: %v", err)
}
}
func TestManualTransactionDisconnectRollsBack(t *testing.T) {
db, driver := openOracleManualTxTestDB(t)
s := newServer()
@@ -4003,6 +4050,7 @@ func (d *oracleManualTxDriver) Open(string) (driver.Conn, error) {
type oracleManualTxConn struct {
driver *oracleManualTxDriver
closed bool
schema string
}
func (c *oracleManualTxConn) Prepare(string) (driver.Stmt, error) {
@@ -4036,6 +4084,9 @@ func (c *oracleManualTxConn) ExecContext(_ context.Context, query string, _ []dr
c.driver.mu.Lock()
defer c.driver.mu.Unlock()
c.driver.execs = append(c.driver.execs, query)
if schema, ok := strings.CutPrefix(query, "ALTER SESSION SET CURRENT_SCHEMA = "); ok {
c.schema = strings.Trim(schema, `"`)
}
return driver.RowsAffected(1), nil
}
@@ -4043,6 +4094,16 @@ func (c *oracleManualTxConn) QueryContext(_ context.Context, query string, _ []d
c.driver.mu.Lock()
defer c.driver.mu.Unlock()
c.driver.queries = append(c.driver.queries, query)
if strings.Contains(query, "'CURRENT_SCHEMA'") {
schema := c.schema
if schema == "" {
schema = "LOGIN_USER"
}
return &oracleManualTxRows{
columns: []string{"SCHEMA"},
values: [][]driver.Value{{schema}},
}, nil
}
return &oracleManualTxRows{
columns: []string{"A"},
values: [][]driver.Value{{int64(1)}},
@@ -468,13 +468,17 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
public List<ColumnInfo> getColumns(String schema, String table) {
String resolvedSchema = metadataSchema(schema, table);
List<ColumnInfo> columns = super.getColumns(resolvedSchema, table);
if (!sqlServer2000Mode || columns.isEmpty()) {
if (columns.isEmpty()) {
return columns;
}
try {
// JDBC REMARKS from sp_columns is always NULL on SQL Server, so the
// MS_Description comments must be read from the catalog directly.
return mergeSqlServer2000ColumnComments(
columns,
readSqlServer2000ColumnComments(resolvedSchema, table)
sqlServer2000Mode
? readSqlServer2000ColumnComments(resolvedSchema, table)
: readColumnCommentsFromQuery(columnCommentsSql(), resolvedSchema, table)
);
} catch (SQLException | RuntimeException error) {
// Comments are optional metadata. Keep the table usable when the
@@ -482,7 +486,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
// Legacy drivers can also throw runtime errors from their catalog
// code, mirroring the RuntimeException guards in getTableDdl.
System.err.println(
"[sqlserver-legacy] SQL Server 2000 column comments unavailable: "
"[sqlserver-legacy] SQL Server column comments unavailable: "
+ error.getClass().getName()
+ ": "
+ error.getMessage()
@@ -547,6 +551,17 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
return columns;
}
static String columnCommentsSql() {
return "SELECT c.name AS column_name, CAST(ep.value AS nvarchar(max)) AS column_comment, "
+ "ep.name AS property_name "
+ "FROM sys.columns c "
+ "JOIN sys.objects o ON o.object_id = c.object_id "
+ "JOIN sys.schemas s ON s.schema_id = o.schema_id "
+ "JOIN sys.extended_properties ep ON ep.class = 1 AND ep.major_id = c.object_id "
+ "AND ep.minor_id = c.column_id AND ep.name = N'MS_Description' "
+ "WHERE s.name = ? AND o.name = ? AND o.type IN ('U', 'V')";
}
static String sqlServer2000ColumnCommentsSql() {
return "SELECT c.name AS column_name, p.value AS column_comment, p.name AS property_name "
+ "FROM sysobjects o JOIN sysusers u ON o.uid = u.uid "
@@ -444,6 +444,27 @@ class SqlServerLegacyAgentTest {
Assertions.assertEquals("JDBC remark", columns.get(2).getComment());
}
@Test
void modernSqlServerColumnCommentsComeFromExtendedProperties() {
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
TestSupport.setPrivateConnection(agent, sqlServer2000CommentConnection(
null,
Arrays.asList(
Arrays.asList("NAME", "Display name", "MS_Description")
),
null
));
setSqlServer2000Mode(agent, false);
List<ColumnInfo> columns = agent.getColumns("dbo", "USERS");
// mssql-jdbc reports NULL REMARKS for every SQL Server column, so the
// MS_Description written by the structure editor must be merged in.
Assertions.assertEquals("Display name", columns.get(1).getComment());
Assertions.assertNull(columns.get(0).getComment());
Assertions.assertEquals("JDBC remark", columns.get(2).getComment());
}
@Test
void sqlServer2000ColumnCommentsFailSoftWhenCatalogThrowsRuntimeError() {
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
@@ -727,6 +748,17 @@ class SqlServerLegacyAgentTest {
Assertions.assertTrue(sql.contains("s.name = ? AND t.name = ?"));
}
@Test
void columnCommentQueryReadsColumnExtendedPropertiesOnly() {
String sql = SqlServerLegacyAgent.columnCommentsSql();
Assertions.assertTrue(sql.contains("sys.extended_properties"));
Assertions.assertTrue(sql.contains("ep.class = 1"), "index comments share major_id/minor_id with columns");
Assertions.assertTrue(sql.contains("ep.minor_id = c.column_id"));
Assertions.assertTrue(sql.contains("ep.name = N'MS_Description'"));
Assertions.assertTrue(sql.contains("s.name = ? AND o.name = ?"));
}
@Test
void tableCommentDdlUsesExtendedPropertyAndPreservesWhitespace() {
String ddl = SqlServerLegacyAgent.appendTableCommentDdl(
+1 -1
View File
@@ -1,6 +1,6 @@
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.teradata.jdbc:terajdbc:20.00.00.56'
implementation 'com.teradata.jdbc:terajdbc:20.00.00.60'
}
tasks.named('shadowJar') {
@@ -1868,6 +1868,14 @@ func (s *server) getTableDDL(schema, table string) (string, error) {
if err != nil {
return "", err
}
if len(columns) == 0 {
if visibleColumns, fallbackErr := s.getColumns("", table); fallbackErr == nil && len(visibleColumns) > 0 {
columns = visibleColumns
if columns[0].ResolvedSchema != nil && *columns[0].ResolvedSchema != "" {
effective = *columns[0].ResolvedSchema
}
}
}
tableComment, _ := s.getTableComment(effective, table)
ddl := renderTableDDL(effective, table, columns, tableComment)
ddl, err = s.appendTableIndexDDL(effective, table, ddl)
+2
View File
@@ -50,6 +50,7 @@ type Config struct {
BrowserResponseTimeout time.Duration
BrowserDisableSSLCheck bool
WaitForNonQueryCompletion bool
IdentifierQuote string
}
var _ driver.Connector = (*HiveConnector)(nil)
@@ -112,6 +113,7 @@ func (c *HiveConnector) Connect(ctx context.Context) (driver.Conn, error) {
connCfg.BrowserResponseTimeout = c.cfg.BrowserResponseTimeout
connCfg.BrowserDisableSSLCheck = c.cfg.BrowserDisableSSLCheck
connCfg.WaitForNonQueryCompletion = c.cfg.WaitForNonQueryCompletion
connCfg.IdentifierQuote = c.cfg.IdentifierQuote
// Fallback: build TLS config from cert/key files if TLSConfig not provided directly
if connCfg.TLSConfig == nil {
+26 -2
View File
@@ -272,11 +272,28 @@ type connectConfiguration struct {
BrowserResponseTimeout time.Duration
BrowserDisableSSLCheck bool
WaitForNonQueryCompletion bool
IdentifierQuote string
// Maximum length of the data in bytes. Used for SASL.
MaxSize uint32
MaxMessageSize int32
}
func (c *connectConfiguration) identifierQuote() string {
if c != nil && c.IdentifierQuote != "" {
return c.IdentifierQuote
}
if c != nil && c.HiveConfiguration != nil {
for k, v := range c.HiveConfiguration {
cleanKey := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(k), "set:hivevar:"), "set:hiveconf:")
cleanKey = strings.TrimPrefix(cleanKey, "#")
if strings.EqualFold(cleanKey, "kyuubi.engine.type") && strings.EqualFold(strings.TrimSpace(v), "TRINO") {
return "\""
}
}
}
return "`"
}
// newConnectConfiguration returns a connect configuration, all with empty fields
func newConnectConfiguration() *connectConfiguration {
return &connectConfiguration{
@@ -652,7 +669,7 @@ func innerConnect(ctx context.Context, host string, port int, auth string,
if configuration.Database != "" {
cursor := conn.cursor()
defer cursor.close(ctx)
cursor.exec(ctx, "USE "+quoteHiveIdentifier(configuration.Database))
cursor.exec(ctx, "USE "+quoteIdentifier(configuration.Database, configuration.identifierQuote()))
if cursor.Err != nil {
return nil, cursor.Err
}
@@ -1693,7 +1710,14 @@ func usesHTTPBasicAuth(auth string) bool {
}
func quoteHiveIdentifier(value string) string {
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
return quoteIdentifier(value, "`")
}
func quoteIdentifier(value, quote string) string {
if quote == "" {
quote = "`"
}
return quote + strings.ReplaceAll(value, quote, quote+quote) + quote
}
var DEFAULT_SQL_STATE = ""
+29
View File
@@ -386,3 +386,32 @@ func TestQuoteHiveIdentifierEscapesBackticks(t *testing.T) {
t.Fatalf("unexpected quoted identifier: %q", value)
}
}
func TestQuoteIdentifierWithCustomQuote(t *testing.T) {
if value := quoteIdentifier(`analytics"prod`, `"`); value != `"analytics""prod"` {
t.Fatalf("unexpected quoted identifier: %q", value)
}
if value := quoteIdentifier("analytics`prod", ""); value != "`analytics``prod`" {
t.Fatalf("unexpected default quoted identifier: %q", value)
}
}
func TestConnectConfigurationIdentifierQuote(t *testing.T) {
cfg := &connectConfiguration{IdentifierQuote: `"`}
if q := cfg.identifierQuote(); q != `"` {
t.Fatalf("expected quote %q, got %q", `"`, q)
}
cfg = &connectConfiguration{
HiveConfiguration: map[string]string{"set:hivevar:kyuubi.engine.type": "TRINO"},
}
if q := cfg.identifierQuote(); q != `"` {
t.Fatalf("expected auto-detected Trino quote %q, got %q", `"`, q)
}
cfg = &connectConfiguration{
HiveConfiguration: map[string]string{"set:hivevar:kyuubi.engine.type": "SPARK_SQL"},
}
if q := cfg.identifierQuote(); q != "`" {
t.Fatalf("expected default quote %q, got %q", "`", q)
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ cassandra-go shared-fallback native-go Uses cassandra-gocql-driver schema metada
dameng native-pushdown java-sql Uses ALL_OBJECTS with type, filter, stable order, and LIMIT/OFFSET with legacy fallback.
databend native-pushdown java-sql Uses system procedures for routine pushdown and shared table metadata constraints for table-like objects.
db2 native-pushdown java-sql Uses SYSCAT metadata with type, filter, stable order, and OFFSET/FETCH.
firebird shared-fallback java-jdbc-metadata Delegates constrained listings to shared JDBC metadata and only applies explicitly configured legacy text decoding to comments.
firebird native-pushdown java-sql Lists objects natively from RDB$ system tables; constrained listings only apply explicitly configured legacy text decoding to comments.
gbase8a native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET.
gbase8s native-pushdown java-sql Uses systables with type, filter, stable order, and SKIP/FIRST.
goldendb native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET.
1 driver strategy scope reason
6 dameng native-pushdown java-sql Uses ALL_OBJECTS with type, filter, stable order, and LIMIT/OFFSET with legacy fallback.
7 databend native-pushdown java-sql Uses system procedures for routine pushdown and shared table metadata constraints for table-like objects.
8 db2 native-pushdown java-sql Uses SYSCAT metadata with type, filter, stable order, and OFFSET/FETCH.
9 firebird shared-fallback native-pushdown java-jdbc-metadata java-sql Delegates constrained listings to shared JDBC metadata and only applies explicitly configured legacy text decoding to comments. Lists objects natively from RDB$ system tables; constrained listings only apply explicitly configured legacy text decoding to comments.
10 gbase8a native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET.
11 gbase8s native-pushdown java-sql Uses systables with type, filter, stable order, and SKIP/FIRST.
12 goldendb native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET.
+43 -43
View File
@@ -1,57 +1,57 @@
{
"access": "0.1.73",
"bigquery": "0.1.77",
"cache": "0.1.22",
"access": "0.1.76",
"bigquery": "0.1.80",
"cache": "0.1.25",
"cassandra": "0.1.53",
"dameng": "0.1.87",
"databend": "0.1.64",
"databricks": "0.1.71",
"db2": "0.1.77",
"duckdb": "0.1.29",
"exasol": "0.1.71",
"firebird": "0.1.73",
"gbase8a": "0.1.71",
"gbase8s": "0.1.66",
"goldendb": "0.1.77",
"h2": "0.1.77",
"h2-legacy": "0.1.46",
"hive": "0.1.58",
"argo": "0.1.2",
"ignite": "0.1.29",
"ignite3": "0.1.29",
"spark": "0.1.48",
"informix": "0.1.77",
"kingbase": "0.1.61",
"kylin": "0.1.76",
"dameng": "0.1.90",
"databend": "0.1.67",
"databricks": "0.1.74",
"db2": "0.1.80",
"duckdb": "0.1.31",
"exasol": "0.1.74",
"firebird": "0.1.77",
"gbase8a": "0.1.74",
"gbase8s": "0.1.69",
"goldendb": "0.1.80",
"h2": "0.1.80",
"h2-legacy": "0.1.49",
"hive": "0.1.59",
"argo": "0.1.3",
"ignite": "0.1.32",
"ignite3": "0.1.32",
"spark": "0.1.51",
"informix": "0.1.80",
"kingbase": "0.1.63",
"kylin": "0.1.79",
"neo4j": "0.1.52",
"nebula": "0.1.2",
"oceanbase-oracle": "0.1.73",
"oracle": "0.1.67",
"oracle-oci": "0.1.0",
"snowflake": "0.1.76",
"oceanbase-oracle": "0.1.76",
"oracle": "0.1.68",
"oracle-oci": "0.1.1",
"snowflake": "0.1.79",
"sqlite-worker": "0.1.6",
"spanner": "0.1.29",
"saphana": "0.1.72",
"sundb": "0.1.77",
"teradata": "0.1.71",
"spanner": "0.1.32",
"saphana": "0.1.75",
"sundb": "0.1.80",
"teradata": "0.1.74",
"tdengine": "0.1.52",
"trino": "0.1.76",
"transwarp": "0.1.7",
"vastbase": "0.1.53",
"vertica": "0.1.71",
"mongodb": "0.1.80",
"highgo": "0.1.74",
"uxdb": "0.1.43",
"yashandb": "0.1.72",
"oscar": "0.1.51",
"trino": "0.1.79",
"transwarp": "0.1.10",
"vastbase": "0.1.54",
"vertica": "0.1.74",
"mongodb": "0.1.83",
"highgo": "0.1.77",
"uxdb": "0.1.46",
"yashandb": "0.1.75",
"oscar": "0.1.54",
"xugu": "0.1.49",
"iris": "0.1.70",
"iris": "0.1.73",
"iotdb": "0.1.45",
"etcd": "0.1.45",
"etcd2": "0.1.3",
"zookeeper": "0.1.33",
"kafka": "0.1.16",
"kafka": "0.1.17",
"rocketmq": "0.1.15",
"rabbitmq": "0.1.14",
"sqlserver-legacy": "0.1.50"
"sqlserver-legacy": "0.1.53"
}
+37
View File
@@ -23,6 +23,39 @@
background-color: rgb(19 20 22);
}
/* Linux floating frame (see create_linux_main_window). Lives here, not in the app
* bundle, so the margin, rounded corners and shadow exist from the very first paint.
* #root is the frame: `contain: layout` makes it the containing block of the
* position:fixed startup/app layers. Keep the 20px in sync with LinuxResizeHandles. */
html.dbx-linux-floating,
html.dbx-linux-floating body {
background: transparent !important;
}
html.dbx-linux-floating #root {
position: fixed;
inset: 20px;
/* globals.css sets html, body, #root to 100%, which would override inset */
width: auto;
height: auto;
contain: layout;
overflow: hidden;
border-radius: 12px;
box-shadow:
0 0 0 1px rgb(0 0 0 / 0.14),
0 3px 12px rgb(0 0 0 / 0.16);
}
html.dark.dbx-linux-floating #root {
box-shadow:
0 0 0 1px rgb(255 255 255 / 0.1),
0 3px 12px rgb(0 0 0 / 0.45);
}
html.dbx-linux-floating #startup-loading {
min-height: 100%;
}
#startup-loading {
display: flex;
flex-direction: column;
@@ -69,6 +102,10 @@
}
}
</style>
<script>
// Injected by the Rust side only for the main window when a compositor is running.
if (window.__DBX_LINUX_FLOATING__) document.documentElement.classList.add("dbx-linux-floating");
</script>
<script data-dbx-startup-theme>
(() => {
const root = document.documentElement;
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+146 -26
View File
@@ -10,6 +10,7 @@ import { useI18n } from "vue-i18n";
import { FileText, FolderPlus } from "@lucide/vue";
import { TooltipProvider } from "@/components/ui/tooltip";
import AppToolbar from "@/components/layout/AppToolbar.vue";
import LinuxResizeHandles from "@/components/layout/LinuxResizeHandles.vue";
import AppTabBar from "@/components/layout/AppTabBar.vue";
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "@/components/layout/groupTabBarPortal";
import PluginShortcutBar from "@/components/plugins/PluginShortcutBar.vue";
@@ -18,6 +19,7 @@ import AppSidebar from "@/components/layout/AppSidebar.vue";
import SqlEditorWorkspace from "@/components/layout/SqlEditorWorkspace.vue";
import { EDITOR_TOOLBAR_ACTIONS } from "@/components/layout/editorToolbarActions";
import AppDialogs from "@/components/layout/AppDialogs.vue";
import McpSqlApprovalDialog from "@/components/mcp/McpSqlApprovalDialog.vue";
import DetachedTabHeader from "@/components/layout/DetachedTabHeader.vue";
import WelcomeScreen from "@/components/layout/WelcomeScreen.vue";
import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue";
@@ -73,7 +75,7 @@ import { useExternalSqlFileChanges } from "@/composables/useExternalSqlFileChang
import { useWebDavAutoUpload } from "@/composables/useWebDavAutoUpload";
import { readSyncMethod, readWebDavAutoUploadConfig, readWebDavBackupSelection } from "@/lib/webdav/webdavAutoUploadConfig";
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
import { shouldDrawDesktopWindowFrame } from "@/composables/useWindowControls";
import { shouldDrawDesktopWindowFrame, shouldDrawLinuxFloatingFrame, useWindowControls } from "@/composables/useWindowControls";
import { createOpenTabsRestorationBarrier, initializeDesktopOpenTabs, initializeOpenTabs, type OpenTabsRestorationBarrier } from "@/lib/app/openTabsStartup";
import { finishAppCloseWithRequiredPersist } from "@/lib/app/appClosePersistence";
import { useSaveSqlFolderSelection } from "@/composables/useSaveSqlFolderSelection";
@@ -86,7 +88,7 @@ import { OBJECT_BROWSER_SEARCH_FOCUS_EVENT, objectBrowserSearchFocusTabId } from
import { parseRecentConnectionIds, rankRecentConnections, RECENT_CONNECTION_IDS_STORAGE_KEY, recordRecentConnection } from "@/lib/connection/recentConnections";
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace";
import { findTreeNodeById, resolveNewQueryTarget, resolveNewQueryInitialSql } from "@/lib/sql/newQueryContext";
import { findTreeNodeById, resolveNewQueryTarget, resolveNewQueryInitialSql, type NewQueryContextSource } from "@/lib/sql/newQueryContext";
import { isSqlObjectNavigationRoutineType, normalizeOracleNavigationTarget, sqlObjectNavigationSourceKind, sqlObjectNavigationSourceName, sqlObjectNavigationSourceSchema, sqlObjectNavigationTableType, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad";
@@ -94,7 +96,7 @@ import { schemaAfterConnectionSwitch } from "@/lib/schema/connectionSchemaInitia
import { resolveHistorySqlRestoreTarget } from "@/lib/history/historyRestoreTarget";
import { resolveExecutableSql, resolveExecutableSqlWithBackend, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import { uuid } from "@/lib/common/utils";
import { isMacOS, isWindows } from "@/lib/backend/platform";
import { getPlatform, isMacOS, isWindows } from "@/lib/backend/platform";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { activeTabExternalSqlFileTarget, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTab, unassociatedExternalSqlFileTarget, type ExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
@@ -115,6 +117,7 @@ import { restoreGlobalNavigationEntry } from "@/lib/navigation/globalNavigationE
import {
isBrowserReloadShortcut,
isBrowserTaskManagerShortcut,
isCloseAllTabsShortcut,
isCloseOtherTabsShortcut,
isCloseTabShortcut,
isCloseWindowShortcut,
@@ -149,6 +152,7 @@ import {
} from "@/lib/editor/keyboardShortcuts";
import { canSaveSqlTab } from "@/lib/tabs/sqlTabSaveTarget";
import { initialTabSwitcherSelection, moveTabSwitcherSelection, tabSwitcherOrder } from "@/lib/tabs/tabSwitcher";
import { createModifierDoubleTapShortcutController, isModifierDoubleTapShortcut, matchesModifierDoubleTapShortcut } from "@/lib/editor/modifierDoubleTapShortcut";
import { createTabSwitcherKeyboardController } from "@/lib/tabs/tabSwitcherKeyboard";
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities";
@@ -163,7 +167,7 @@ import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDri
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { webPath } from "@/lib/common/webPath";
import { eventTargetAllowsNativeClipboard, shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts";
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, EDITOR_FONT_FAMILY_CSS_VAR, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts";
import { DATA_GRID_TYPE_COLOR_KEYS, dataGridTypeColorCssVar, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory";
import { useUiFontFamilyPreview } from "@/composables/useUiFontFamilyPreview";
@@ -172,6 +176,7 @@ import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { savedSqlDefaultTargetForWrite } from "@/lib/savedSql/savedSqlExecutionTarget";
import { countActiveUpdateBlockingTasks } from "@/lib/app/appUpdateTaskGuard";
import { initSavedSqlEditorPositions } from "@/lib/app/savedSqlEditorPosition";
import { dismissOpenModalSurface } from "@/lib/ui/modalSurface";
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import { objectBrowserTablesToAiTreeNodes } from "@/lib/ai/objectBrowserToAiTargets";
import { aiTargetFromTab, type AiConversationBinding } from "@/lib/ai/aiConversationBinding";
@@ -373,11 +378,12 @@ const activeAiRunCount = computed(() => (isDesktop ? activeDesktopAiRuns().lengt
/** Runs waiting for a write confirmation — the panel-entry badge shows these
* with a higher-priority indicator (parent PRD §4 line 71 / §9). */
const awaitingAiRunCount = computed(() => (isDesktop ? activeDesktopAiRuns().filter((run) => run.status === "awaiting_write_confirmation").length : 0));
const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged, applyMcpStatus } = useMcpUpdateBadge({
const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged, applyMcpStatus, invalidateMcpUpdateStatus } = useMcpUpdateBadge({
isDesktop,
// Update availability remains visible when every auto-update switch is off;
// the switches control installation, not whether the user can be reminded.
updateNotificationsEnabled: () => true,
shouldDeferRefresh: () => componentUpdates.updating.value,
});
const drawDesktopWindowFrame = shouldDrawDesktopWindowFrame(isMacOS(), isDesktop, isWindows());
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
@@ -385,6 +391,22 @@ let updateCheckTimer: ReturnType<typeof setInterval> | undefined;
const needsAuth = ref(!isDesktop && (startupProps.startupAuthentication?.required ?? true));
const authenticated = ref(isDesktop || (startupProps.startupAuthentication?.authenticated ?? false));
const setupRequired = ref(!isDesktop && (startupProps.startupAuthentication?.setup_required ?? false));
const { isMaximized: windowMaximized, isFullscreen: windowFullscreen } = useWindowControls();
// The Rust side injects this flag into the main window only when a compositing manager is
// running (see create_linux_main_window); detached-tab and plugin windows never get it.
const linuxCompositing = (window as unknown as { __DBX_LINUX_FLOATING__?: boolean }).__DBX_LINUX_FLOATING__ === true;
const drawLinuxFloatingFrame = computed(() =>
shouldDrawLinuxFloatingFrame({
isLinux: getPlatform() === "linux",
isDesktop,
isMainWindow: windowContext.kind === "main",
compositing: linuxCompositing,
showingAuthPage: setupRequired.value || (needsAuth.value && !authenticated.value),
isMaximized: windowMaximized.value,
isFullscreen: windowFullscreen.value,
}),
);
watch(drawLinuxFloatingFrame, (enabled) => document.documentElement.classList.toggle("dbx-linux-floating", enabled), { immediate: true });
// Mirrors the template gate above the app shell. The backend liveness stream is registered
// against it so the web runtime only opens an authenticated subscription.
const appReady = computed(() => !setupRequired.value && (!needsAuth.value || authenticated.value));
@@ -464,6 +486,8 @@ const cursorPos = ref(0);
const previewChangesAvailable = ref(false);
const formatSqlRequest = ref<{ id: number; tabId: string } | null>(null);
const compressSqlRequest = ref<{ id: number; tabId: string } | null>(null);
/** Toolbar `a`/`A`: keyword-only re-case for the acting editor (issue #11828). */
const keywordCaseRequest = ref<{ id: number; tabId: string; mode: "upper" | "lower" } | null>(null);
const activeOutputView = computed<TabOutputView>({
get: () => activeTab.value?.uiState?.activeOutputView ?? "result",
set: (view) => {
@@ -471,7 +495,6 @@ const activeOutputView = computed<TabOutputView>({
if (tab) queryStore.updateTabUiState(tab.id, { activeOutputView: view });
},
});
const newQueryContextSource = ref<"tab" | "sidebar">("tab");
const queryEditorDdlTarget = ref<{ connectionId: string; database: string; catalog?: string; schema?: string; tableName: string; objectType?: ObjectSourceKind } | null>(null);
const queryEditorObjectSourceTarget = ref<{
connectionId: string;
@@ -1091,9 +1114,9 @@ provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(isSpecialPageActive));
provide(EDITOR_TOOLBAR_ACTIONS, {
canNewQuery: canCreateNewQuery,
newQuery: (groupId: string) => {
lastFocusedSidebarSurface.value = false;
queryStore.focusGroup(groupId);
newQueryContextSource.value = "tab";
void newQuery();
void newQuery({ preferredSource: "tab" });
},
explainMode,
blockDangerousRedisCommands,
@@ -1285,6 +1308,7 @@ function activateMainContentSurface(surface: MainContentSurface) {
settingsStore.settingsPageActive = surface === "settings";
driverStoreActive.value = surface === "driverStore";
pluginCenterActive.value = surface === "pluginCenter";
lastFocusedSidebarSurface.value = false;
}
watch(
@@ -1439,6 +1463,8 @@ function handleToolbarUpdateClick() {
function syncToolbarComponentUpdateState() {
agentDriverUpdateCount.value = componentUpdates.driverUpdateCount.value;
applyMcpStatus(componentUpdates.mcpUpdateAvailable.value);
// 组件更新是权威来源;丢弃轮询期间发出的旧快照,避免其晚返回后重新点亮更新入口。
invalidateMcpUpdateStatus();
}
function handleComponentUpdatesChanged() {
@@ -1489,6 +1515,8 @@ async function consumePendingComponentUpdatesAfterRestart() {
if (currentVersion && !appVersion.value) appVersion.value = currentVersion;
const pending = takePendingComponentUpdatesAfterAppRestart(currentVersion);
if (!pending) return;
// 丢弃重启过程中发出的后台 MCP 轮询:它们可能读到升级前快照,晚返回后会覆盖权威结果。
invalidateMcpUpdateStatus();
reportComponentUpdateResult(await runPendingComponentUpdatePlan(pending, componentUpdates));
}
@@ -1682,7 +1710,9 @@ function applyUiFontFamily(fontFamily: string, options?: { debouncePluginBumpMs?
// Mirrors the editor font onto the global mono token so host mono surfaces and
// plugin sandboxes (via the plugin bridge's token push) follow the same setting.
function applyMonoFontFamily(fontFamily: string) {
writeRootToken(FONT_MONO_CSS_VAR, fontFamily || DEFAULT_MONO_FONT_FAMILY);
const value = fontFamily || DEFAULT_MONO_FONT_FAMILY;
writeRootToken(FONT_MONO_CSS_VAR, value);
writeRootToken(EDITOR_FONT_FAMILY_CSS_VAR, value);
}
function applyDataGridFontFamily(fontFamily: string) {
@@ -1732,7 +1762,6 @@ watch(
}),
);
}
if (id) newQueryContextSource.value = "tab";
if (id) activateQuerySurface();
else if (previousId) activateOpenSpecialPageFallback();
if (id && pluginCenterActive.value) pluginCenterActive.value = false;
@@ -1832,13 +1861,6 @@ watch(
{ immediate: true },
);
watch(
() => connectionStore.selectedTreeNodeId,
(id) => {
if (id) newQueryContextSource.value = "sidebar";
},
);
watch(
() => settingsStore.editorSettings.uiScale,
(scale) => {
@@ -2125,14 +2147,30 @@ function compressActiveSql(tabId?: string) {
};
}
function toggleSqlKeywordCase() {
function toggleSqlKeywordCase(tabId: string) {
const sqlFormatter = settingsStore.editorSettings.sqlFormatter;
const keywordCase = sqlFormatter.keywordCase === "lower" ? "upper" : "lower";
// Clicking the button and formatting must agree, so the preference flips and
// the editor re-cases the current document (or selection) to match — the
// button is labeled "使用大写/小写 SQL 关键字", and issue #11828 reported it as
// doing nothing because only the preference moved.
settingsStore.updateEditorSettings({
sqlFormatter: {
...sqlFormatter,
keywordCase: sqlFormatter.keywordCase === "lower" ? "upper" : "lower",
keywordCase,
},
});
const tab = resolveToolbarTab(tabId);
if (!tab || tab.mode !== "query") return;
const connection = connectionStore.getConfig(tab.connectionId);
const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type;
if (!canFormatSqlForDatabaseType(databaseType)) return;
keywordCaseRequest.value = {
id: (keywordCaseRequest.value?.id ?? 0) + 1,
tabId: tab.id,
mode: keywordCase,
};
}
function defaultSavedSqlName(title: string) {
@@ -3074,15 +3112,27 @@ function openConnectionSettings(connectionId: string, initialTab: ConfigTab = "c
showConnectionDialog.value = true;
}
async function newQuery() {
interface NewQueryOptions {
preferredSource?: NewQueryContextSource;
}
async function newQuery(options: NewQueryOptions = {}) {
const sqlConnections = connectionStore.connections.filter((connection) => quickConnectionOpenTarget(connection).kind === "query" && supportsGenericNewQuery(connection));
const connectedSqlConnectionIds = new Set(sqlConnections.filter((connection) => connectionStore.connectedIds.has(connection.id)).map((connection) => connection.id));
let target = resolveNewQueryTarget({
activeTab: activeTab.value,
selectedTreeNode: findTreeNodeById(connectionStore.treeNodes, connectionStore.selectedTreeNodeId),
activeConnectionId: connectionStore.activeConnectionId,
connections: connectionStore.connections,
preferredSource: newQueryContextSource.value,
connectedSqlConnectionIds,
preferredSource: options.preferredSource,
});
if (!target) return;
if (!target) {
// No active editor/sidebar choice and no unique online SQL connection:
// leave the connection unset so the user can choose in the editor toolbar.
if (sqlConnections.length > 0) queryStore.createTab("", "", undefined, "query");
return;
}
let conn = connectionStore.getConfig(target.connectionId);
if (!conn) return;
@@ -3132,7 +3182,7 @@ async function newQuery() {
const initialSql = resolveNewQueryInitialSql({
activeTab: activeTab.value,
selectedTreeNode: findTreeNodeById(connectionStore.treeNodes, connectionStore.selectedTreeNodeId),
preferredSource: newQueryContextSource.value,
preferredSource: options.preferredSource,
prefillEnabled: settingsStore.editorSettings.prefillNewQueryWithSelect,
targetConnectionId: target.connectionId,
targetDatabase: target.database,
@@ -4007,7 +4057,35 @@ function closeTabSwitcher(commit: boolean) {
if (tab) activateQueryTab(tab.id);
}
const modifierDoubleTapSearch = createModifierDoubleTapShortcutController();
watch(
() => [settingsStore.editorSettings.shortcuts.quickOpen, settingsStore.editorSettings.shortcuts.globalSearch],
() => modifierDoubleTapSearch.reset(),
{ flush: "sync" },
);
function canCaptureModifierDoubleTapSearch(e: KeyboardEvent): boolean {
const target = e.target instanceof Element ? e.target : document.activeElement;
const shortcuts = settingsStore.editorSettings.shortcuts;
const enabled = isModifierDoubleTapShortcut(shortcuts.quickOpen) || isModifierDoubleTapShortcut(shortcuts.globalSearch);
// Capture runs before the settings input can stop propagation.
if (e.defaultPrevented || showTabSwitcher.value || !enabled || target?.closest("[data-shortcut-input], [data-sql-shortcut-input]")) {
modifierDoubleTapSearch.reset();
return false;
}
return true;
}
function handleKeyup(e: KeyboardEvent) {
const shortcut = canCaptureModifierDoubleTapSearch(e) ? modifierDoubleTapSearch.handleKeyup(e) : null;
const shortcuts = settingsStore.editorSettings.shortcuts;
const quickOpenMatched = !!shortcut && matchesModifierDoubleTapShortcut(shortcut, shortcuts.quickOpen);
if (shortcut && (quickOpenMatched || matchesModifierDoubleTapShortcut(shortcut, shortcuts.globalSearch))) {
e.preventDefault();
e.stopPropagation();
quickOpenForceContent.value = !quickOpenMatched;
showQuickOpen.value = true;
}
tabSwitcherKeyboard.handleKeyup(e);
}
@@ -4016,6 +4094,9 @@ function handleTabSwitcherKeydownCapture(e: KeyboardEvent) {
}
function handleGlobalSearchKeydownCapture(e: KeyboardEvent) {
if (canCaptureModifierDoubleTapSearch(e)) modifierDoubleTapSearch.handleKeydown(e);
const target = e.target instanceof Element ? e.target : document.activeElement;
if (target?.closest("[data-shortcut-input], [data-sql-shortcut-input]")) return;
if (e.defaultPrevented || !isGlobalSearchShortcut(e, settingsStore.editorSettings.shortcuts)) return;
e.preventDefault();
e.stopPropagation();
@@ -4032,10 +4113,12 @@ function handleAuxiliarySearchKeydownCapture(e: KeyboardEvent) {
}
function handleTabSwitcherWindowBlur() {
modifierDoubleTapSearch.reset();
tabSwitcherKeyboard.handleWindowBlur();
}
function handleTabSwitcherVisibilityChange() {
modifierDoubleTapSearch.reset();
tabSwitcherKeyboard.handleVisibilityChange(document.visibilityState);
}
@@ -4121,6 +4204,10 @@ function rememberSidebarSearchSurface() {
lastFocusedSidebarSurface.value = true;
}
function rememberEditorContentSurface() {
lastFocusedSidebarSurface.value = false;
}
function setPluginWorkbenchTabRef(tabId: string, element: unknown) {
if (element && typeof element === "object" && "refresh" in element && typeof element.refresh === "function") {
pluginWorkbenchTabRefs.set(tabId, element as PluginWorkbenchTabHandle);
@@ -4150,6 +4237,10 @@ function refreshPluginWorkbenches(pluginId: string): void {
}
async function closeActiveSurface() {
// Cmd+W (and the native macOS "Close Tab" menu item) must not reach the surface behind
// an open modal dialog. While a dialog owns the window the keystroke dismisses that
// dialog instead, exactly like Esc, so the shortcut never goes dead on the user.
if (dismissOpenModalSurface()) return;
if (showSettingsPage.value) {
closeSettingsPage();
} else if (showPluginCenter.value) {
@@ -4255,7 +4346,10 @@ async function handleKeydown(e: KeyboardEvent) {
if (isNewQueryShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
void newQuery();
const target = e.target instanceof Element ? e.target : null;
const targetIsDocument = !target || target === document.body || target === document.documentElement;
const sidebarFocused = target?.closest("[data-app-sidebar]") || (targetIsDocument && lastFocusedSidebarSurface.value);
void newQuery({ preferredSource: sidebarFocused ? "sidebar" : undefined });
return;
}
if (isToggleSidebarShortcut(e, shortcuts)) {
@@ -4302,6 +4396,12 @@ async function handleKeydown(e: KeyboardEvent) {
appTabBarRef.value?.closeOtherActiveTabs();
return;
}
if (isCloseAllTabsShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
appTabBarRef.value?.closeAllTabs();
return;
}
if (isDisconnectAllActiveConnectionsShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
@@ -4618,6 +4718,9 @@ onMounted(async () => {
});
applyTheme();
void applyUiScale(settingsStore.editorSettings.uiScale);
window.addEventListener("pointerdown", modifierDoubleTapSearch.cancel, true);
window.addEventListener("focusin", modifierDoubleTapSearch.cancel, true);
window.addEventListener("compositionstart", modifierDoubleTapSearch.cancel, true);
window.addEventListener("keydown", handleGlobalSearchKeydownCapture, true);
window.addEventListener("keydown", handleTabSwitcherKeydownCapture, true);
window.addEventListener("keydown", handleAuxiliarySearchKeydownCapture, true);
@@ -4710,6 +4813,10 @@ onUnmounted(() => {
clearInterval(updateCheckTimer);
}
window.removeEventListener("keydown", handleNativeSelectAll, true);
modifierDoubleTapSearch.reset();
window.removeEventListener("pointerdown", modifierDoubleTapSearch.cancel, true);
window.removeEventListener("focusin", modifierDoubleTapSearch.cancel, true);
window.removeEventListener("compositionstart", modifierDoubleTapSearch.cancel, true);
window.removeEventListener("keydown", handleGlobalSearchKeydownCapture, true);
window.removeEventListener("keydown", handleTabSwitcherKeydownCapture, true);
window.removeEventListener("keydown", handleAuxiliarySearchKeydownCapture, true);
@@ -4731,12 +4838,13 @@ onUnmounted(() => {
<template>
<LoginPage v-if="setupRequired || (needsAuth && !authenticated)" :setup-mode="setupRequired" @authenticated="onLoginSuccess" />
<div v-show="!setupRequired && (!needsAuth || authenticated)" class="fixed inset-0 h-screen w-screen overflow-hidden">
<div v-show="!setupRequired && (!needsAuth || authenticated)" class="dbx-app-root fixed inset-0 h-screen w-screen overflow-hidden">
<div v-if="appBackgroundActive && appBackgroundObjectUrl" data-app-background class="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<div class="h-full w-full" :style="appBackgroundImageStyle"></div>
</div>
<TooltipProvider :delay-duration="300">
<SidebarDangerDialogHost />
<LinuxResizeHandles v-if="drawLinuxFloatingFrame" />
<div data-app-shell class="h-screen w-screen max-w-full min-w-[760px] min-h-[600px] flex flex-col bg-background text-foreground overflow-hidden" :class="{ 'dbx-desktop-window-frame': drawDesktopWindowFrame }" :style="appUiFontFamilyStyle">
<AppToolbar
v-if="!isDetachedWindowContext"
@@ -4811,6 +4919,7 @@ onUnmounted(() => {
data-editor-content
v-show="isSpecialPageActive || (!isAiPanelMaximized && !isHistoryPanelMaximized) || isZenMode"
:class="isDetachedWindowContext ? 'flex-1 min-w-0 overflow-hidden bg-background' : isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'"
@mousedown="rememberEditorContentSurface"
>
<div class="h-full flex min-h-0 min-w-0 flex-col">
<AppTabBar
@@ -4850,7 +4959,16 @@ onUnmounted(() => {
:focus-target="driverStoreFocus"
@update-count-change="updateAgentDriverUpdateCount"
/>
<PluginCenterPage v-if="pluginCenterTabOpen" v-show="pluginCenterActive" class="flex-1 min-h-0" :focus-target="pluginCenterFocus" :install-url-request="pluginCenterInstallRequest" @new-connection="openPluginConnectionDialog" @plugin-runtime-replaced="refreshPluginWorkbenches" />
<PluginCenterPage
v-if="pluginCenterTabOpen"
v-show="pluginCenterActive"
class="flex-1 min-h-0"
:focus-target="pluginCenterFocus"
:install-url-request="pluginCenterInstallRequest"
@opened="activateMainContentSurface('query')"
@new-connection="openPluginConnectionDialog"
@plugin-runtime-replaced="refreshPluginWorkbenches"
/>
<EditorSettingsPage
v-if="settingsPageTabOpen"
v-show="settingsStore.settingsPageActive"
@@ -4938,6 +5056,7 @@ onUnmounted(() => {
:active-output-view="activeOutputView"
:format-sql-request="formatSqlRequest"
:compress-sql-request="compressSqlRequest"
:keyword-case-request="keywordCaseRequest"
:selected-sql="selectedSql"
:cursor-pos="cursorPos"
:block-dangerous-redis-commands="blockDangerousRedisCommands"
@@ -5389,6 +5508,7 @@ onUnmounted(() => {
:database-type="queryEditorDdlDatabaseType"
:dialect="queryEditorDdlDialect"
/>
<McpSqlApprovalDialog v-if="isDesktop && !isDetachedWindowContext" />
<QueryEditorObjectSourceDialog
v-if="queryEditorObjectSourceTarget"
v-model:open="showQueryEditorObjectSourceDialog"

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