Compare commits

...

342 Commits

Author SHA1 Message Date
t8y2 91ebf9bd8e
fix(ai): fix Grok CLI provider test compile and icon format 2026-08-10 11:29:58 +08:00
t8y2 11c47d1250
fix(sql): preserve leading hints through pagination
Closes #5728
2026-08-10 11:09:55 +08:00
t8y2 89ca914f0e
chore(release): bump version to 0.5.79 2026-08-10 11:00:23 +08:00
chenow9 d08189ba7d
feat(ai): add Grok CLI as a Desktop no-API-key provider
* feat(ai): add Grok CLI as a Desktop no-API-key provider

Add grok-cli alongside Codex/Claude Code/Pi, with path/env settings,
isolated MCP-scoped headless runs, model listing, and connection test.

* feat(ai): expose Grok CLI reasoning effort like Codex

Attach low/medium/high effort capabilities to Grok models for the
assistant effort menu, wire resolve_model_effort for grok-cli, and
use the monochrome Grok mark icon.

* i18n: add Grok CLI error messages for remaining locales

Fill grokCli* cliErrors strings in es, it, ja, ko, pt-BR, and zh-TW
to match en/zh-CN and satisfy i18n autofill CI.

* fix(ai): align Grok CLI headless with official Build flags

Address review feedback on PR #5494:
- Use streaming-json + CliAgentJsonlDialect::GrokStreamingJson
- Switch permission to --always-approve and MCPTool(dbx__*) rules
- Prefer --effort over --reasoning-effort; keep --prompt-file

Verified with unit tests and a local headless e2e against grok 1.0.

* style(ai): fix rustfmt for Grok CLI provider files

* test(ai): fix Grok CLI entry in provider ordering spec

* fix(ai): harden grok CLI error wording and config schema

Add 'not signed in' to classify_grok_run_error and test_grok_connection
auth detection — real @xai-official/grok 1.0.0 emits this exact wording
on headless auth failure (verified against the shipped binary).

Remove invalid config.toml fields: permission_mode='always-approve'
(not a valid enum; auto-approve is driven by --always-approve flag) and
startup_timeout_sec/tool_timeout_sec/enabled_tools (not mcp_servers
schema fields per 'grok mcp add').

---------

Co-authored-by: t8y2 <1156263951@qq.com>
2026-08-10 10:54:27 +08:00
zipg fff07ada83
fix(grid): remove inline NULL badge from result header
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-10 10:32:46 +08:00
zipg a3cf5fe256
fix(postgres): avoid crash on PG-compatible database connect
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-10 10:32:42 +08:00
zipg a8495b2454
fix(grid): 保留 JSON 详情双击选词不进编辑
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-10 10:28:33 +08:00
zipg 4311ace960
fix(grid): 修复批量生成值后保存快捷键失效
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-10 10:28:29 +08:00
zipg 6c2ca2eadd
fix(settings): 防止设置读取失败后覆盖用户配置
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-10 10:24:42 +08:00
t8y2 0c9b6c7e1d
fix(import): preserve empty strings in Excel by default
Closes #5329
2026-08-10 09:54:30 +08:00
github-actions[bot] a1918ec8c2 chore(docs): refresh contributor activity 2026-08-09 18:09:28 +00:00
github-actions[bot] c20ad57f9d chore(packages): release 0.4.57 [skip node-packages-release] 2026-08-09 17:41:08 +00:00
github-actions[bot] b2e450308e chore: bump module versions [skip ci] 2026-08-09 17:32:01 +00:00
github-actions[bot] 318e1dfc5d chore(jdbc): bump plugin version [skip ci] 2026-08-09 17:25:29 +00:00
t8y2 a41b3bf417
chore(release): bump version to 0.5.78 2026-08-10 01:24:39 +08:00
jinmo 868f7961e3
feat(completion): fix SQL alias completion, star expansion, and formatter layout 2026-08-10 01:11:25 +08:00
Abeautifulsnow 0402fea37b
feat(ai): make default assistant mode (Ask/Agent) configurable 2026-08-10 01:06:44 +08:00
hanxuanyu 681c6f35c5
feat(dolt): add native profile, branch database discovery, and SQL completion 2026-08-10 00:37:34 +08:00
t8y2 56edbd3656
fix(connection): keep healthy tunnel ports stable
Closes #5716
2026-08-10 00:15:17 +08:00
大峰 f66a18d88d
feat(editor): add SQL intention actions (Alt+Enter style) 2026-08-10 00:07:12 +08:00
shenmo 789482eceb
docs(readme): update Spark Store name and add one-key install badge 2026-08-09 23:33:24 +08:00
tyloryang f1c998c9cc
fix(etcd): make gRPC max inbound message size configurable 2026-08-09 23:28:02 +08:00
zipg 5966aea43b
feat(completion): expand INSERT all-columns with VALUES tab stops 2026-08-09 23:24:36 +08:00
zipg 295c260a72
feat(connection): persist manually selected default schema 2026-08-09 23:23:55 +08:00
t8y2 99888aaeff
fix(ai): restore login shell PATH for Pi agent
Closes #5714
2026-08-09 23:01:46 +08:00
zipg fdad1873bc
fix(ui): improve unsaved dialog secondary button contrast 2026-08-09 22:30:38 +08:00
zipg 35362c8bf4
feat(grid): import BYTEA/BLOB cell from file with size gate 2026-08-09 20:13:18 +08:00
zipg 31587bcbfe
feat(grid): preserve selection highlight after data refresh 2026-08-09 20:04:24 +08:00
t8y2 09edbfbef3
fix(postgres): decode point query values
Closes #5711
2026-08-09 20:00:16 +08:00
zipg cdfa993642
fix(completion): keep auto alias and schema-qualify SQL Server FK JOIN candidates 2026-08-09 19:55:12 +08:00
azens a4129c02fa
fix(editor): rebuild current SQL statement frame with pixel-accurate layer 2026-08-09 19:51:12 +08:00
zipg 7c2528d32f
feat(grid): show nullable indicator in query result headers 2026-08-09 19:35:25 +08:00
t8y2 5d5b4f6154
fix(query): normalize SAP HANA metadata identifiers
Closes #5299
2026-08-09 19:19:15 +08:00
t8y2 1daaac03aa
fix(jdbc): show Oracle schemas for custom drivers
Closes #5638
2026-08-09 18:05:23 +08:00
t8y2 8f597a0516
fix(bigquery): allow external JDBC driver selection
Closes #5534
2026-08-09 17:20:52 +08:00
t8y2 9e3013adf9
fix(mongodb): support createUser shell commands
Closes #5639
2026-08-09 15:51:04 +08:00
t8y2 e7f496bf0b
fix(kafka): bind SSH tunnel to advertised loopback port
Closes #5571
2026-08-09 14:47:14 +08:00
miracle 71ccb3d97d
feat(execution): multi-database SQL execution with scoped targets 2026-08-09 14:24:10 +08:00
zipg 328a2aa107
feat(redis): improve key browser toolbar and infinite scroll loading 2026-08-09 14:14:45 +08:00
二丫讲梵 b1fcb23625
feat(data-grid): improve transpose view record location and last-column alignment 2026-08-09 14:14:08 +08:00
zipg 72ff141f02
feat(timeout): support global connection and query timeout settings 2026-08-09 13:57:42 +08:00
zipg 5c343ab416
fix(tab): avoid accidental drag on slight tab movement 2026-08-09 13:57:02 +08:00
t8y2 d72d4beed3
fix(agent): avoid validation contention on active JDBC pools
Closes #5567
2026-08-09 11:59:30 +08:00
t8y2 2b3bf1367d
fix(jdbc): resolve primary keys from returned metadata identity
Closes #5574
2026-08-09 10:47:46 +08:00
t8y2 ca5a5dc9bf
fix(postgres): support RisingWave table listing
Closes #5584
2026-08-09 02:42:36 +08:00
github-actions[bot] 7445f647c8 chore(docs): refresh contributor activity 2026-08-08 18:09:08 +00:00
t8y2 cedd2e5097
fix(query): show returned rows in execution summary
Closes #5621
2026-08-09 01:27:23 +08:00
t8y2 f10642e29a
fix(mongodb): support legacy aggregate commands
Closes #5575
2026-08-08 20:30:20 +08:00
t8y2 d38e58b136
fix(mcp): support bounded long-value windows
Closes #5620
2026-08-08 19:20:46 +08:00
t8y2 ad9ca8400b
fix(tdengine): recognize tbname in stable diagnostics
Closes #5685
2026-08-08 18:28:11 +08:00
t8y2 be7b9ac021
fix(highgo): preserve public functions in schema queries
Closes #5617
2026-08-08 17:15:38 +08:00
t8y2 3d55100717
fix(oracle): allow DML execution plans
Closes #5590
2026-08-08 16:38:42 +08:00
t8y2 d74e007150
fix(sqlserver): preserve Unicode literals in SQL exports
Closes #5680
2026-08-08 15:37:09 +08:00
t8y2 28b3491843
fix(oracle): preserve table metadata in DDL and clones
Closes #5215
2026-08-08 14:34:55 +08:00
Abeautifulsnow 5b85f9eae8
feat(completion): configurable SQL completion trigger modes 2026-08-08 12:00:50 +08:00
jinmo bad7f6b472
feat(mqtt): optimize console and subscription sync 2026-08-08 11:56:32 +08:00
zipg e49d86b101
feat(mongo): refine document browsing and structured filter experience 2026-08-08 11:36:57 +08:00
zipg 0107d5fafb
fix(editor): copy full error text from multi-statement execution summary 2026-08-08 11:34:15 +08:00
zipg c4289e6f21
fix(kingbase): use # as LIKE escape for MySQL-compat schema query 2026-08-08 11:34:05 +08:00
二丫讲梵 fc1f89ddb6
feat(etcd): multi-select tree keys for batch export and safe delete 2026-08-08 11:33:47 +08:00
Ez 062daf3093
fix(web): make logo and icon assets base-path aware 2026-08-08 11:33:37 +08:00
zipg 2c036e2860
fix(structure): silence column reorder notice when adding columns 2026-08-08 11:33:28 +08:00
zipg 50c42d8935
fix(postgres): support array types when adding columns 2026-08-08 11:13:42 +08:00
Guoyu Su 36e5fa4f4c
feat(ai): add Cursor CLI provider 2026-08-08 11:08:18 +08:00
t8y2 25fd5677c7
fix(yashandb): load function and procedure source
Closes #5625
2026-08-08 11:04:58 +08:00
Fernando Possebon 63498da121
feat(docs): export database documentation as standalone HTML 2026-08-08 11:04:16 +08:00
二丫讲梵 efd0c381ad
feat(nacos): namespace production protection and sidebar visibility 2026-08-08 10:55:21 +08:00
zipg 9057be0e78
fix(sql): skip metadata refresh for temporary tables 2026-08-08 10:44:21 +08:00
GIWTO e00a6a8b52
fix(wry): swallow WebView2 F6 accelerator to prevent black screen 2026-08-08 10:40:33 +08:00
zipg fa2365153a
fix(kingbase): compat V8 routine source and add extension details 2026-08-08 10:38:16 +08:00
高桥凉介 1405f00060
feat(ui): make new-connection dialog draggable 2026-08-08 10:38:08 +08:00
Elias 159e813c9c
feat(xugu): expose routine parameter metadata 2026-08-08 10:37:59 +08:00
Fernando Possebon bdf87ac255
fix(docs): move Documentation to database context menu 2026-08-08 10:37:50 +08:00
t8y2 9a255aa135
fix(jdbc): enable Phoenix auto-commit
Closes #5648
2026-08-08 02:34:05 +08:00
github-actions[bot] e134acbccb chore(docs): refresh contributor activity 2026-08-07 18:17:44 +00:00
t8y2 29fe571436
fix(mcp): use desktop agent store for local backend
Closes #5633
2026-08-07 23:30:58 +08:00
t8y2 42bd1d4d16
fix(agent): sync MongoDB explain protocol contract
Refs: #5619
2026-08-07 22:21:41 +08:00
t8y2 996ce42e80
fix(mongodb): support find explain commands
Closes #5619
2026-08-07 18:51:04 +08:00
t8y2 bac8f4166f
fix(mcp): route MongoDB getIndexes through metadata
Closes #5618
2026-08-07 17:21:31 +08:00
zipg 43234ed47d
fix(grid): fix short column header clipping with index indicator 2026-08-07 17:06:19 +08:00
Guoyu Su 309e3aef5e
feat(ai): add OpenCode CLI provider 2026-08-07 17:06:03 +08:00
Elias f711855ddf
feat(xugu): enable schema diagrams 2026-08-07 17:05:48 +08:00
t8y2 3affcbfa85
fix(ui): improve desktop hover and cursor feedback 2026-08-07 16:31:02 +08:00
t8y2 2df9a1dd48
fix(postgres): render daterange values as readable ranges
Closes #5616
2026-08-07 16:18:10 +08:00
t8y2 15b524404b
perf(editor): defer search match count past panel open animation 2026-08-07 16:16:04 +08:00
Freedom b10d276d96
fix(grid): unify and refine data grid selection visuals 2026-08-07 15:57:45 +08:00
Diego Fabricio 19401112d2
feat(grid): add checkbox/dropdown display mode for boolean columns 2026-08-07 15:51:09 +08:00
高桥凉介 9fab90b617
fix(grid): trigger index load on table identity change 2026-08-07 15:40:13 +08:00
一颗红心 9893ac8734
fix(export): use real file name and reveal for background table-export tasks 2026-08-07 15:32:16 +08:00
onenewcode 8fb7559310
fix(settings): keep cross-db same-name preset fields 2026-08-07 15:31:44 +08:00
Eddy Lei a5e46fd659
feat(query): show database server messages in query results 2026-08-07 15:18:32 +08:00
t8y2 4c7ddd266b
fix(ui): remove desktop backdrop blur 2026-08-07 15:17:22 +08:00
t8y2 904ab5456e
fix(sqlserver): recover duplicate wildcard probe columns
Closes #5606
2026-08-07 14:40:56 +08:00
zipg 4ae3e33103
fix(i18n): parse imported locale sections in autofill 2026-08-07 13:54:37 +08:00
高桥凉介 9bb1b7aa91
fix(export): show all dameng databases in export dropdown 2026-08-07 13:48:10 +08:00
Anubis 143fd96c3f
fix(postgres): correct query and connection timeout handling under SSH tunnel 2026-08-07 13:45:00 +08:00
Elias b8144da5f0
fix(xugu): preserve invalid view status in object tree 2026-08-07 13:44:44 +08:00
zipg 96d16b173b
fix(oracle): build windows-x64 agent with Go 1.20 for Win7 support 2026-08-07 13:44:28 +08:00
zipg 7d2a5d3355
fix(export): release db backup snapshot only on terminal state 2026-08-07 13:44:13 +08:00
zipg f9e93b356f
feat(grid): enhance field search and filter interaction 2026-08-07 13:43:55 +08:00
Abeautifulsnow 13878a1a30
fix(sidebar): match cross-word table names via prefix concatenation 2026-08-07 13:43:42 +08:00
如梦技术 fd3f9b169c
refactor(icons): shrink db icons and center mqtt.svg 2026-08-07 13:43:29 +08:00
jinmo e8885f7798
fix(mqtt): preserve saved connections when persisting MQTT topics 2026-08-07 13:43:09 +08:00
t8y2 6d2659ac97
fix(vastbase): tolerate NULL columns in index metadata scan 2026-08-07 12:58:10 +08:00
t8y2 9a00c10da4
fix(dameng): fall back from invalid catalog dates
Closes #5586
2026-08-07 12:51:51 +08:00
t8y2 d7a3a03357
fix(ci): handle read return value to satisfy clippy unused_io_amount 2026-08-07 12:28:07 +08:00
t8y2 4e02102610
fix(cloudberry): avoid privileged DDL catalog reads
Closes #5599
2026-08-07 12:11:13 +08:00
t8y2 ae13ca9c1a
feat(oscar): enable table structure editing and view source for Shentong 2026-08-07 11:58:52 +08:00
t8y2 dd5d74fa4f
fix(gbase8s): restore server name and table queries
Closes #5573
2026-08-07 11:39:13 +08:00
t8y2 1ac1b75f6d
fix(agent): preserve unsupported JDBC network timeout errors
Closes #5568
2026-08-07 10:42:12 +08:00
github-actions[bot] 518f60fc11 chore(packages): release 0.4.56 [skip node-packages-release] 2026-08-07 00:00:08 +00:00
github-actions[bot] 7f3d07bc29 chore: bump module versions [skip ci] 2026-08-06 23:46:07 +00:00
t8y2 d959045889
chore(release): bump version to 0.5.77 2026-08-07 03:39:29 +08:00
Fernando Possebon f30d59b989
feat(docs): database documentation viewer and DBML export
* feat(docs): add SchemaSnapshot model

* feat(docs): infer relationship cardinality from foreign keys

* fix(docs): resolve foreign keys within the source schema

* feat(docs): convert group hue to sRGB hex for DBML

* feat(docs): add DBML lexical primitives

* feat(docs): render DBML table blocks

* feat(docs): render DBML refs, enums and table groups

* feat(docs): assemble complete DBML documents

* feat(docs): collect schema snapshots with bounded fan-out

* fix(docs): gate FK warning on engine capability and reference synthesized enums

* fix(docs): qualify synthesized enum references in multi-schema output

* test(docs): anchor the multi-schema enum reference assertion

* feat(docs): add snapshot collection route

* feat(docs): add collect_docs_snapshot to DbxBackend

* feat(docs): add dbx dbml command

* test(docs): add live snapshot and DBML verification

Runs collect_snapshot + to_dbml against a real PostgreSQL database
(organon, 47 tables) and asserts structural DBML validity: Project
header, every table present, balanced braces, trailing newline.

* refactor(docs): use sort_by_key for snapshot table ordering

* docs(docs): add database documentation design and implementation plan

Records the Part 1 design (SchemaSnapshot + DBML export) and the plan that
produced it. The plan carries an appendix listing the thirteen assumptions
that proved wrong during execution, so the corrected facts are not re-derived
from the surrounding prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn

* fix(docs): use native enum names, canonical engine labels, and surface metadata failures

Final review fix wave covering four findings:

- PostgreSQL named enums (e.g. `ConversationStatus`) now keep their own
  type name and synthesized: false, instead of being renamed to
  `{table}_{column}` and losing identity. A type shared by several
  columns now dedupes to a single Enum block instead of colliding or
  duplicating. `synthesize_enum` and `render_type` route through one
  shared `enum_type_name` helper so the two can't drift apart again.
- `database_type` (and the CommentsUnsupported/NoForeignKeyMetadata
  warnings) now use the same canonical engine label already used
  throughout table_structure_sql's own warning prose, instead of a raw
  Rust Debug string (`Postgres`, `SqlServer`, `MongoDb`).
- An index-fetch failure during collection now surfaces as a
  TableSkipped warning instead of silently degrading to an empty index
  list, which relations.rs uses to infer relationship cardinality.
- A schema-enumeration failure now surfaces as a warning instead of
  silently proceeding against schema "".
- Removed the redundant Arc<Semaphore>; buffer_unordered already caps
  concurrency at MAX_CONCURRENT_TABLES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn

* feat(docs): add annotation file model

* test(docs): assert full round-trip fidelity for the annotation model

* feat(docs): add per-engine annotation key folding

* fix(docs): do not fold identifiers on case-sensitive engines

* feat(docs): load and validate the notes file

* fix(docs): report a version mismatch before unknown-field errors

* feat(docs): merge annotations into the schema snapshot

* feat(docs): detect orphaned annotations without deleting them

* feat(docs): add --notes to dbx dbml

* feat(docs): remember a notes file path per connection

Adds docs_notes_path to ConnectionConfig so the desktop app (Part 3)
can persist where a connection's documentation notes file lives. The
CLI is unaffected — it takes an explicit --notes path.

ConnectionConfig has a hand-written Deserialize impl that delegates to
a mirror struct, ConnectionConfigData, and converts via From. Adding
the field only to ConnectionConfig would compile but never populate
from stored JSON, since ConnectionConfigData's fields are what serde
actually reads. The field is threaded through all three places:
ConnectionConfig, ConnectionConfigData, and the From impl, mirroring
the existing `color` field.

* test(docs): verify annotations against a live database

* test(docs): assert exactly one orphaned annotation

* feat(docs): add snapshot types for the docs viewer

* fix(docs): correct snapshot type nullability and add missing column fields

* feat(docs): add a real-output fixture and drift conformance test

The fixture is generated by dump_docs_fixture.rs from a live collect_snapshot
run against the keycloak database in the shared local-infra stack, with
annotations applied so a LOCAL note, column note, group and orphanedNotes
warning are all present. fixtureConformance.spec.ts asserts against that real
JSON rather than a hand-written literal, so a change to the Rust snapshot
shape breaks the test instead of silently drifting from the hand-maintained
types.ts.

Keycloak is used because its schema is public open-source knowledge, so the
committed fixture carries no private schema. The kept tables are an explicit
allowlist rather than an alphabetical slice, because the conformance test
needs a connected foreign-key subgraph: protocol_mapper has two foreign keys
to different tables and composite_role has two to the same one.

* feat(docs): add index grouping for the docs viewer

* feat(docs): describe snapshot warnings for the viewer

* feat(docs): add client-side search for the docs viewer

* test(docs): make the search fixture able to fail

* feat(docs): expose group hue as a CSS custom property

* docs(plan): add annotations and viewer plans, correct Task 7

Parts 2 and 3a were planned after the first plan was committed and were never
tracked. docs/superpowers/ is gitignored, so both needed -f, matching how the
existing specs and plans in that directory were added.

The viewer plan's Task 7 is corrected against the installed marked@18.0.4. Its
original text carried four defects, found by probing the library rather than by
review: an assertion that fails against a correct implementation, a pre-escaping
approach that double-escapes entities, a javascript: blocklist with live
bypasses (entity-encoded, vbscript:, data:text/html, and <img src>, which was
never covered at all), and a link renderer whose text property is raw markdown
source rather than parsed HTML — an XSS hole found while verifying the fix for
the previous defect.

The viewer plan also gains a corrections appendix grouping defects by failure
mode. The annotations plan is committed as written; its defects are recorded in
a follow-up.

* feat(docs): render note markdown with raw HTML escaped

* fix(docs): drop protocol-relative URLs in note markdown

The URL allowlist permitted anything starting with / so relative paths work,
and //evil.com qualifies. Over https that grants nothing a note author could
not do with an ordinary https link, but the Part 3b standalone export is
opened via file://, where //host/path is a UNC path. On Windows that opens an
SMB connection and leaks an NTLM hash, with no click required since images
auto-load, and it is plantable from a COMMENT ON value.

Found by an adversarial probe of the committed module rather than by review;
the backslash form was already dropped, only the slash form slipped through.

* docs(plan): add corrections appendix to the annotations plan

Groups the defects found executing Part 2 by failure mode, matching the form
of the viewer plan's appendix. The notable one is Mode A: docs_notes_path had
to be added in three places because ConnectionConfig has a serde mirror
struct, and adding it in one place compiles, passes a round-trip test written
against the same struct, and then reads None forever after every load.

Also records a controller hypothesis that turned out to be wrong, since
checking it cost one read.

* fix(docs): reject backslash protocol-relative URLs in notes

The // guard from the previous commit was itself a blocklist: /\evil.com
starts with a single slash, so it passed, and the WHATWG URL spec treats /\ identically to // for special schemes. Browsers normalise the backslash, so
it reaches the same file:// UNC path and the same no-click NTLM leak.

Rejecting both separator characters in both positions closes it. Found by
probing what String(raw).trim() leaves unnormalised before the prefix checks;
the third defect on this file found by probing rather than by reading.

* feat(docs): add docs viewer components

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn

* test(docs): pin each theme's legacy colour base independently

The ordering check indexOf(hsl) < indexOf(@supports) quantified over any
occurrence, so deleting the light .docs-group block left the dark block's hsl
satisfying it. The test passed while every table group rendered colourless on
light-theme WebViews without oklch.

Asserting each selector's own base block catches deleting either one. Found by
the task implementer, which deleted one block and then both to show the guard
pinned 'some base exists' rather than 'each selector has a base'.

* fix(docs): escape single quotes in note attribute values

escapeHtml covered & < > and double quotes but not single quotes. Not
exploitable today because every attribute in this file is double-quoted, but
that is a formatting convention enforced nowhere and living in a different
part of the file from the escaper. A future edit writing title='...' would
turn a formatting choice into an attribute breakout.

Raised by review as latent fragility rather than a defect; fixed because the
escaper should be correct on its own rather than correct-given-an-invariant.

* test(docs): match single-quoted v-html bindings in the contract guard

The guard matched /v-html\s*=\s*"([^"]*)"/ — double quotes only. A binding
written `v-html='table.note'` produced zero matches and passed, handing a
database COMMENT ON value straight to the DOM with the renderNote sanitiser
bypassed. Both quote styles are valid Vue and nothing in the repo enforces
one, so the guard had a hole exactly where it mattered most.

Verified by temporarily rewriting a real WikiIndex binding as
`v-html='table.note'`: the test now fails with "WikiIndex.vue: v-html must
render renderNote output: expected 'table.note' to contain 'renderNote'".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn

* fix(docs): cap search results per kind instead of overall

DocsSearch sliced searchDocs output to 40 AFTER concatenation, and the
concatenation order is tables -> columns -> groups -> enums. Columns always
flooded the list, so the cap deleted the tail — every group and enum hit.
Against the real fixture, "e" produced 155 hits (9 table, 133 column, 1 group,
12 enum) and rendered 9 tables, 31 columns and nothing else; groups and enums
were structurally unreachable through search.

Cap each kind against its own limit inside docsSearch.ts, where the logic is
tested, and drop the slice from the template so exactly one place limits
results. Ranking is unchanged: tables still precede columns.

Verified by reverting to a single .slice(0, 40) over the concatenation: the
new tests fail with "enums must survive a column flood: expected false to be
true" and "expected 30 to be 20".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn

* fix(docs): move the index card note out of its button

The card rendered renderNote output with v-html inside the <button>. A note
containing a markdown link — [spec](https://example.com) — put an <a> inside a
<button>: invalid nesting, and the anchor was not keyboard reachable because
the button swallows it in the tab order.

The <li> now carries the card's border, background and hover, the button holds
only the table name and kind, and the note is its sibling. Visually identical —
same padding, same 0.5 gap, previously mt-0.5 — and the name row still spans
the full width as the click target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn

* test(docs): pin every fixture struct's key set in both directions

The guard pinned ColumnInfo and IndexInfo plus three DocTable keys, and only
in the "no missing key" direction. Relationship, FieldRef, DocEnum,
ProjectMeta, ColumnNote, ForeignKeyInfo, TableGroup and 10 of 13 DocTable keys
were unchecked. Renaming Relationship::to to `target` in Rust kept the suite at
67/67 and vue-tsc at exit 0 while RelationshipList read `field.table` on
undefined and every table page rendered blank.

Every struct in the fixture is now pinned both ways — no missing key, no
unexpected key — over every instance rather than element [0]. Each direction
catches a different half of a rename. Object.hasOwn throughout, so a key that
is present and null stays distinguishable from one skip_serializing_if omitted.

ForeignKeyInfo, TableGroup and the SchemaSnapshot root are included beyond the
list the review gave: they are equally present in the fixture and equally
unpinned.

Verified against a modified copy of the fixture outside the repo with
Relationship::to renamed: "Relationship[0] must always carry to: expected
false to be true".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn

* fix(cli): error when an explicit --notes path does not exist

load_annotations returns Ok(None) for a missing file, which is right for the
implicit per-connection notes path — it may legitimately not exist yet. It is
wrong for --notes, where the user named a specific file: a mistyped path
produced DBML with every note silently absent and no diagnostic at all,
indistinguishable from a database that has no documentation.

Both final reviewers independently ruled this must-fix-before-merge.

* fix(docs): treat an explicit FK ref_schema as authoritative

find_target tried the explicit ref_schema, then fell through to the source
table's own schema on failure. When the referenced schema was not collected —
routine, since users select schemas — an FK from sales.orders to
archive.customers resolved to sales.customers instead: a different table, and
a diagram that is confidently wrong rather than visibly incomplete.

The function's own doc comment already promised that keys pointing outside the
collected set are dropped. Now it does that.

This is a regression of the defect found in Part 1: the three-tier lookup was
added then, but tier one was written to fall through rather than to decide.

* fix(docs): corroborate engine capability warnings against what was collected

supports_comments and supports_foreign_keys delegate to the structure
editor's DDL-generation capabilities, not to introspection support. IRIS is
the proven divergence: it reports %DESCRIPTION on introspection but DBX
cannot ALTER an existing one, so the flag is false while the collector reads
and includes those comments — producing a snapshot that warned comments were
unsupported alongside the comments themselves.

Each warning now fires only when the flag says the engine cannot AND
collection found nothing of the kind to contradict it. ClickHouse and Doris,
which genuinely report no foreign keys, still warn. The doc comments now say
what the functions actually measure.

Found by final review, which traced every other caller to establish the
flag's real semantics.

* docs(fixture): finish repointing the fixture source to keycloak

Follows the rebase that replaced the fixture at its origin commit. Repoints
the live annotation test's project identity and both plans, and records the
one capability keycloak costs us.

Keycloak declares no PostgreSQL enum types, so the fixture cannot exercise
DocEnum — a Rust-side rename of a DocEnum field would pass every test here
and break the viewer's enum rendering silently. Rather than delete the test,
it now asserts the gap, so it fails the day the fixture source gains an enum
and prompts restoring the pin.

* docs(spec): design for in-app database documentation (Part 3b)

Mounts the Part 3a viewer in DBX and makes it editable: table/column notes,
table groups, and per-group colour, autosaved to a notes file that can live
in the user's repository.

Two findings shaped the scope. DBX already ships SchemaDiagramDialog, so the
viewer links to it rather than building a second ER diagram — only the Part 3c
export needs its own minimal renderer, because that dialog reaches into stores
and cannot be inlined. And nothing currently reads docs_notes_path or writes
annotations at all, so 'in-app editing' needs new Rust rather than frontend
wiring alone.

The standalone export, dbx docs verb and hash routing are deferred to 3c.

* docs(plan): implementation plan for in-app database documentation

Ten tasks: atomic annotation save and path resolution, Tauri commands, web
route parity, the frontend facade, pure edit transforms, the i18n namespace
with a parity guard, editing components, the enum page, edit plumbing through
the viewer, and the dialog with debounced autosave.

Self-review caught three defects before dispatch, all the same class that cost
Part 3a ten fix rounds: a return type named DescribedWarning that does not
exist (it is WarningNotice), a test calling emptySnapshot() which does not
exist, and a table() helper invoked with columns when its real signature takes
a groupId. Every identifier the plan names is a claim about the codebase.

* docs(plan): resolve the data directory without a dbx-mcp dependency

Tasks 2 and 3 called dbx_mcp::paths::app_data_dir(), but neither src-tauri nor
dbx-web depends on dbx-mcp, so neither would have compiled. Both already have
a better source: AppState.storage.data_dir() honours a custom data dir, and
WebState already carries data_dir.

Found by the pre-flight scan before any implementer saw it.

* docs(plan): fix Task 1 against the real crate (no Default, no tempfile)

ConnectionConfig has no Default derive and ~60 fields, so resolve_notes_path
now takes the connection id and the optional override directly — the two
fields it actually reads. The test pain was pointing at the signature.

dbx-core has no dev-dependencies, so the tests use the temp_dir + uuid idiom
already present in annotations.rs rather than tempfile.

* feat(docs): add atomic annotation save and notes path resolution

* docs(plan): guard the autosave against concurrent writes

flush() cleared the debounce timer but not an in-flight write, so closing the
dialog while a debounced save was awaiting the backend started a second one.
Two concurrent saves of the same file waste a round trip, race to land stale,
and are the exact concurrency that corrupts the notes file when the temp path
is not unique per writer.

Found while adjudicating the Task 1 review, which demonstrated the Rust half
of the same problem.

* fix(docs): make temp paths unique to prevent concurrent save corruption

* fix(docs): replace vacuous atomicity test with inode-based verification

* docs(plan): correct the apply_annotations import path

dbx_core::docs re-exports collector, color, dbml, keys, relations and snapshot
but NOT annotations, so apply_annotations and friends are only reachable at
dbx_core::docs::annotations. Tasks 2 and 3 both used the shorter path and
would not have compiled.

Verified against crates/dbx-core/src/docs/mod.rs before either was dispatched.

* fix(docs): truncate temp filename to stay within 255-byte filesystem limit

* feat(docs): add Tauri commands for docs snapshot and annotations

* docs(plan): propagate the resolve_notes_path signature to its callers

Pre-flight changed resolve_notes_path to take (connection_id, docs_notes_path,
data_dir) instead of a ConnectionConfig, but only Task 1 was updated. Tasks 2
and 3 still called it with the old signature and would not have compiled.

The Task 2 implementer caught it and used the correct form from its dispatch
note. Task 3 had the identical stale call and had not been dispatched yet.

The plan's self-review checks signature consistency across tasks; this changed
AFTER that review, during pre-flight, and nothing re-ran the check.

* docs(plan): pin Task 4's http.ts idiom and the Tauri argument names

http.ts uses a post<T>(url, body) helper at line 222; the plan said only
'match the existing idiom', which is delegating verification to someone with
less context. Written out concretely now.

Also records what Task 2's review flagged as unverifiable from its own diff:
Tauri serialises command arguments by name, so the invoke object keys must
match the Rust parameter names. That mismatch compiles cleanly on both sides
and fails only when a user clicks — and it falls in the gap between two
task-scoped reviews, since neither diff contains both halves.

* feat(docs): add web routes for annotation load, apply and save

Mirrors the Tauri commands (docs_load_annotations, docs_apply_annotations,
docs_save_annotations) added in the previous task: collect returns the raw
snapshot, apply is separate so the shadowedNote rule stays in one place.

* docs(plan): make the i18n parity guard actually observable

Every non-English locale is export default withEnglishFallback({...}) — the
fallback is applied at module level, inside the locale file. Only en.ts is a
bare object. So importing a locale's default export yields the ALREADY-MERGED
object, and the parity test would have found every key present in every locale
and passed while translations were missing.

The test written to catch silent English fallback would have been silently
defeated by that fallback.

Task 6 now puts the new namespace in per-locale modules under locales/docs/,
which the test imports directly and unwrapped. Scoped entirely to the new
namespace; the existing 315 KB of keys are untouched. Step 6 also asks the
implementer to demonstrate the trap: point the imports back at the merged
modules and watch a missing key pass.

* feat(docs): expose docs snapshot and annotations to the frontend

* docs(plan): locate the duplicated table-key rule correctly in Task 8

The plan said docsIndex.ts builds the qualified table key inline. It does not
— it groups by table.schema, a section key. The table key rule lives in a
private qualified() in docsSearch.ts and in a hand-rolled tableKey() in
DocsApp.vue, DocsSidebar.vue and WikiIndex.vue.

Part 3a's final review flagged that duplication as a Minor and it was
deferred. Task 8 was about to add a fifth copy, so it now extracts
docsKeys.ts first and replaces the existing ones. This is the key annotations
are stored under, so two call sites disagreeing attaches a note to the wrong
table.

* feat(docs): add pure annotation edit transforms

* docs(plan): ground the group hue picker in DBX's existing swatch idiom

ConnectionDialog.vue already has a swatch row — h-6 w-6 rounded-full buttons,
ring-2 selected state, i18n titles. Task 7 now points at it so the new picker
looks native.

With an explicit warning not to copy the fill mechanism: connection colours
are hex painted via Tailwind classes, group colours are hues rendered through
docs.css. A naive copy introduces hex literals and fails the contract test —
correctly, since a hardcoded hex cannot stay legible on both grounds, which is
why groups store a hue at all.

* docs(plan): make Task 10's dialog wiring concrete

Five exact locations, all verified: the store ref and its export, the
useDialogSources watcher, the AppDialogs import and render, and the
ObjectBrowser trigger plus its context-menu entry.

Records the non-obvious part: the watcher clears the source back to null
after firing, and that clearing is what makes the dialog re-openable —
without it, setting the same value twice does not re-trigger the watch.

ObjectBrowser is the entry point rather than the connection tree, because the
tree has no diagram entry either and ObjectBrowser already supplies exactly
the prefills the docs dialog needs.

* feat(docs): add the docs i18n namespace with a parity guard

* fix(docs): ban vue-i18n from standalone-exportable docs components

WarningBanner.vue used useI18n() directly, which throws with no Vue app
instance -- exactly the standalone HTML export case describeWarning's
translator parameter exists to avoid. Thread translate as a prop from
DocsApp instead, and add vue-i18n/useI18n( to the component contract's
forbidden list so the constraint is enforced, not just documented.

* docs(plan): note that DocsApp already has snapshot and translate

Task 6's fix added translate to DocsApp when describeWarning started taking a
translator. Task 9 said to add it, which would have been a duplicate prop.
It now says to add only annotations and readonly to the existing defineProps.

* feat(docs): add note editor, group editor and group picker

* docs(plan): give Task 10 the dialog shell from SchemaDiagramDialog

Exact Dialog primitives, the get/set computed every dialog here uses to bridge
the open prop, and the sizing class copied verbatim from
SchemaDiagramDialog.vue:827 — the docs viewer is the same kind of full-window
workspace, not a form, so it should not invent dimensions.

Also states explicitly that this component lives outside src/docs/ and so may
and must use useI18n(): it is what supplies the translate prop the viewer
components need, since they are banned from importing vue-i18n themselves.

* test(docs): guard the light-ground group tokens too

* feat(docs): add the enum page and a shared table-key helper

EnumPage renders an enum's values and every column declared with that type.
It is read-only on purpose: AnnotationFile has no `enums` key, so an edited
note would have nowhere to be saved.

qualifiedTableKey moves the `schema.name` rule — bare name on schema-less
engines like SQLite and MySQL — into docsKeys, where the call sites that had
each copied it can share one definition. It is the key annotations are stored
under, so two call sites disagreeing would attach a note to the wrong table.

columnsUsingEnum matches data_type exactly rather than by substring: an enum
named `state` would otherwise claim every `estado` and `statement` column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTBWnT4goXLA9iqDrfTPqk

* refactor(docs): share one qualified table key across the viewer

Six call sites had each copied the `schema.name` rule; they now import
qualifiedTableKey from docsKeys instead. Two were not in the plan's list:
TablePage.vue and RelationshipList.vue. RelationshipList passes a remapped
FieldRef rather than a DocTable, which is why the helper takes
Pick<DocTable, "schema" | "name"> — that widening is what let every call site
be adapted directly instead of keeping a thin delegating wrapper.

Also strengthens the columnsUsingEnum substring guard, which was not guarding
anything. Its only column had type `integer`, and "integer".includes("state")
is false, so replacing the exact match with includes() left all 8 tests
green. Adds a `statement` column — the only type here that really contains
`state` — and drops `estado` from the doc comment, since it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTBWnT4goXLA9iqDrfTPqk

* feat(docs): thread editing through the viewer as emitted events

DocsApp gains `annotations` and `readonly` and re-emits a DocsEdit for every
change its children request. Nothing under src/docs/ persists anything, which
is what keeps the directory bundleable into the standalone HTML export; a new
contract guard now pins that by rejecting any component that names
save/load/applyDocsAnnotations.

NoteEditor is fed the MERGED snapshot note, not the local annotation layer. It
renders and edits one value, so seeding it locally would show nothing for a
note that came from a database comment. Writing over one shadows it, which is
what noteSource and shadowedNote already exist to disclose.

`annotations` is threaded for what the merge erases: `groups` carries the
editable GroupAnnotation records, while snapshot.groups carries resolved
TableGroups that GroupPicker and GroupEditor cannot write back to.

Also makes enums reachable. EnumPage rendered nowhere and search returned enum
hits that DocsSearch deliberately disabled, since enums carry no table key;
they now navigate by bare name, which is how columnsUsingEnum resolves them
too. Groups remain unclickable — they still have no page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTBWnT4goXLA9iqDrfTPqk

* feat(docs): mount the documentation viewer in DBX with autosaved editing

DatabaseDocsDialog hosts DocsApp outside src/docs/, which is what lets it use
useI18n() and supply the `translate` prop the viewer components are banned from
importing for themselves. It collects the snapshot, loads the notes file
(falling back to emptyAnnotations), and holds the raw snapshot so every edit can
re-derive the merged view through applyDocsAnnotations.

createAutosave debounces writes and, above all, makes a failure visible: a
silently swallowed write is the worst outcome here, because the user keeps
typing and believes their notes are saved. It also refuses to run two writes at
once — flush() clearing the timer does not stop a write already awaiting save,
and two concurrent writes of the same file is the exact race that corrupted the
notes file before the Rust side used a unique temp path. Both properties are
pinned by tests I confirmed fail when the guard is removed.

Loads and re-derivations carry a generation number so a slow response cannot
overwrite a newer one, and closing flushes the debounce rather than dropping a
note typed a moment earlier.

Trigger mirrors the schema diagram's wiring: docsSource on connectionStore, a
watch in useDialogSources that clears the source so the dialog is re-openable,
an async component in AppDialogs, and openDocs in ObjectBrowser. The context
entry is added at BOTH object menus — the plan named only the table one, but
views offer diagram.open too and documentation is no less relevant there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTBWnT4goXLA9iqDrfTPqk

* docs(plan): record Tasks 1-10 as done

The plan carried 58 unticked boxes after ten completed tasks, so progress had
to be reconstructed from the commit trail instead of read off the document.

Tasks 1-7 are ticked from that commit evidence rather than from step-by-step
observation — they landed in earlier sessions. Tasks 8-10 were executed and
verified directly.

Task 8 Step 2 stays open on purpose. `columnsUsingEnum` was already implemented
and committed before that step was reached, so its failure was never observed;
the exact-match guard was verified by Step 5 instead, which is what exposed
that the test could not detect a substring match at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTBWnT4goXLA9iqDrfTPqk

* feat(docs): print snapshot warnings as prose from the CLI

`dbx dbml` printed `{warning:?}`, so a skipped table surfaced as
`TableSkipped { table: "public.orders", reason: "permission denied" }` —
the struct shape, reading like a panic rather than like advice.

The prose lives in Rust rather than in the `docs.warnings` i18n namespace
because the CLI has no i18n runtime. That is the same constraint that made
`describeWarning` take a translator instead of importing vue-i18n: the
viewer translates, the CLI cannot, so each needs its own source for the
same text.

A second test asserts the rendering is not the Debug form, because
reverting the CLI to `{warning:?}` is a one-character edit that still
compiles and still prints something.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH

* feat(docs): let a connection point its notes file at a repository

`docs_notes_path` has existed on `ConnectionConfig` since Part 2 and has
been read by `resolve_notes_path` since Part 3b, but nothing ever set it —
so every connection silently used the app data directory default and the
override was unreachable.

The field is what makes schema documentation reviewable: pointing it at a
file inside a repository puts notes in the same diff as the migration that
changed the schema.

Gated on `isSchemaAware`, matching the row above it, since documentation is
a relational-only feature. A cleared field is normalised to absent rather
than "" — `resolve_notes_path` treats blank as unset, but an empty string
would still be persisted as though a path had been chosen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH

* docs: document database documentation and DBML export

Covers opening the viewer, notes and groups, the LOCAL/database-comment
rule, the notes file format and where it lives, every warning the viewer
can raise, and the `dbx dbml` verb including the CI drift check.

States the boundaries explicitly — relational engines only, no triggers or
procedures, and DBML export is one-way — because each of those is a
question the feature invites and would otherwise be answered by trying it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: t8y2 <t8y2@users.noreply.github.com>
2026-08-07 03:35:19 +08:00
Elias 352403d79e
feat(xugu): add programmable object compile actions
* feat(xugu): add programmable object compile actions

* chore(i18n): autofill new translations

---------

Co-authored-by: mapan <pan.anarkh1994@gmail.com>
Co-authored-by: dbx-i18n-bot <github-actions[bot]@users.noreply.github.com>
2026-08-07 03:30:52 +08:00
t8y2 c3bb16f93e
fix(grid): revert dirty cell base color to yellow 2026-08-07 03:29:52 +08:00
Guoyu Su dfb9971849
feat(sidebar): expose visible object filters in the connection tree
* feat(sidebar): expose visible object filter

* fix(sidebar): refine visible filter status

* refactor(sidebar): move visible filter status to details

* fix(sidebar): clarify visible filter action
2026-08-07 03:24:25 +08:00
t8y2 cc5a4068d2
fix(kafka): report the active KRaft controller
Closes #5453
2026-08-07 03:04:23 +08:00
Abeautifulsnow b437c470e1
fix(sql): keep Ctrl+Enter on current statement at trailing whitespace 2026-08-07 02:56:26 +08:00
Elias c32f92a902
feat(sidebar): show invalid object status marker in tree 2026-08-07 02:53:51 +08:00
Fernando Possebon 4efe579d90
fix(macos): surface failed main-window reveal diagnostics 2026-08-07 02:42:08 +08:00
AiLi 6a82a87e26
fix(rocketmq): wire Advanced timeout through and tighten Docker remap 2026-08-07 02:41:53 +08:00
二丫讲梵 d6fa7fd866
fix(sidebar): preserve table search focus and selection after refresh 2026-08-07 02:41:28 +08:00
AiLi 87fd52de87
fix(query): insert pagination before FOR UPDATE locking clauses 2026-08-07 02:41:15 +08:00
二丫讲梵 59187f2e41
feat(redis): view and edit Hash field expiry (HEXPIRE/HTTL/HPERSIST) 2026-08-07 02:41:04 +08:00
zipg f8b2924f42
fix(diagram): preserve viewport across fullscreen and restore on exit 2026-08-07 02:40:49 +08:00
t8y2 ef4d7664ac
fix(agents): include Neo4j in native release packaging
Closes #5489
2026-08-07 02:23:58 +08:00
monellin 4a0de8c586
feat(grid): support Home/End/PageUp/PageDown keyboard navigation 2026-08-07 02:19:45 +08:00
t8y2 4402faac1c
fix(jdbc): fall back when statement batches are unsupported
Closes #5556
2026-08-07 01:51:22 +08:00
如梦技术 cda6fd2600
refactor(mqtt): use Select for protocol version and refine mqtt icon 2026-08-07 00:13:34 +08:00
t8y2 1648c0b602
fix(milvus): honor configured database in connection checks
Closes #5547
2026-08-06 23:40:58 +08:00
Holmesin ba68892759
fix(linux): disable dmabuf renderer on software-only virtio gpu 2026-08-06 23:33:28 +08:00
zipg a1dfc81d4c
fix(oracle): highlight execution error position in editor 2026-08-06 23:16:35 +08:00
Guoyu Su 69f4450aee
feat(jdbc): add managed Apache Phoenix profile 2026-08-06 23:16:12 +08:00
amwps290 c835deb3f9
fix(mysql): correct geometry export lon/lat order via WKB round-trip 2026-08-06 23:15:59 +08:00
Elias 00bbfad055
fix(xugu): scope metadata queries to current database 2026-08-06 23:15:46 +08:00
二丫讲梵 e452ce26ce
docs(getting-started): add Docker environment variable guide 2026-08-06 23:15:34 +08:00
jinmo ab586d121a
fix(mqtt): persist topics and support advanced options 2026-08-06 23:15:17 +08:00
t8y2 670ddcfcb3
fix(editor): ignore formatter parse errors for incomplete SQL
Closes #5462
2026-08-06 22:41:42 +08:00
t8y2 48e5ef331a
fix(mcp): expose message queue topics
Closes #5214
2026-08-06 21:49:30 +08:00
Ez 142ccb175f
fix(web): serve index.html at base path root for subpath deploy 2026-08-06 20:18:46 +08:00
zipg 89a35d6e02
fix(editor): protect external SQL files from conflicting changes 2026-08-06 20:13:59 +08:00
zipg 575d87c022
fix(postgres): use FromSql presence for compatible type protocol 2026-08-06 20:11:11 +08:00
zipg 62b0c5d880
fix(grid): protect unsaved cell edits on data tab reuse 2026-08-06 20:10:56 +08:00
zipg 3e6b3d5321
fix(navigation): use target db for cross-database table jump 2026-08-06 20:10:44 +08:00
zipg a71c767174
fix(mongodb-agent): block readonly query misjudged as write 2026-08-06 20:10:31 +08:00
t8y2 fa15eb5707
fix(hive): preserve Hive table DDL metadata
Closes #5477
2026-08-06 19:56:29 +08:00
t8y2 ebb51cde16
fix(transfer): order Doris unique-key transfer pages
Closes #5472
2026-08-06 19:39:11 +08:00
t8y2 835864e8a8
fix(duckdb): ignore prefix aliases as SQL parameters
Closes #5408
2026-08-06 18:56:19 +08:00
t8y2 25dc3ec2f2
fix(sqlserver): count wildcard join query results
Closes #5522
2026-08-06 18:33:59 +08:00
t8y2 7bb5767bf7
fix(sql): preserve ampersand tenant routing hints
Closes #5488
2026-08-06 17:57:03 +08:00
t8y2 6f1bf65726
fix(sqlserver): support multi-column ALTER TABLE ADD
Closes #5484
2026-08-06 17:33:58 +08:00
二丫讲梵 cd6a3c8b5c
feat(nacos): add fuzzy configuration group filtering 2026-08-06 17:19:27 +08:00
onenewcode 939c8d493d
feat(elasticsearch): add response content search 2026-08-06 17:09:15 +08:00
t8y2 b7606ceee3
fix(oracle): serialize non-finite query values
Closes #5523
2026-08-06 16:38:23 +08:00
zipg 9d348f8ef7
fix(export): keep MySQL database export snapshots alive 2026-08-06 16:32:39 +08:00
onenewcode 71a3b383d3
fix(quick-open): clarify search result states 2026-08-06 16:31:33 +08:00
ptma 15cdbcb8a8
fix(query): show selected chart Y-axis fields 2026-08-06 16:30:20 +08:00
高桥凉介 f36c280d6d
feat(grid): show column index indicators in data headers 2026-08-06 16:29:26 +08:00
t8y2 b55d944514
fix(sqlserver): support OPTION query hints in analysis
Closes #5485
2026-08-06 16:11:44 +08:00
t8y2 2daf8cb4be
fix(mysql): show information_schema system views
Closes #5501
2026-08-06 15:32:20 +08:00
二丫讲梵 c0b2cfc2ca
fix(sidebar): sync tree node expansion state after async load 2026-08-06 14:45:25 +08:00
t8y2 74f7ce31b6
fix(readme): restore tagline and MCP badge placement 2026-08-06 14:30:45 +08:00
t8y2 87752a227b
feat(readme): refresh hero layout and badges 2026-08-06 14:19:41 +08:00
TangT 122b606bd7
fix(grid): keep canvas backing store at observed pixel density under reduced UI scale 2026-08-06 14:09:21 +08:00
lewis c398a07573
fix(editor): stabilize SQL run-statement gutter width 2026-08-06 14:00:09 +08:00
AiLi 0862e63389
fix(dameng): preserve table and column comments when cloning table 2026-08-06 13:01:59 +08:00
ssy 61e5073d25
feat(grid): support custom page navigation 2026-08-06 12:41:23 +08:00
二丫讲梵 c754b85295
feat(nacos): improve service and instance management 2026-08-06 12:16:52 +08:00
James Leong b5ba730404
fix(sidebar): keep connection tooltip open while copying 2026-08-06 12:13:56 +08:00
t8y2 0f319edca9
feat(sponsors): add Atlas Cloud 2026-08-06 11:50:52 +08:00
t8y2 3bad08a56b
fix(ui): remove button press translate/scale for stable desktop feel 2026-08-06 02:49:09 +08:00
github-actions[bot] 5547923b84 chore(docs): refresh contributor activity 2026-08-05 18:33:53 +00:00
t8y2 ebb9b35c10
fix(mcp): sync runtime config cache so new desktop connections work without reload 2026-08-06 02:30:26 +08:00
t8y2 9dbbfb4a06
feat(sidebar): show connection note next to label with tight comment spacing 2026-08-06 02:11:02 +08:00
t8y2 67e6b3f32d
Revert "feat(sidebar): expose visible object filter"
This reverts commit 580b70e1d4.
2026-08-06 01:27:16 +08:00
AiLi 2f9ca313cb
fix(rocketmq): 优化消费组加载/连接并修复积压、FIFO 与 Docker remap
* feat(rocketmq): 优化 RocketMQ 消费者组管理和性能监控

- 添加并行订阅组配置收集功能,支持超时控制和并发限制
- 实现消费者滞后监控功能,支持按主题过滤的滞后统计
- 重构消费滞后结果构建逻辑,添加 Broker 客户端映射功能
- 优化 Admin 客户端构建,确保请求超时参数正确传递
- 为 RabbitMQ 面板组件添加 MQ 写入保护确认机制
- 统一 MQ 面板样式表引入,清理重复按钮样式定义
- 增强连接对话框中的生产环境保护逻辑,支持 MQ 类型范围
- 更新测试用例以验证新的消费者滞后计算和客户端映射功能
- 为 RocketMQ 订阅面板添加过滤计数功能和测试覆盖

* feat(rocketmq): 优化 RocketMQ 客户端连接和消费者组管理

- 移除硬编码的 5 秒收集超时,改用连接配置的请求超时
- 添加从第一个可用主节点收集消费者组配置的方法以提高速度
- 重构内部方法访问客户端 API 实现和超时配置
- 在主题过滤模式下保留一个主节点转储以恢复 FIFO 类型准确性
- 改进消费延迟计算,失败时不设置总延迟而是保持未定义
- 为消费者运行信息获取添加 Docker 地址映射支持
- 更新命名服务器探测逻辑以更好地处理高可用配置
- 增加消息队列保活机制的查询超时支持
- 在前端界面中显示部分积压加载失败的错误信息
- 添加卸载主题前的操作确认保护
- 改进空结果集的用户界面提示信息

* fix(rocketmq): 解决消费组延迟统计失败时显示为健康零延迟的问题

- 在 examineConsumeStatsRemapped 中添加成功计数和最后错误记录
- 添加 ensureConsumeStatsProbeSucceeded 方法确保没有主节点响应时抛出异常
- 修改 TopicConsumeDetail 接口使 delay 字段可选以表示探针失败
- 更新 UI 显示逻辑在延迟不可用时显示破折号而不是零
- 添加测试用例验证所有代理失败时的异常处理
- 在 Rust 代码中检查 totalLag 是否存在以避免将缺失值视为零
- 添加消息队列适配器的标识检查防止过期的保活连接被错误清理

* fix(rocketmq): 修复RocketMQ消费组详情加载和统计查询问题

- 为examineConsumeStats调用添加超时参数以支持Docker地址映射
- 更新ensureConsumeStatsProbeSucceeded方法以处理部分失败情况下的空合并结果
- 在消费者组对话框中添加序列号防止陈旧详情/配置响应覆盖新数据
- 修复消费延迟显示逻辑,将null延迟值显示为"-"
- 改进错误处理,确保仅在所有代理成功时才接受空偏移表作为有效结果

* fix(rocketmq): 修复消费者组配置收集和消息积压显示问题

- 修改 collectConsumerGroupConfigsForTopicFilter 方法以合并多个主节点的订阅配置
- 优化 Topic-filtered 列表的配置收集逻辑,确保 FIFO 组类型准确性
- 添加 totalLagFailed 标志以正确处理消息积压探测失败情况
- 更新前端组件以在探测失败时显示 "-" 而非 0
- 修复 Docker 环境下多代理地址映射冲突问题
- 添加 RFC1918 私有 IP 验证方法并改进主机可达性判断
- 为积压不可用状态添加数据模型支持

* fix(rocketmq): 修复RocketMQ消费者组配置收集中的FIFO组识别问题

- 重构订阅组配置合并逻辑,确保FIFO组不会被误识别为NORMAL组
- 添加mergeSubscriptionGroupConfigs方法处理重复和冲突的订阅组配置
- 修改collectConsumerGroupConfigsForTopicFilter方法使用新的合并策略
- 添加测试用例验证FIFO组优先级和后续组保持功能
- 修复中间代理返回重复转储时丢失FIFO-only组的问题

* refactor(rocketmq): 优化消费组统计和名称服务器连接逻辑

- 引入 MasterBrokerAddrPlan 类来管理重映射和冲突回退地址
- 将 examineConsumeStatsRemapped 重构为 examineConsumeStatsOnMasters 方法
- 实现对 Docker 环境中重映射地址的更好支持和故障转移处理
- 在连接前预先探测 NameServer 可达性以避免 JVM 启动超时
- 优化消费者组在线成员数显示,未知状态时显示为 '-'
- 添加相关单元测试验证地址解析和冲突处理逻辑

* fix(rocketmq): 修复Docker重映射碰撞和消费者组配置问题

- 修复订阅组配置查找逻辑,支持FIFO合并避免顺序消息被隐藏
- 添加超时参数到订阅组查询方法
- 实现Docker重映射碰撞检测和失败关闭机制
- 添加测试验证碰撞空合并失败关闭逻辑
- 修复RocketMQ跟踪详情对话框加载序列控制问题
- 更新消费延迟标签为积压(backlog)
- 添加连接缺失和只读写拒绝的国际化消息提示
- 修复CSS样式中按钮主题颜色应用问题
- 优化NameServer探测预算计算逻辑

* fix(rocketmq): 修复消费者组类型分类和成员计数显示问题

- 当订阅配置缺失时返回 UNKNOWN 类型而不是默认 NORMAL
- 在删除和更新消费者组时对所有主节点执行操作确保一致性
- 成员计数查询失败时保持字段为空避免显示错误的离线状态
- 添加 UNKNOWN 类型的国际化支持和UI样式适配
- 修改测试用例验证缺失配置时的类型分类逻辑

* style(mq): 移除MQ面板中的主按钮样式覆盖

- 删除了 .mq-btn-primary 和 .btn-primary 的填充颜色样式定义
- 保留了悬停效果样式以维持用户体验一致性
- 更新注释说明MQ Chrome在浅色主题中保持页面表面样式
- 避免与工具栏和表格操作按钮产生视觉冲突

* test(composables): 为 useMqMutationGuard 添加测试并更新实现

- 添加了测试用例验证MQ面板使用的getter连接ID解析
- 将 unref 替换为 toValue 以正确处理getter参数
- 更新类型导入使用 MaybeRefOrGetter 替代 MaybeRef
- 添加注释说明使用 toValue 的原因

* fix(rocketmq): 修复消费者连接检查和订阅组变更的容错机制

- 重构 examineConsumerConnectionInfoRemapped 方法以正确处理无主节点响应的情况
- 添加 requireConsumerConnectionProbeResult 方法确保连接探针结果的准确性
- 修改 mutateSubscriptionGroupOnMasters 方法实现全地址覆盖要求防止部分更新
- 新增 ensureSubscriptionGroupMutationSucceeded 和 shouldFailClosedOnCollisionPartialMutation
  方法增强订阅组变更的错误处理
- 更新 rocketmq_subscription_from_group 函数默认类型为 UNKNOWN 而非 NORMAL
- 添加多个单元测试验证连接探针和订阅组变更的各种异常场景

* test(rocketmq): 为消费者连接探测测试方法添加异常声明

- 在requireConsumerConnectionProbeResultFailsClosedWhenNoMasterAnswers测试方法中添加throws Exception声明
- 确保测试方法能够正确处理可能抛出的异常情况

* fix(i18n): MQ 生产确认 source 本地化并清理 fieldStatus 死键

---------

Co-authored-by: AiLi <aili@aili.com>
Co-authored-by: t8y2 <t8y2@users.noreply.github.com>
Co-authored-by: skyler <1156263951@qq.com>
2026-08-06 01:12:31 +08:00
二丫讲梵 5c281b1653
feat(editor): 支持 SELECT * 展开为字段列表
*  feat(editor): 支持将 SELECT * 展开为字段列表

- 新增右键菜单及 `Mod+Shift+X` 快捷键,可展开 `*` 和 `别名.*`
- 基于语义分析识别单表来源、别名及原始引用符,并按数据库方言处理字段名转义
- 优先读取本地或远程表字段元数据,单表纯星号查询可回退使用执行结果字段
- 增加字段加载失败提示及多语言文案
- 补充星号展开、结果匹配和快捷键注册测试

* fix(editor): selectStar 回退加词边界检查并记录加载失败

---------

Co-authored-by: t8y2 <t8y2@users.noreply.github.com>
Co-authored-by: skyler <1156263951@qq.com>
2026-08-06 00:55:36 +08:00
二丫讲梵 fa6540b739
fix(sidebar): 修复搜索筛选树分组折叠在 scope-only 过滤下空操作
* 🐛 fix(sidebar): 修复搜索筛选树的分组折叠与重复点击切换

- 在保留搜索子树时应用临时折叠状态,避免空分组重新展开
- 仅在搜索筛选期间记录节点折叠操作
- 允许分组标签双击后的连续点击正常切换展开状态
- 补充侧边栏搜索及节点点击行为测试

* fix(sidebar): collapseAllTreeNodes 按 scope-only 过滤填充 searchCollapsedIds

---------

Co-authored-by: t8y2 <t8y2@users.noreply.github.com>
Co-authored-by: skyler <1156263951@qq.com>
2026-08-06 00:55:24 +08:00
t8y2 b58c221e16
test(sidebar): 同步 openObjectSourceDialog 正则到 editableSource 2026-08-06 00:39:05 +08:00
zipg 445b6473f4
fix(saved-sql): 修复 SQL 库连接关联丢失与历史文件不可见
* fix: 持久化并显示 SQL 库连接关联

* fix: 补充 SQL 库关联状态翻译

* chore: 修复数据标签页策略格式

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-05 23:58:32 +08:00
Guoyu Su 580b70e1d4
feat(sidebar): expose visible object filter 2026-08-05 23:58:22 +08:00
zipg 2ede64f1cb
feat(db): 支持 VictoriaMetrics 数据库
* feat(victoriametrics): support native MetricsQL queries

* fix(victoriametrics): 完善指标浏览和结果导出

* fix(victoriametrics): 修复指标对象通用操作入口

* fix(victoriametrics): 修正指标注释和截断提示

* fix(i18n): 补齐 VictoriaMetrics 连接配置翻译

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-05 23:58:11 +08:00
二丫讲梵 930aa48c20
feat(grid): 新增原始数据复制模式并设为默认
*  feat(grid): 新增原始数据复制模式并设为默认

- 原样输出单元格内容,不对引号、制表符和换行进行 DSV 转义
- 支持原始数据复制后的网格粘贴还原
- 补充多语言复制选项文案及前后端提取器实现与测试

*  feat(grid): 优化新增智能复制选项

- 单个单元格复制保留原始单元格文本,多单元格自动使用转义 TSV
- 默认复制设置由格式选择升级为复制行为选择,并支持预览
- 限制原始文本格式仅用于单单元格复制
- 更新多语言文案及相关测试
2026-08-05 23:58:01 +08:00
Yy-702 e749bdd42c
feat(oracle): Ctrl/Cmd+click 打开存储过程源码
* feat(oracle): open procedure source via Ctrl/Cmd+click in SQL editor

Allow navigating from SQL identifiers to routine source like DataGrip/DBeaver.
Call sites open immediately without a global ALL_OBJECTS scan, and query-tab
views wrap bare Oracle ALL_SOURCE as CREATE OR REPLACE for editable DDL.

Close #5290

* fix(oracle): harden Ctrl/Cmd+click navigation identity model

Address review feedback on routine navigation correctness:
- Distinguish relation column lists (INSERT INTO t(...)) from routine calls
- Resolve schema.routine vs package.member before optimistic open
- Preserve quoted mixed-case identities for Oracle ALL_SOURCE lookup
- Try exact then uppercase name candidates in the Oracle Go agent
2026-08-05 23:57:50 +08:00
zipg 44e0eff76e
fix(transfer): 修复 OceanBase 向 MySQL 传输建表失败
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-05 23:57:40 +08:00
zipg 51dedaa5b7
fix(structure): 允许清除已删除字段的索引引用
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-05 23:57:30 +08:00
zipg e1822cb704
feat(ui): 补充国产数据库中文品牌名称
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-05 23:15:53 +08:00
github-actions[bot] a6d335fff9 chore(packages): release 0.4.55 [skip node-packages-release] 2026-08-05 11:31:01 +00:00
github-actions[bot] 1b11098ddb chore: bump module versions [skip ci] 2026-08-05 11:26:29 +00:00
t8y2 f0c0065b17
fix(ci): set up sccache before win7 cargo fetch 2026-08-05 17:29:28 +08:00
t8y2 ecec6381ed
fix(docker): copy all vendored patched crates in image build 2026-08-05 17:29:27 +08:00
miracle c8c40ba839
fix(errors): unify backend error protocol and preserve db detail 2026-08-05 17:25:21 +08:00
t8y2 f1617bbd87
chore(release): prepare v0.5.76 2026-08-05 16:35:50 +08:00
t8y2 1ebff8e7a5
fix(tabs): apply oxfmt formatting to dataTabOpenPolicy 2026-08-05 16:20:39 +08:00
zipg ad81f02db4
fix(sqlserver): show PRINT messages 2026-08-05 15:53:16 +08:00
zipg 5d89e01247
fix(kingbase): show connectable custom template databases 2026-08-05 15:52:54 +08:00
Abeautifulsnow 62624786dc
feat(mcp): add update notification badge 2026-08-05 15:52:20 +08:00
Anubis 7243f5eb06
fix(postgresql): keep enum queries on binary protocol 2026-08-05 15:29:33 +08:00
zipg c972dab3d0
fix(quick-open): search unloaded tables after cold start 2026-08-05 14:58:03 +08:00
miracle 605d20dd4f
perf(export): optimize SQL export performance 2026-08-05 14:57:06 +08:00
zipg 7fd4aef060
fix(grid): optimize data filter control width 2026-08-05 14:52:18 +08:00
onenewcode 12360e9793
fix(mysql): allow SHOW TRIGGERS in agent 2026-08-05 14:51:13 +08:00
t8y2 7a499ce9ff
fix(macos): remove vendored wry deprecation warnings 2026-08-05 14:38:41 +08:00
t8y2 bff03ed78a
fix(ui): improve panel resize and sidebar borders 2026-08-05 14:23:03 +08:00
t8y2 f67f02e107
feat(grid): refine boolean editing and copy actions 2026-08-05 14:22:52 +08:00
t8y2 d65d712089
feat(tabs): add configurable data tab reuse modes 2026-08-05 13:05:26 +08:00
t8y2 1a818563b7
fix(ci): wait for TDengine dnode readiness 2026-08-05 12:58:42 +08:00
amwps290 c4683b026a
fix(kingbase): filter unsupported DSN parameters safely 2026-08-05 11:37:57 +08:00
hb 5bd9f41abe
fix(informix): hide empty owners without losing routines 2026-08-05 11:36:33 +08:00
Andrew 336fbe094b
feat(gaussdb): support multi-host connections 2026-08-05 11:17:01 +08:00
Abeautifulsnow c0a6d6f2f9
feat(agent): add timezone-aware current time tool 2026-08-05 11:15:35 +08:00
zipg 4cd88384ba
fix(sidebar): keep multi-object DDL in one connection context 2026-08-05 11:11:29 +08:00
guoyongchang 200d6f55ab
fix(editor): preserve literals during SQL case conversion 2026-08-05 11:10:15 +08:00
Sean 8825a98ca2
fix(grid): allow editing row count in multi-row insert dialog 2026-08-05 09:49:13 +08:00
Elias 76501d6ae3
fix(xugu): return EXPLAIN statements as query results 2026-08-05 09:48:03 +08:00
zipg 509a1530f6
fix(grid): preserve row selection during infinite-scroll append 2026-08-05 09:47:26 +08:00
二丫讲梵 ffaede31b9
feat(redis): support score sorting and inline sorted-set editing 2026-08-05 09:46:18 +08:00
zipg 2c91cdaba3
fix(sqlserver): report affected rows across SQL batches 2026-08-05 09:45:38 +08:00
zipg 9440835c94
fix(compare): preserve unchecked comparison options 2026-08-05 09:44:58 +08:00
zipg 0dfc6b5cd8
fix(editor): preserve focus after executing SQL 2026-08-05 09:44:00 +08:00
zipg 39fcaa15cb
fix(grid): sort negative values correctly on current page 2026-08-05 09:40:01 +08:00
Abeautifulsnow c3547fcb61
docs(redis): document decompressed value views 2026-08-05 02:59:51 +08:00
TangT 16369acbda
fix(grid): sharpen canvas text at fractional scaling 2026-08-05 02:59:34 +08:00
guoyongchang eed0a26b6f
fix(tabs): restore close-right tab action 2026-08-05 02:59:19 +08:00
guoyongchang fb0aafa04b
fix(sqlserver): support Unicode table references in completion 2026-08-05 02:51:45 +08:00
github-actions[bot] e61e3935c8 chore(docs): refresh contributor activity 2026-08-04 18:33:40 +00:00
t8y2 680d24b686
fix(dameng): preserve restricted user schemas
Closes #5322
2026-08-05 02:10:46 +08:00
Freedom f700627675
feat(completion): support pinyin matching for Chinese identifiers 2026-08-05 02:03:09 +08:00
t8y2 eafa98b771
fix(sql): remember external file data sources
Closes #5234
2026-08-05 01:20:43 +08:00
zipg be2590347f
fix(grid): keep row copy actions consistent after refresh 2026-08-05 01:10:57 +08:00
rihkddd 27d72f8776
feat(grid): improve save error feedback and dirty cell highlighting 2026-08-05 01:09:59 +08:00
onenewcode a2e1128670
fix(mongodb): move sidebar index actions to indexes group 2026-08-05 01:02:21 +08:00
zipg e82c1aedf0
fix(windows): restore Windows 7 startup compatibility 2026-08-05 00:07:41 +08:00
t8y2 4aa36cb893
fix(sql): preserve leading tenant routing hints
Closes #5332
2026-08-04 23:17:35 +08:00
t8y2 bc60339806
fix(postgres): gate index INCLUDE by server version
Closes #5357
2026-08-04 22:58:51 +08:00
t8y2 f9e49bed1c
feat(neo4j): replace JDBC agent with native Go driver 2026-08-04 20:31:58 +08:00
t8y2 8d03d284d9
fix(mqtt): gate Tauri commands behind feature 2026-08-04 19:56:19 +08:00
jinmo 8cfe380784
feat(mqtt): add MQTT broker connection support 2026-08-04 19:05:05 +08:00
t8y2 dff33662fc
fix(mysql): bind procedure output parameters
Closes #5330
2026-08-04 18:39:49 +08:00
t8y2 018c94cba1
fix(vastbase): restore legacy index metadata compatibility
Closes #5337
2026-08-04 18:00:21 +08:00
t8y2 d38fe5b173
feat(tdengine): replace Java agent with native Rust driver 2026-08-04 17:42:05 +08:00
t8y2 ec8f308c53
fix(mysql): restore trigger source lookup
Closes #5331
2026-08-04 17:30:39 +08:00
t8y2 11b120a55c
fix(duckdb): preserve static CRT in Windows CI 2026-08-04 16:03:45 +08:00
t8y2 cd5c222f95
fix(duckdb): statically link Windows worker runtime
Closes #5313
2026-08-04 15:08:04 +08:00
Rson9 947cc16d92
feat(ssh): support JumpServer keyboard-interactive authentication 2026-08-04 14:23:44 +08:00
Freedom 8228c2af17
fix(grid): restore keyboard focus after tab switches 2026-08-04 13:14:34 +08:00
onenewcode ae87353a28
feat(kafka): redesign message browsing 2026-08-04 13:13:49 +08:00
t8y2 2e57ac8bc0
fix(mongodb): support legacy Agent findOne queries
Closes #5300
2026-08-04 13:09:33 +08:00
dienaso 343ef6fa84
fix(grid): normalize numeric values with thousand separators 2026-08-04 13:02:30 +08:00
jean 44784d664c
feat(desktop): add DDL shortcut and grid row delete action 2026-08-04 13:01:50 +08:00
t8y2 70db5be257
feat(docs): add sponsor contact form 2026-08-04 12:28:20 +08:00
t8y2 f8c71e61dc
fix(sqlserver): paginate joins with duplicate result columns
Closes #5284
2026-08-04 12:14:49 +08:00
github-actions[bot] b6e1d6d9c4 chore: bump module versions [skip ci] 2026-08-04 02:08:30 +00:00
zipg 7e6923d53b
fix(gbase8s): stabilize connections and table access
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-08-04 09:55:36 +08:00
github-actions[bot] 7d5a9459e4 chore(packages): release 0.4.54 [skip node-packages-release] 2026-08-03 20:25:08 +00:00
github-actions[bot] 6f906e7cbd chore: bump module versions [skip ci] 2026-08-03 20:12:17 +00:00
github-actions[bot] 497c88aa1e chore(jdbc): bump plugin version [skip ci] 2026-08-03 20:06:59 +00:00
t8y2 33de1834c4
chore: bump version to 0.5.75 2026-08-04 04:06:29 +08:00
t8y2 66cec0f5d3
fix(ci): align Cassandra coverage matrix key 2026-08-04 03:36:47 +08:00
t8y2 a7c4ab6714
fix(agent): wait for pool close before reconnect
Closes #5251
2026-08-04 03:17:20 +08:00
t8y2 6f0b4666f8
feat(cassandra): support HOCON Kerberos and Astra natively 2026-08-04 02:59:41 +08:00
github-actions[bot] 6193055453 chore(docs): refresh contributor activity 2026-08-03 18:36:45 +00:00
AiLi ce7b8c7b74
feat(rocketmq): improve connections, topics, and message queries 2026-08-04 01:38:29 +08:00
t8y2 0ece86c478
fix(oracle): defer legacy LOB connection switch
Closes #5242
2026-08-04 01:35:59 +08:00
t8y2 44ef416d2f
feat(cassandra): complete native agent migration 2026-08-04 01:21:17 +08:00
t8y2 8c584590ef
fix(export): order MySQL views by dependency
Closes #5254
2026-08-04 00:55:07 +08:00
Holmesin 92991bcc7c
fix(linux): disable DMABuf without usable render devices 2026-08-04 00:46:27 +08:00
二丫讲梵 77be504394
fix(editor): distinguish macOS Control and Command shortcuts 2026-08-04 00:46:12 +08:00
t8y2 5c2dfde334
feat(cassandra): add native Go agent 2026-08-04 00:40:20 +08:00
zipg a6f1a9a871
fix(editor): preserve current SQL execution boundaries 2026-08-04 00:37:42 +08:00
zipg c9764be9a0
fix(mongodb): support collation for find queries 2026-08-04 00:37:13 +08:00
t8y2 1a42246909
fix(import): disambiguate duplicate source headers
Closes #5258
2026-08-04 00:10:22 +08:00
t8y2 8481061a03
fix(oracle): retry XMLTYPE queries after decode errors
Closes #5216
2026-08-03 23:15:47 +08:00
t8y2 dae150ea74
feat(vastbase): replace JDBC agent with native Go driver 2026-08-03 23:09:11 +08:00
t8y2 05841277d1
fix(export): omit BOM from JSON files
Closes #5231
2026-08-03 22:34:03 +08:00
t8y2 feaf914dcf
fix(turso): decode Hrana float and blob values
Closes #5245
2026-08-03 22:05:12 +08:00
Andrew 1dd005db0f
feat(jdbc): add GaussDB types and migrate plugin to Gradle 2026-08-03 21:29:24 +08:00
Abeautifulsnow ec44e09ec8
feat(redis): add decompressed view for compressed values 2026-08-03 20:59:39 +08:00
Elias 06838c422b
fix(xugu): recover metadata access for ordinary users 2026-08-03 20:25:00 +08:00
zipg 35535be419
fix(agents): honor download source for update checks 2026-08-03 19:18:52 +08:00
t8y2 e4d22904ed
fix(cassandra): infer local datacenter when unspecified
Closes #976
2026-08-03 18:04:00 +08:00
t8y2 892a40274e
fix(objects): refresh active table metadata
Closes #5224
2026-08-03 17:08:32 +08:00
zipg 51560c49ae
fix(tabs): reuse data tabs only for the same table 2026-08-03 17:05:56 +08:00
zipg 6e4363be36
fix(sidebar): refresh table sizes after table creation 2026-08-03 17:01:30 +08:00
二丫讲梵 5104d48346
feat(docs): generate driver downloads from agent registries 2026-08-03 17:01:15 +08:00
hhzorg 7fbee89144
fix(ssh): recover first-connect host-key prompts on web 2026-08-03 16:59:34 +08:00
Jevin db791b5aed
feat(zookeeper): support cluster authentication setup 2026-08-03 16:58:47 +08:00
zhangyc 50cd9a8f95
fix(kingbase): cache metadata field capabilities 2026-08-03 16:58:20 +08:00
zipg 658e3d16ad
fix(frontend): guard startup and loading shortcuts 2026-08-03 16:57:26 +08:00
t8y2 246dfad018
fix(grid): reset infinite scroll state on database sort
Closes #5172
2026-08-03 16:37:42 +08:00
CGerAJ 212cf1da7e
feat(diagram): enhance ER diagram editing and exports 2026-08-03 16:05:57 +08:00
t8y2 9fda1d690a
fix(prestosql): avoid duplicate JDBC URL parameters
Closes #5207
2026-08-03 15:52:01 +08:00
fuuulstack ed26fc10f5
feat(editor): add semantic selection and match-all cursors 2026-08-03 15:50:43 +08:00
zipg 02bad5d754
fix(sqlserver): preserve batch results and DML counts 2026-08-03 15:23:12 +08:00
t8y2 147e92ff39
fix(oracle): preserve quoted object names in DDL
Closes #5186
2026-08-03 15:18:34 +08:00
Diego Fabricio a49e795680
feat(grid): add boolean cell checkbox editing 2026-08-03 15:08:09 +08:00
zipg a537565875
fix(dameng): handle DBMS_OUTPUT messages safely 2026-08-03 15:07:18 +08:00
t8y2 5325c42637
fix(structure): load indexes when tab becomes visible 2026-08-03 14:49:35 +08:00
lewis 3e3e6d04b8
fix(redis): isolate sparse scan continuation state 2026-08-03 14:34:07 +08:00
t8y2 043ecfeafa
fix(editor): optimize large PL/SQL source rendering
Closes #5171
2026-08-03 14:25:45 +08:00
t8y2 16920188ee
fix(errors): hide internal agent metadata 2026-08-03 13:56:18 +08:00
t8y2 fa17c01c3b
fix(agents): ignore native placeholder artifacts 2026-08-03 13:18:47 +08:00
github-actions[bot] 3dac9d715a chore: bump module versions [skip ci] 2026-08-03 05:12:23 +00:00
t8y2 12323e406c
fix(agents): reuse effective release artifacts 2026-08-03 13:10:18 +08:00
t8y2 4858fed870
fix(kingbase): handle missing type metadata columns
Closes #5173
2026-08-03 12:30:19 +08:00
t8y2 876e1b38b9
fix(sqlserver): preserve multi-result statement mapping 2026-08-03 11:44:55 +08:00
t8y2 003c3c682d
fix(clickhouse): support result limits for readonly users
Closes #5178
2026-08-03 11:28:25 +08:00
github-actions[bot] 8af3459577 chore(packages): release 0.4.53 [skip node-packages-release] 2026-08-03 03:21:29 +00:00
t8y2 43b24c43dc
fix(mcp): handle discovery probes before initialization 2026-08-03 11:03:56 +08:00
github-actions[bot] 8673bb4717 chore(packages): release 0.4.52 [skip node-packages-release] 2026-08-02 20:10:24 +00:00
github-actions[bot] 3fb938c733 chore: bump module versions [skip ci] 2026-08-02 20:07:49 +00:00
t8y2 acdf78967f
chore(release): prepare v0.5.74 2026-08-03 03:53:03 +08:00
Fernando Possebon dfd38a9559
fix(core): use is_none_or to satisfy clippy on main (#5162)
`cargo clippy --all-targets -- -D warnings` fails on main:

    error: this `map_or` can be simplified
      --> crates/dbx-core/src/table_structure_sql/triggers.rs:58:32
       = note: `-D clippy::unnecessary-map-or` implied by `-D warnings`

`Option::map_or(true, f)` is `Option::is_none_or(f)` — same result for
both None and Some, so this is a lint fix with no behavior change.


Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 03:42:40 +08:00
Fernando Possebon ed1e36912c
feat(explain): add plan canvas with actual execution plans 2026-08-03 03:06:36 +08:00
t8y2 cc7b2b17c5
fix(postgresql): allow table edits with unchanged triggers
Closes #4218
2026-08-02 18:34:03 +00:00
github-actions[bot] 1f0e20e150 chore(docs): refresh contributor activity 2026-08-02 18:21:44 +00:00
t8y2 8559aec8bc
fix(docker): include Java runtime for JDBC
Closes #1151
2026-08-02 23:45:26 +08:00
Abeautifulsnow 5c3b045979
feat(grid): add positioned batch row insertion 2026-08-02 22:46:12 +08:00
onenewcode 8e7b74d0ee
feat(mongodb): improve sidebar index management 2026-08-02 22:45:33 +08:00
t8y2 22055e081f
fix(editor): complete Dameng columns without selected schema 2026-08-02 20:16:26 +08:00
t8y2 2aeafa136c
fix(editor): refresh diagnostics after structure saves 2026-08-02 19:46:44 +08:00
CSXFanMeng b75e7094c2
feat(backup): improve progress history and PostgreSQL exports 2026-08-02 19:35:44 +08:00
t8y2 a299d7a294
fix(mcp): load JDBC plugins from desktop store 2026-08-02 19:22:57 +08:00
zipg 918c2fdf7a
fix(mongodb): honor selected columns in update copy 2026-08-02 19:09:22 +08:00
t8y2 2ac8799cb0
fix(dameng): fall back for restricted table DDL 2026-08-02 18:45:03 +08:00
Abeautifulsnow b2811788a7
docs(ai): clarify saved manual model behavior 2026-08-02 18:25:14 +08:00
t8y2 20334dc928
fix(sidebar): wrap long names in delete dialog 2026-08-02 18:13:31 +08:00
t8y2 8efe83aaa1
fix(mcp): include portable data directory in configs 2026-08-02 16:50:55 +08:00
miracle bcafc4a474
fix(query): preserve structured backend errors 2026-08-02 16:37:46 +08:00
lizujie 2e9dcdc26b
fix(mcp): preserve DML results and returning rows 2026-08-02 16:35:04 +08:00
GIWTO 473ef910b6
feat(transfer): add tree-based object selection 2026-08-02 16:34:50 +08:00
zipg fb7b2878fd
fix(diagram): display relationship cardinalities 2026-08-02 16:34:35 +08:00
二丫讲梵 86c53479a1
feat(settings): add global settings search 2026-08-02 16:34:14 +08:00
zipg 3af3dda9b5
fix(webview): recover Windows WebView2 startup 2026-08-02 16:34:00 +08:00
Abeautifulsnow b053adf175
fix(ai): preserve saved and manual models 2026-08-02 16:33:46 +08:00
t8y2 2220eed285
fix(sql): preserve Oracle database link references 2026-08-02 16:03:41 +08:00
t8y2 7206900313
fix(agent): recover native executable permissions 2026-08-02 15:42:16 +08:00
t8y2 5611449eaf
fix(kingbase): resolve user-defined column types 2026-08-02 15:05:30 +08:00
t8y2 916ac4399f
fix(postgres): support Hologres schema selection 2026-08-02 14:44:57 +08:00
t8y2 e6c9a3932e
fix(mongodb): continue queries when count estimates fail 2026-08-02 13:30:26 +08:00
t8y2 98b1cb8219
fix(oceanbase): restore Oracle mode table imports 2026-08-02 12:51:57 +08:00
1387 changed files with 192291 additions and 14510 deletions

21
.gitattributes vendored
View File

@ -1,6 +1,15 @@
# Keep formatter-sensitive source files consistent across platforms.
# Keep formatter-sensitive source files consistent across platforms. The three
# apps/desktop rules below the .vue line exist for
# docs_export_bundle_is_current, which hashes raw bytes: every extension it
# manifests (.css, tsconfig.json, and the one .ts outside src/ —
# vite.docs-export.config.ts) needs an explicit eol=lf, or a CRLF checkout
# fails the guard for files the contributor never touched.
apps/desktop/src/**/*.ts text eol=lf
apps/desktop/src/**/*.vue text eol=lf
apps/desktop/**/*.css text eol=lf
apps/desktop/tsconfig.json text eol=lf
apps/desktop/*.ts text eol=lf
src-tauri/windows/nsis/**/*.nsi text eol=lf
# Keep tests available for review and CI without counting them as shipped code
# in GitHub's repository language breakdown.
@ -11,3 +20,13 @@ apps/desktop/src/**/*.test.ts linguist-vendored
packages/**/tests/** linguist-vendored
packages/**/*.spec.ts linguist-vendored
packages/**/*.test.ts linguist-vendored
# Build output, committed because the Rust crate embeds it. Minified and large,
# so it would drown every diff and review it appears in — `-diff` makes git
# report "Binary files differ" instead. The manifest stays diffable on purpose:
# it is how a reviewer sees which sources moved. `-text` disables any
# eol conversion on checkout: docs_export_bundle_is_current hashes these two
# files' raw bytes, and an autocrlf checkout would otherwise change those
# bytes and fail the guard for a contributor who touched neither file.
crates/dbx-core/assets/docs-export.js -text -diff linguist-generated
crates/dbx-core/assets/docs-export.css -text -diff linguist-generated

View File

@ -0,0 +1,56 @@
[CmdletBinding()]
param(
[string]$RuntimeDirectory = (Join-Path $PSScriptRoot "..\..\src-tauri\webview2-fixed-runtime"),
[string]$LoaderPath = (Join-Path ([System.IO.Path]::GetTempPath()) "dbx-win7-webview2-loader-probe\WebView2Loader.dll"),
[string]$ExpectedVersion = "109.0.1518.78"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$runtimeDirectory = (Resolve-Path -LiteralPath $RuntimeDirectory).Path
$runtimeExecutable = Join-Path $runtimeDirectory "msedgewebview2.exe"
if (!(Test-Path -LiteralPath $runtimeExecutable -PathType Leaf)) {
throw "WebView2 fixed runtime executable does not exist: $runtimeExecutable"
}
$loaderPath = (Resolve-Path -LiteralPath $LoaderPath).Path
$escapedLoaderPath = $loaderPath.Replace('"', '""')
$source = @"
using System;
using System.Runtime.InteropServices;
public static class DbxWebView2LoaderProbe
{
[DllImport(@"$escapedLoaderPath", CharSet = CharSet.Unicode, ExactSpelling = true)]
public static extern int GetAvailableCoreWebView2BrowserVersionString(
string browserExecutableFolder,
out IntPtr versionInfo);
}
"@
Add-Type -TypeDefinition $source -Language CSharp
$versionPointer = [IntPtr]::Zero
$result = [DbxWebView2LoaderProbe]::GetAvailableCoreWebView2BrowserVersionString(
$runtimeDirectory,
[ref]$versionPointer
)
if ($result -ne 0) {
throw "WebView2 loader failed to recognize fixed runtime at $runtimeDirectory (HRESULT 0x$($result.ToString('X8')))."
}
if ($versionPointer -eq [IntPtr]::Zero) {
throw "WebView2 loader returned an empty version pointer for $runtimeDirectory."
}
try {
$version = [Runtime.InteropServices.Marshal]::PtrToStringUni($versionPointer)
}
finally {
[Runtime.InteropServices.Marshal]::FreeCoTaskMem($versionPointer)
}
if ([string]::IsNullOrWhiteSpace($version) -or !$version.StartsWith($ExpectedVersion)) {
throw "Expected WebView2 fixed runtime $ExpectedVersion, detected '$version'."
}
Write-Host "WebView2 fixed runtime probe passed: loader=$loaderPath runtime=$runtimeDirectory version=$version"

View File

@ -0,0 +1,38 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$InstallerPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$installerPath = (Resolve-Path -LiteralPath $InstallerPath).Path
$installDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "dbx-win7-installer-audit"
if (Test-Path -LiteralPath $installDirectory) {
Remove-Item -LiteralPath $installDirectory -Recurse -Force
}
$installer = Start-Process -FilePath $installerPath -ArgumentList @("/S", "/D=$installDirectory") -Wait -PassThru
if ($installer.ExitCode -ne 0) {
throw "Windows 7 test installer failed with exit code $($installer.ExitCode)."
}
$expectedFiles = @(
(Join-Path $installDirectory "dbx.exe"),
(Join-Path $installDirectory "webview2-fixed-runtime\msedgewebview2.exe"),
(Join-Path $installDirectory "uninstall.exe")
)
foreach ($path in $expectedFiles) {
if (!(Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Windows 7 test installer omitted required file: $path"
}
}
Write-Host "Windows 7 installer content audit passed: $installerPath"
$uninstallerPath = Join-Path $installDirectory "uninstall.exe"
$uninstaller = Start-Process -FilePath $uninstallerPath -ArgumentList @("/S", "_?=$installDirectory") -Wait -PassThru
if ($uninstaller.ExitCode -ne 0) {
Write-Warning "Windows 7 test uninstaller returned exit code $($uninstaller.ExitCode)."
}

View File

@ -0,0 +1,71 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$BinaryPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if (!(Test-Path -LiteralPath $BinaryPath -PathType Leaf)) {
throw "Windows 7 PE audit target does not exist: $BinaryPath"
}
$dumpbinCommand = Get-Command dumpbin.exe -ErrorAction SilentlyContinue
$dumpbinPath = if ($null -ne $dumpbinCommand) { $dumpbinCommand.Source } else { $null }
if ($null -eq $dumpbinPath) {
$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path -LiteralPath $vswhere) {
$visualStudio = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
if ($visualStudio) {
$dumpbin = Get-ChildItem (Join-Path $visualStudio "VC\Tools\MSVC") -Filter dumpbin.exe -Recurse |
Where-Object { $_.FullName -match '\\bin\\Hostx64\\x64\\dumpbin\.exe$' } |
Sort-Object FullName -Descending |
Select-Object -First 1
if ($null -ne $dumpbin) {
$dumpbinPath = $dumpbin.FullName
}
}
}
}
if ($null -eq $dumpbinPath) {
throw "Unable to find dumpbin.exe for the Windows 7 PE compatibility audit."
}
$imports = (& $dumpbinPath /nologo /imports $BinaryPath 2>&1 | Out-String)
if ($LASTEXITCODE -ne 0) {
throw "dumpbin failed while auditing ${BinaryPath}:`n$imports"
}
$forbiddenImports = [ordered]@{
"combase.dll" = "COMBASE is only available starting with Windows 8; use OLE32 imports."
"api-ms-win-core-winrt-" = "WinRT API sets are unavailable on Windows 7."
"CoIncrementMTAUsage" = "CoIncrementMTAUsage is unavailable on Windows 7."
"EventSetInformation" = "EventSetInformation is unavailable on Windows 7. Use the legacy WebView2 loader."
"GetSystemTimePreciseAsFileTime" = "GetSystemTimePreciseAsFileTime is unavailable on Windows 7."
"GetDpiForWindow" = "GetDpiForWindow is unavailable on Windows 7."
"GetSystemMetricsForDpi" = "GetSystemMetricsForDpi is unavailable on Windows 7."
"SetThreadDpiAwarenessContext" = "SetThreadDpiAwarenessContext is unavailable on Windows 7."
"VCRUNTIME140.dll" = "The Windows 7 package must not require a separately installed VC++ Runtime."
"VCRUNTIME140_1.dll" = "The Windows 7 package must not require a separately installed VC++ Runtime."
"MSVCP140.dll" = "The Windows 7 package must not require a separately installed VC++ Runtime."
"ucrtbase.dll" = "The Windows 7 package must link the Universal CRT statically."
"api-ms-win-crt-" = "The Windows 7 package must not require separately installed Universal CRT API sets."
}
$violations = @()
foreach ($entry in $forbiddenImports.GetEnumerator()) {
if ($imports -match [regex]::Escape($entry.Key)) {
$violations += "$($entry.Key): $($entry.Value)"
}
}
if ($violations.Count -gt 0) {
$summary = $violations -join "`n"
Write-Host "Full PE import table for diagnosis:"
Write-Host $imports
throw "Windows 7 incompatible PE imports detected in ${BinaryPath}:`n$summary"
}
Write-Host "Windows 7 PE import audit passed: $BinaryPath"

View File

@ -3,6 +3,12 @@ import { execFileSync } from "node:child_process";
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
const VERSIONS_PATH = "agents/versions.json";
const VERSION_SYNC_SUBJECT = "chore: bump module versions [skip ci]";
const JRE_BUILD_PATHS = new Set([".github/workflows/agents-release.yml"]);
const NATIVE_RELEASE_PACKAGING_PATHS = new Set([
".github/scripts/reuse-agent-release-assets.mjs",
"agents/scripts/version_agent_artifacts.py",
]);
function bumpPatchVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(version);
@ -43,11 +49,16 @@ function fileContainsCommonDependency(path, moduleExists, readModuleFile) {
}
const nativeDriverDirectories = {
cassandra: "cassandra-go",
duckdb: "duckdb",
oracle: "oracle-go",
kingbase: "kingbase-go",
neo4j: "neo4j-go",
vastbase: "vastbase-go",
rabbitmq: "rabbitmq",
tdengine: "tdengine",
};
const nativeDriverModules = new Set(["cassandra", "duckdb", "oracle", "xugu", "kingbase", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) {
let checkDir = null;
@ -70,10 +81,18 @@ function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists,
return {
checkDir,
modulePath,
javaBuild: hasBuildGradle,
nativeBuild: nativeDriverModules.has(moduleName),
commonDependent: hasBuildGradle && (explicitlyDependsOnCommon || !legacyStandaloneModules.has(moduleName)),
};
}
function classifyModules(versions, options) {
return Object.keys(versions)
.map((moduleName) => ({ moduleName, module: resolveAgentModule(moduleName, options) }))
.filter(({ module }) => module);
}
export function evaluateAgentVersionBump({
versions,
prevVersions = versions,
@ -87,6 +106,11 @@ export function evaluateAgentVersionBump({
const nextVersions = { ...versions };
const logs = [];
let changed = false;
const changedModules = [];
const javaModules = [];
const nativeModules = [];
const reusedModules = [];
const resolvedModules = classifyModules(versions, { legacyStandaloneModules, moduleExists, readModuleFile });
if (manualVersionsChanged && !skipBump) {
logs.push("Manual agents/versions.json changes detected; preserving manually changed module versions and auto-bumping the rest.");
@ -94,35 +118,50 @@ export function evaluateAgentVersionBump({
if (skipBump) {
logs.push("Skipping automatic module version bump for migrated first release; versions.json was carried over from dbx-agents.");
return { changed, versions: nextVersions, prevVersions, logs };
for (const { moduleName, module } of resolvedModules) {
changedModules.push(moduleName);
if (module.javaBuild) javaModules.push(moduleName);
if (module.nativeBuild) nativeModules.push(moduleName);
}
return { changed, versions: nextVersions, prevVersions, logs, changedModules, javaModules, nativeModules, reusedModules };
}
const commonChanged = changedFiles.some(isCommonRuntimeChange);
if (commonChanged) {
logs.push("Common agent runtime changes detected; common-triggered bumps are limited to modules that package agents/common.");
}
const nativeReleasePackagingChanged = changedFiles.some((file) => NATIVE_RELEASE_PACKAGING_PATHS.has(file));
if (nativeReleasePackagingChanged) {
logs.push("Shared native release packaging changes detected; all native modules will be rebuilt.");
}
for (const moduleName of Object.keys(versions)) {
const module = resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile });
if (!module) continue;
for (const { moduleName, module } of resolvedModules) {
const moduleChanged = pathChanged(changedFiles, module.modulePath);
// Only modules that package agents/common need installer-visible updates
// for shared Java runtime changes; native and standalone agents do not.
const commonAffectsModule = commonChanged && module.commonDependent;
const nativePackagingAffectsModule = nativeReleasePackagingChanged && module.nativeBuild;
const oldVersion = nextVersions[moduleName] ?? "0.1.0";
const prevVersion = prevVersions[moduleName] ?? "";
const manuallyVersioned = manualVersionsChanged && (!prevVersion || prevVersion !== oldVersion);
const moduleNeedsBuild = moduleChanged || commonAffectsModule || nativePackagingAffectsModule || manuallyVersioned;
if (!moduleChanged && !commonAffectsModule) {
if (!moduleNeedsBuild) {
logs.push(` ${moduleName}: no changes`);
reusedModules.push(moduleName);
} else if (manuallyVersioned) {
changedModules.push(moduleName);
if (module.javaBuild) javaModules.push(moduleName);
if (module.nativeBuild) nativeModules.push(moduleName);
if (!prevVersion) {
logs.push(` ${moduleName}: CHANGED, new module version kept at ${oldVersion}`);
} else {
logs.push(` ${moduleName}: CHANGED, manual version ${prevVersion} -> ${oldVersion}`);
}
} else {
changedModules.push(moduleName);
if (module.javaBuild) javaModules.push(moduleName);
if (module.nativeBuild) nativeModules.push(moduleName);
const newVersion = bumpPatchVersion(oldVersion);
nextVersions[moduleName] = newVersion;
changed = true;
@ -131,7 +170,7 @@ export function evaluateAgentVersionBump({
}
}
return { changed, versions: nextVersions, prevVersions, logs };
return { changed, versions: nextVersions, prevVersions, logs, changedModules, javaModules, nativeModules, reusedModules };
}
export function getAgentVersionChanges(previousVersions, nextVersions) {
@ -148,6 +187,62 @@ function git(args) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
function lines(value) {
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
}
export function resolveAgentReleaseBaseline({ prevTag, headRef = "HEAD", gitOutput = git }) {
const allChangedFiles = lines(gitOutput(["diff", "--name-only", `${prevTag}..${headRef}`]));
const versionCommits = lines(
gitOutput([
"log",
"--reverse",
"--ancestry-path",
"--format=%H%x09%s",
`${prevTag}..${headRef}`,
"--",
VERSIONS_PATH,
]),
);
let syncCommit = "";
for (const entry of versionCommits) {
const separator = entry.indexOf("\t");
if (separator < 0 || entry.slice(separator + 1) !== VERSION_SYNC_SUBJECT) continue;
const commit = entry.slice(0, separator);
const changedPaths = lines(gitOutput(["diff-tree", "--no-commit-id", "--name-only", "-r", commit]));
if (changedPaths.length !== 1 || changedPaths[0] !== VERSIONS_PATH) continue;
JSON.parse(gitOutput(["show", `${commit}:${VERSIONS_PATH}`]));
syncCommit = commit;
break;
}
const versionsRef = syncCommit || prevTag;
const versions = JSON.parse(gitOutput(["show", `${versionsRef}:${VERSIONS_PATH}`]));
const versionsChangedAfterSync = syncCommit
? lines(gitOutput(["log", "--format=%H", `${syncCommit}..${headRef}`, "--", VERSIONS_PATH])).length > 0
: false;
const changedFiles = syncCommit && !versionsChangedAfterSync
? allChangedFiles.filter((file) => file !== VERSIONS_PATH)
: allChangedFiles;
return {
prevTag,
versionsRef,
syncCommit,
versions,
changedFiles,
allChangedFiles,
versionsChangedAfterSync,
};
}
export function shouldBuildAgentJre(changedFiles, migratedFirstRelease = false) {
return migratedFirstRelease || changedFiles.some((file) => JRE_BUILD_PATHS.has(file));
}
function parseArgs(argv) {
const options = {
migratedFirstRelease: false,
@ -180,7 +275,7 @@ function parseArgs(argv) {
return options;
}
function outputStepValues(result, prevTag, migratedFirstRelease) {
function outputStepValues(result, baseline, migratedFirstRelease, buildJre) {
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) return;
@ -189,7 +284,14 @@ function outputStepValues(result, prevTag, migratedFirstRelease) {
[
`versions=${JSON.stringify(result.versions)}`,
`prev_versions=${JSON.stringify(result.prevVersions)}`,
`prev_tag=${prevTag}`,
`prev_tag=${baseline.prevTag}`,
`effective_prev_ref=${baseline.versionsRef}`,
`changed_modules=${JSON.stringify(result.changedModules)}`,
`java_modules=${JSON.stringify(result.javaModules)}`,
`native_modules=${JSON.stringify(result.nativeModules)}`,
`reuse_modules=${JSON.stringify(migratedFirstRelease ? [] : result.reusedModules)}`,
`build_jre=${buildJre}`,
`reuse_jre=${!migratedFirstRelease && !buildJre}`,
`migrated_first_release=${migratedFirstRelease}`,
"",
].join("\n"),
@ -200,13 +302,18 @@ function main() {
const options = parseArgs(process.argv.slice(2));
const versions = JSON.parse(readFileSync(VERSIONS_PATH, "utf8"));
const legacyStandaloneModules = parseLegacyStandaloneProjects(readFileSync("agents/build.gradle", "utf8"));
const changedFiles = options.skipBump ? [] : git(["diff", "--name-only", `${options.prevTag}..HEAD`]).split("\n").filter(Boolean);
const baseline = options.prevVersionsFile
? {
prevTag: options.prevTag,
versionsRef: options.prevTag,
syncCommit: "",
versions: JSON.parse(readFileSync(options.prevVersionsFile, "utf8")),
changedFiles: lines(git(["diff", "--name-only", `${options.prevTag}..HEAD`])),
}
: resolveAgentReleaseBaseline({ prevTag: options.prevTag });
const changedFiles = options.skipBump ? [] : baseline.changedFiles;
const manualVersionsChanged = changedFiles.includes(VERSIONS_PATH);
const prevVersions = options.prevVersionsFile
? JSON.parse(readFileSync(options.prevVersionsFile, "utf8"))
: manualVersionsChanged
? JSON.parse(git(["show", `${options.prevTag}:${VERSIONS_PATH}`]))
: versions;
const prevVersions = baseline.versions;
const result = evaluateAgentVersionBump({
versions,
@ -226,7 +333,8 @@ function main() {
writeFileSync(VERSIONS_PATH, versionsJson);
}
console.log(versionsJson);
outputStepValues(result, options.prevTag, options.migratedFirstRelease);
const buildJre = shouldBuildAgentJre(baseline.changedFiles, options.migratedFirstRelease);
outputStepValues(result, baseline, options.migratedFirstRelease, buildJre);
}
if (import.meta.url === `file://${process.argv[1]}`) {

View File

@ -1,7 +1,11 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { evaluateAgentVersionBump } from "./bump-agent-versions.mjs";
import { evaluateAgentVersionBump, resolveAgentReleaseBaseline, shouldBuildAgentJre } from "./bump-agent-versions.mjs";
const moduleExists = (path) => path === "agents/drivers/duckdb";
@ -30,6 +34,20 @@ test("bumps DuckDB after its initial release", () => {
assert.equal(result.versions.duckdb, "0.1.1");
});
test("classifies TDengine Rust changes as native-only", () => {
const result = evaluateAgentVersionBump({
versions: { tdengine: "0.1.39" },
changedFiles: ["agents/drivers/tdengine/src/driver.rs"],
moduleExists: (path) => path === "agents/drivers/tdengine",
readModuleFile: () => "",
});
assert.equal(result.versions.tdengine, "0.1.40");
assert.deepEqual(result.changedModules, ["tdengine"]);
assert.deepEqual(result.javaModules, []);
assert.deepEqual(result.nativeModules, ["tdengine"]);
});
test("bumps the native RabbitMQ agent from its Go directory", () => {
const result = evaluateAgentVersionBump({
versions: { rabbitmq: "0.1.0" },
@ -40,3 +58,194 @@ test("bumps the native RabbitMQ agent from its Go directory", () => {
assert.equal(result.versions.rabbitmq, "0.1.1");
});
test("bumps the native Vastbase agent from its independent Go directory", () => {
const result = evaluateAgentVersionBump({
versions: { vastbase: "0.1.37" },
changedFiles: ["agents/drivers/vastbase-go/main.go"],
moduleExists: (path) => path === "agents/drivers/vastbase-go",
readModuleFile: () => "",
});
assert.equal(result.versions.vastbase, "0.1.38");
assert.deepEqual(result.nativeModules, ["vastbase"]);
});
test("bumps Cassandra from its native Go source directory", () => {
const result = evaluateAgentVersionBump({
versions: { cassandra: "0.1.37" },
changedFiles: ["agents/drivers/cassandra-go/main.go"],
moduleExists: (path) => path === "agents/drivers/cassandra-go",
readModuleFile: () => "",
});
assert.equal(result.versions.cassandra, "0.1.38");
assert.deepEqual(result.nativeModules, ["cassandra"]);
});
test("bumps Neo4j from its native Go source directory", () => {
const result = evaluateAgentVersionBump({
versions: { neo4j: "0.1.39" },
changedFiles: ["agents/drivers/neo4j-go/main.go"],
moduleExists: (path) => path === "agents/drivers/neo4j-go",
readModuleFile: () => "",
});
assert.equal(result.versions.neo4j, "0.1.40");
assert.deepEqual(result.javaModules, []);
assert.deepEqual(result.nativeModules, ["neo4j"]);
});
test("rebuilds native modules when shared native packaging changes", () => {
const existing = new Set([
"agents/drivers/access",
"agents/drivers/access/build.gradle",
"agents/drivers/duckdb",
"agents/drivers/neo4j-go",
]);
const result = evaluateAgentVersionBump({
versions: { access: "0.1.37", duckdb: "0.1.3", neo4j: "0.1.40" },
changedFiles: ["agents/scripts/version_agent_artifacts.py"],
moduleExists: (path) => existing.has(path),
readModuleFile: () => "implementation project(':common')",
});
assert.equal(result.versions.access, "0.1.37");
assert.equal(result.versions.duckdb, "0.1.4");
assert.equal(result.versions.neo4j, "0.1.41");
assert.deepEqual(result.nativeModules, ["duckdb", "neo4j"]);
assert.deepEqual(result.reusedModules, ["access"]);
});
test("builds a manually versioned module even without runtime file changes", () => {
const result = evaluateAgentVersionBump({
versions: { duckdb: "0.1.1" },
prevVersions: { duckdb: "0.1.0" },
changedFiles: ["agents/versions.json"],
moduleExists,
readModuleFile: () => "",
});
assert.deepEqual(result.changedModules, ["duckdb"]);
assert.deepEqual(result.nativeModules, ["duckdb"]);
assert.deepEqual(result.reusedModules, []);
assert.equal(result.versions.duckdb, "0.1.1");
});
test("bumps DuckDB when its Cargo target configuration changes", () => {
const result = evaluateAgentVersionBump({
versions: { duckdb: "0.1.2" },
changedFiles: ["agents/drivers/duckdb/.cargo/config.toml"],
moduleExists: (path) => path === "agents/drivers/duckdb",
readModuleFile: () => "",
});
assert.equal(result.versions.duckdb, "0.1.3");
assert.deepEqual(result.nativeModules, ["duckdb"]);
});
test("builds only common-dependent Java modules for a shared runtime change", () => {
const existing = new Set([
"agents/drivers/access",
"agents/drivers/access/build.gradle",
"agents/drivers/mongodb",
"agents/drivers/mongodb/build.gradle",
]);
const result = evaluateAgentVersionBump({
versions: { access: "0.1.0", mongodb: "0.1.0" },
changedFiles: ["agents/common/src/main/java/com/dbx/Agent.java"],
legacyStandaloneModules: new Set(["mongodb"]),
moduleExists: (path) => existing.has(path),
readModuleFile: () => "",
});
assert.deepEqual(result.changedModules, ["access"]);
assert.deepEqual(result.javaModules, ["access"]);
assert.deepEqual(result.reusedModules, ["mongodb"]);
assert.equal(result.versions.access, "0.1.1");
assert.equal(result.versions.mongodb, "0.1.0");
});
test("rebuilds JREs only for the first migration or release recipe changes", () => {
assert.equal(shouldBuildAgentJre(["agents/drivers/access/src/main/java/Agent.java"]), false);
assert.equal(shouldBuildAgentJre([".github/workflows/agents-release.yml"]), true);
assert.equal(shouldBuildAgentJre([], true), true);
});
test("uses the first post-tag version sync as the effective release baseline", () => {
const repository = createRepository({ kingbase: "0.1.0" });
git(repository, ["tag", "agents-v0.2.72"]);
writeVersions(repository, { kingbase: "0.1.1" });
commitAll(repository, "chore: bump module versions [skip ci]");
const syncCommit = git(repository, ["rev-parse", "HEAD"]);
writeFileSync(join(repository, "agents/drivers/kingbase-go/kingbase_metadata.go"), "package main\n\nconst fixed = true\n");
commitAll(repository, "fix(kingbase): export primary key columns");
const baseline = resolveAgentReleaseBaseline({
prevTag: "agents-v0.2.72",
gitOutput: (args) => git(repository, args),
});
assert.equal(baseline.versionsRef, syncCommit);
assert.deepEqual(baseline.versions, { kingbase: "0.1.1" });
assert.deepEqual(baseline.changedFiles, ["agents/drivers/kingbase-go/kingbase_metadata.go"]);
const result = evaluateAgentVersionBump({
versions: { kingbase: "0.1.1" },
prevVersions: baseline.versions,
changedFiles: baseline.changedFiles,
moduleExists: (path) => path === "agents/drivers/kingbase-go",
readModuleFile: () => "",
});
assert.equal(result.versions.kingbase, "0.1.2");
assert.deepEqual(result.nativeModules, ["kingbase"]);
});
test("keeps versions.json publish-relevant when it changes after the sync commit", () => {
const repository = createRepository({ duckdb: "0.1.0" });
git(repository, ["tag", "agents-v0.2.72"]);
writeVersions(repository, { duckdb: "0.1.1" });
commitAll(repository, "chore: bump module versions [skip ci]");
writeVersions(repository, { duckdb: "0.1.2" });
commitAll(repository, "chore: adjust DuckDB agent version");
const baseline = resolveAgentReleaseBaseline({
prevTag: "agents-v0.2.72",
gitOutput: (args) => git(repository, args),
});
assert.equal(baseline.versionsChangedAfterSync, true);
assert.deepEqual(baseline.versions, { duckdb: "0.1.1" });
assert.deepEqual(baseline.changedFiles, ["agents/versions.json"]);
});
function createRepository(versions) {
const repository = mkdtempSync(join(tmpdir(), "dbx-agent-release-"));
git(repository, ["init", "--initial-branch=main"]);
git(repository, ["config", "user.name", "DBX Test"]);
git(repository, ["config", "user.email", "dbx-test@example.com"]);
mkdirSync(join(repository, "agents/drivers/kingbase-go"), { recursive: true });
mkdirSync(join(repository, "agents/drivers/duckdb"), { recursive: true });
writeVersions(repository, versions);
writeFileSync(join(repository, "agents/drivers/kingbase-go/kingbase_metadata.go"), "package main\n");
writeFileSync(join(repository, "agents/drivers/duckdb/Cargo.toml"), "[package]\nname = \"duckdb-test\"\n");
commitAll(repository, "feat(agents): initial release state");
return repository;
}
function writeVersions(repository, versions) {
mkdirSync(join(repository, "agents"), { recursive: true });
writeFileSync(join(repository, "agents/versions.json"), `${JSON.stringify(versions, null, 2)}\n`);
}
function commitAll(repository, message) {
git(repository, ["add", "."]);
git(repository, ["commit", "-m", message]);
}
function git(repository, args) {
return execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim();
}

View File

@ -2,11 +2,11 @@
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
const POM_PATH = "plugins/jdbc/pom.xml";
const BUILD_GRADLE_PATH = "plugins/jdbc/build.gradle";
const MANIFEST_PATH = "plugins/jdbc/manifest.json";
function firstProjectVersion(pomXml) {
const match = pomXml.match(/<project[\s\S]*?<version>([^<]+)<\/version>/);
function gradleVersion(buildGradle) {
const match = buildGradle.match(/^version\s*=\s*['"]([^'"]+)['"]/m);
return match?.[1]?.trim() ?? "";
}
@ -27,16 +27,16 @@ function isReleaseBumpRelevantJdbcPluginChange(file) {
if (!file.startsWith("plugins/jdbc/")) return false;
if (file.startsWith("plugins/jdbc/dist/") || file.startsWith("plugins/jdbc/target/")) return false;
if (file === "plugins/jdbc/README.md" || file === "plugins/jdbc/package.sh") return false;
if (file === POM_PATH || file === MANIFEST_PATH) return false;
if (file === BUILD_GRADLE_PATH || file === MANIFEST_PATH) return false;
return true;
}
function hasJdbcPluginVersionChange(file) {
return file === POM_PATH || file === MANIFEST_PATH;
return file === MANIFEST_PATH;
}
function updatePomVersion(pomXml, version) {
return pomXml.replace(/(<project[\s\S]*?<version>)([^<]+)(<\/version>)/, `$1${version}$3`);
function updateGradleVersion(buildGradle, version) {
return buildGradle.replace(/^(version\s*=\s*)(['"])[^'"]+\2/m, (_, prefix, quote) => `${prefix}${quote}${version}${quote}`);
}
function updateManifestVersion(manifestJson, version) {
@ -45,20 +45,20 @@ function updateManifestVersion(manifestJson, version) {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
export function evaluateJdbcPluginReleaseBump({ changedFiles, pomXml, manifestJson }) {
const pomVersion = firstProjectVersion(pomXml);
export function evaluateJdbcPluginReleaseBump({ changedFiles, buildGradle, manifestJson }) {
const currentGradleVersion = gradleVersion(buildGradle);
const currentManifestVersion = manifestVersion(manifestJson);
if (pomVersion !== currentManifestVersion) {
throw new Error(`JDBC plugin version mismatch: pom.xml is ${pomVersion} but manifest.json is ${currentManifestVersion}.`);
if (currentGradleVersion !== currentManifestVersion) {
throw new Error(`JDBC plugin version mismatch: build.gradle is ${currentGradleVersion} but manifest.json is ${currentManifestVersion}.`);
}
const shouldBump = changedFiles.some(isReleaseBumpRelevantJdbcPluginChange) && !changedFiles.some(hasJdbcPluginVersionChange);
const newVersion = shouldBump ? bumpPatchVersion(pomVersion) : pomVersion;
const newVersion = shouldBump ? bumpPatchVersion(currentGradleVersion) : currentGradleVersion;
return {
changed: shouldBump,
oldVersion: pomVersion,
oldVersion: currentGradleVersion,
newVersion,
pomXml: shouldBump ? updatePomVersion(pomXml, newVersion) : pomXml,
buildGradle: shouldBump ? updateGradleVersion(buildGradle, newVersion) : buildGradle,
manifestJson: shouldBump ? updateManifestVersion(manifestJson, newVersion) : manifestJson,
};
}
@ -73,12 +73,12 @@ function main() {
const changedFiles = git(["diff", "--name-only", baseRef, headRef]).split("\n").filter(Boolean);
const result = evaluateJdbcPluginReleaseBump({
changedFiles,
pomXml: readFileSync(POM_PATH, "utf8"),
buildGradle: readFileSync(BUILD_GRADLE_PATH, "utf8"),
manifestJson: readFileSync(MANIFEST_PATH, "utf8"),
});
if (write && result.changed) {
writeFileSync(POM_PATH, result.pomXml);
writeFileSync(BUILD_GRADLE_PATH, result.buildGradle);
writeFileSync(MANIFEST_PATH, result.manifestJson);
}

View File

@ -1,11 +1,11 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
const POM_PATH = "plugins/jdbc/pom.xml";
const BUILD_GRADLE_PATH = "plugins/jdbc/build.gradle";
const MANIFEST_PATH = "plugins/jdbc/manifest.json";
function firstProjectVersion(pomXml) {
const match = pomXml.match(/<project[\s\S]*?<version>([^<]+)<\/version>/);
export function jdbcGradleVersion(buildGradle) {
const match = buildGradle.match(/^version\s*=\s*['"]([^'"]+)['"]/m);
return match?.[1]?.trim() ?? "";
}
@ -13,10 +13,10 @@ function manifestVersion(manifestJson) {
return JSON.parse(manifestJson).version ?? "";
}
export function evaluateJdbcPluginVersionChange({ headPomVersion, headManifestVersion }) {
export function evaluateJdbcPluginVersionChange({ headGradleVersion, headManifestVersion }) {
const errors = [];
if (headPomVersion !== headManifestVersion) {
errors.push(`JDBC plugin version mismatch: pom.xml is ${headPomVersion} but manifest.json is ${headManifestVersion}.`);
if (headGradleVersion !== headManifestVersion) {
errors.push(`JDBC plugin version mismatch: build.gradle is ${headGradleVersion} but manifest.json is ${headManifestVersion}.`);
return errors;
}
return errors;
@ -32,10 +32,10 @@ function readFileAt(ref, path) {
function main() {
const [, headRef = "HEAD"] = process.argv.slice(2);
const headPomVersion = firstProjectVersion(readFileAt(headRef, POM_PATH));
const headGradleVersion = jdbcGradleVersion(readFileAt(headRef, BUILD_GRADLE_PATH));
const headManifestVersion = manifestVersion(readFileAt(headRef, MANIFEST_PATH));
const errors = evaluateJdbcPluginVersionChange({
headPomVersion,
headGradleVersion,
headManifestVersion,
});
@ -45,7 +45,7 @@ function main() {
}
process.exit(1);
}
console.log(`JDBC plugin version check passed (${headPomVersion}).`);
console.log(`JDBC plugin version check passed (${headGradleVersion}).`);
}
if (import.meta.url === `file://${process.argv[1]}`) {

View File

@ -84,6 +84,7 @@ const supplementalDrivers = [
{ dbType: "kafka", label: "Apache Kafka" },
{ dbType: "rocketmq", label: "Apache RocketMQ" },
{ dbType: "rabbitmq", label: "RabbitMQ" },
{ dbType: "mqtt", label: "MQTT", aliases: ["emqx", "hivemq", "mosquitto"] },
];
const manifestUrl = new URL("../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url);

View File

@ -201,9 +201,7 @@ function assertSamePlaceholders(key, source, translated, locale) {
const sourcePlaceholders = placeholders(source);
const translatedPlaceholders = placeholders(translated);
if (sourcePlaceholders.join("\0") !== translatedPlaceholders.join("\0")) {
throw new Error(
`${locale}:${key} placeholder mismatch: expected [${sourcePlaceholders.join(", ")}], got [${translatedPlaceholders.join(", ")}]`,
);
throw new Error(`${locale}:${key} placeholder mismatch: expected [${sourcePlaceholders.join(", ")}], got [${translatedPlaceholders.join(", ")}]`);
}
}
@ -362,7 +360,7 @@ function flattenNode(node, path, result) {
const nextPath = [...path, property.key];
if (property.value.type === "object") {
flattenNode(property.value, nextPath, result);
} else {
} else if (property.value.type === "string") {
result.set(nextPath.join("."), property.value.value);
}
}
@ -416,6 +414,12 @@ class Parser {
const start = this.index;
const key = this.parseKey();
this.skipSpace();
if (this.peek() === "," || this.peek() === "}") {
const hasComma = this.peek() === ",";
if (hasComma) this.index += 1;
properties.push({ key, start, end: this.index, hasComma, value: { type: "external" } });
continue;
}
this.expect(":");
this.skipSpace();
const value = this.parseValue();

View File

@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import { evaluateJdbcPluginReleaseBump } from "./bump-jdbc-plugin-version.mjs";
import { evaluateJdbcPluginVersionChange, jdbcGradleVersion } from "./check-jdbc-plugin-version.mjs";
const buildGradle = `plugins {
id 'java'
}
version = '0.1.26'
`;
const manifestJson = `${JSON.stringify({ version: "0.1.26" }, null, 2)}\n`;
test("reads the JDBC plugin version from Gradle", () => {
assert.equal(jdbcGradleVersion(buildGradle), "0.1.26");
});
test("checks Gradle and manifest versions together", () => {
assert.deepEqual(
evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.26", headManifestVersion: "0.1.26" }),
[],
);
assert.match(
evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.27", headManifestVersion: "0.1.26" })[0],
/build\.gradle is 0\.1\.27/,
);
});
test("bumps Gradle and manifest versions for JDBC source changes", () => {
const result = evaluateJdbcPluginReleaseBump({
changedFiles: ["plugins/jdbc/build.gradle", "plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java"],
buildGradle,
manifestJson,
});
assert.equal(result.changed, true);
assert.equal(result.oldVersion, "0.1.26");
assert.equal(result.newVersion, "0.1.27");
assert.match(result.buildGradle, /version = '0\.1\.27'/);
assert.equal(JSON.parse(result.manifestJson).version, "0.1.27");
});
test("keeps an explicit Gradle version change", () => {
const result = evaluateJdbcPluginReleaseBump({
changedFiles: ["plugins/jdbc/build.gradle", "plugins/jdbc/manifest.json"],
buildGradle,
manifestJson,
});
assert.equal(result.changed, false);
assert.equal(result.newVersion, "0.1.26");
});

View File

@ -57,6 +57,8 @@ test("labels native and compatibility database products with their families", ()
["Dremio", ["db/dremio", "db/jdbc"]],
["Apache Kafka", ["db/kafka", "db/mq"]],
["RabbitMQ", ["db/mq", "db/rabbitmq"]],
["MQTT 5.0", ["db/mqtt"]],
["EMQX 5.8", ["db/mqtt"]],
];
for (const [database, expected] of cases) {

View File

@ -47,17 +47,20 @@ const LABEL_PALETTE = [
];
const DRIVER_DATABASE_ALIASES = {
"cassandra-go": "cassandra",
gbase8a: "gbase",
gbase8s: "gbase",
"h2-legacy": "h2",
kafka: "mq",
"kingbase-go": "kingbase",
"neo4j-go": "neo4j",
"oracle-10g": "oracle",
"oracle-go": "oracle",
"oracle-legacy": "oracle",
rabbitmq: "mq",
rocketmq: "mq",
"sqlserver-legacy": "sqlserver",
"vastbase-go": "vastbase",
};
const DIALECT_DATABASE_ALIASES = {

View File

@ -14,17 +14,20 @@ import {
const knownDatabaseTypes = new Set([
"access",
"cassandra",
"doris",
"jdbc",
"mongodb",
"mq",
"mysql",
"neo4j",
"oceanbase-oracle",
"oracle",
"postgres",
"redis",
"sqlite",
"sqlserver",
"vastbase",
]);
test("labels a desktop MySQL UI fix", () => {
@ -54,11 +57,14 @@ test("maps agent and dialect paths to existing database types", () => {
assert.deepEqual(
inferDatabaseTypes([
"agents/drivers/oracle-go/go.mod",
"agents/drivers/cassandra-go/go.mod",
"agents/drivers/neo4j-go/go.mod",
"agents/drivers/vastbase-go/go.mod",
"agents/drivers/kafka/build.gradle",
"plugins/dialects/postgresql.yaml",
"plugins/dialects/oceanbase.yaml",
], knownDatabaseTypes),
["mq", "oceanbase-oracle", "oracle", "postgres"],
["cassandra", "mq", "neo4j", "oceanbase-oracle", "oracle", "postgres", "vastbase"],
);
});

View File

@ -0,0 +1,106 @@
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Newer WebView2 static loaders import EventSetInformation, which does not exist on Windows 7.
# The loader entry points are stable, so the Win7 bundle uses the last verified compatible SDK loader.
$sdkVersion = "1.0.1054.31"
$sdkPackageSha256 = "0afe683aa3d143a5f6330db1ce833c69278b38fe5e1eadec52f26910ad26e22f"
$loaderSha256 = "76314119685bbf4c2b2423a44e81b57beadc914c943d0e772fd6bc78c8e6b0e8"
$webView2ComSysVersion = "0.38.2"
$upstreamLoaderSha256 = "0659b741bde6348d4c4a6ec4ceb9af50e3d0048ed9cd3c8659bccbb61fde55ee"
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dbx-win7-webview2-loader-$([Guid]::NewGuid())"
$packagePath = Join-Path $temporaryRoot "Microsoft.Web.WebView2.$sdkVersion.nupkg"
$extractedPath = Join-Path $temporaryRoot "extracted"
try {
New-Item -ItemType Directory -Path $extractedPath -Force | Out-Null
$packageUrl = "https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/$sdkVersion"
Write-Host "Downloading WebView2 SDK $sdkVersion for the Windows 7 loader..."
Invoke-WebRequest -Uri $packageUrl -OutFile $packagePath -UseBasicParsing
$actualPackageSha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualPackageSha256 -ne $sdkPackageSha256) {
throw "Unexpected WebView2 SDK package SHA256: $actualPackageSha256"
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($packagePath, $extractedPath)
$legacyLoader = Join-Path $extractedPath "build/native/x64/WebView2LoaderStatic.lib"
if (!(Test-Path -LiteralPath $legacyLoader -PathType Leaf)) {
throw "WebView2 SDK $sdkVersion does not contain the x64 static loader."
}
$legacyLoaderDll = Join-Path $extractedPath "build/native/x64/WebView2Loader.dll"
if (!(Test-Path -LiteralPath $legacyLoaderDll -PathType Leaf)) {
throw "WebView2 SDK $sdkVersion does not contain the x64 loader DLL."
}
$actualLoaderSha256 = (Get-FileHash -LiteralPath $legacyLoader -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualLoaderSha256 -ne $loaderSha256) {
throw "Unexpected Windows 7 WebView2 loader SHA256: $actualLoaderSha256"
}
Push-Location $repositoryRoot
try {
& cargo fetch --locked --target x86_64-win7-windows-msvc
if ($LASTEXITCODE -ne 0) {
throw "cargo fetch failed while preparing the Windows 7 WebView2 loader."
}
$metadataJson = & cargo metadata --locked --format-version 1
if ($LASTEXITCODE -ne 0) {
throw "cargo metadata failed while locating webview2-com-sys."
}
}
finally {
Pop-Location
}
$metadata = $metadataJson | ConvertFrom-Json
$webView2Packages = @($metadata.packages | Where-Object {
$_.name -eq "webview2-com-sys" -and $_.version -eq $webView2ComSysVersion
})
if ($webView2Packages.Count -ne 1) {
throw "Expected exactly one webview2-com-sys $webView2ComSysVersion package, found $($webView2Packages.Count)."
}
$crateRoot = Split-Path -Parent $webView2Packages[0].manifest_path
$loaderDestination = Join-Path $crateRoot "x64/WebView2LoaderStatic.lib"
if (!(Test-Path -LiteralPath $loaderDestination -PathType Leaf)) {
throw "webview2-com-sys static loader does not exist: $loaderDestination"
}
$existingLoaderSha256 = (Get-FileHash -LiteralPath $loaderDestination -Algorithm SHA256).Hash.ToLowerInvariant()
$knownLoaderHashes = @($upstreamLoaderSha256, $loaderSha256)
if ($existingLoaderSha256 -notin $knownLoaderHashes) {
throw "Refusing to replace an unknown webview2-com-sys loader SHA256: $existingLoaderSha256"
}
Set-ItemProperty -LiteralPath $loaderDestination -Name IsReadOnly -Value $false
Copy-Item -LiteralPath $legacyLoader -Destination $loaderDestination -Force
$installedLoaderSha256 = (Get-FileHash -LiteralPath $loaderDestination -Algorithm SHA256).Hash.ToLowerInvariant()
if ($installedLoaderSha256 -ne $loaderSha256) {
throw "Windows 7 WebView2 loader replacement failed: $installedLoaderSha256"
}
$probeDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "dbx-win7-webview2-loader-probe"
New-Item -ItemType Directory -Path $probeDirectory -Force | Out-Null
$probeLoader = Join-Path $probeDirectory "WebView2Loader.dll"
Copy-Item -LiteralPath $legacyLoaderDll -Destination $probeLoader -Force
Write-Host "Prepared WebView2 SDK $sdkVersion static loader for Windows 7: $loaderDestination"
Write-Host "Prepared WebView2 SDK $sdkVersion loader probe DLL: $probeLoader"
}
finally {
if (Test-Path -LiteralPath $temporaryRoot) {
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force
}
}

View File

@ -1,65 +1,76 @@
[CmdletBinding()]
param(
[string]$CacheRoot = (Join-Path $env:LOCALAPPDATA "tauri"),
[string]$RuntimeDirectory = (Join-Path $PSScriptRoot "..\..\src-tauri\webview2-fixed-runtime"),
[string]$DownloadDirectory = $env:RUNNER_TEMP
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$runtimeVersion = "109.0.1518.140"
$runtimeUrl = "https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/updt/2023/09/microsoftedgestandaloneinstallerx64_1c890b4b8dd6b7c93da98ebdc08ecdc5e30e50cb.exe"
$runtimeSha256 = "eac95c8095ec5f9971eade9827d8fb67fd251f5c16e702b5312d31067e39119b"
$evergreenUrl = "https://go.microsoft.com/fwlink/?linkid=2124701"
$runtimeVersion = "109.0.1518.78"
$runtimeFolderName = "Microsoft.WebView2.FixedVersionRuntime.$runtimeVersion.x64"
$archiveName = "$runtimeFolderName.cab"
$runtimeUrl = "https://github.com/westinyang/WebView2RuntimeArchive/releases/download/$runtimeVersion/$archiveName"
$runtimeSha256 = "7622281cf83de1a35e3a471f432f7a897d65f0a7d3975df08512b7b253dd45c7"
if ([string]::IsNullOrWhiteSpace($CacheRoot)) {
throw "A Tauri cache root is required."
if ([string]::IsNullOrWhiteSpace($RuntimeDirectory)) {
throw "A WebView2 fixed runtime directory is required."
}
if ([string]::IsNullOrWhiteSpace($DownloadDirectory)) {
$DownloadDirectory = [System.IO.Path]::GetTempPath()
}
New-Item -ItemType Directory -Force -Path $DownloadDirectory | Out-Null
$downloadPath = Join-Path $DownloadDirectory "MicrosoftEdgeWebView2Runtime-$runtimeVersion-x64.exe"
$archivePath = Join-Path $DownloadDirectory $archiveName
if (Test-Path $downloadPath) {
$downloadHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash.ToLowerInvariant()
if (Test-Path $archivePath) {
$downloadHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($downloadHash -ne $runtimeSha256) {
Remove-Item -LiteralPath $downloadPath -Force
Remove-Item -LiteralPath $archivePath -Force
}
}
if (!(Test-Path $downloadPath)) {
Write-Host "Downloading WebView2 Runtime $runtimeVersion for Windows 7..."
Invoke-WebRequest -Uri $runtimeUrl -OutFile $downloadPath
if (!(Test-Path $archivePath)) {
Write-Host "Downloading WebView2 fixed runtime $runtimeVersion for Windows 7..."
Invoke-WebRequest -Uri $runtimeUrl -OutFile $archivePath
}
$actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash.ToLowerInvariant()
$actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $runtimeSha256) {
throw "WebView2 Runtime SHA-256 mismatch. Expected $runtimeSha256, got $actualHash."
throw "WebView2 fixed runtime SHA-256 mismatch. Expected $runtimeSha256, got $actualHash."
}
# Tauri 2.11 does not expose an offline-installer path override. It resolves the
# Evergreen URL and reuses a matching cache entry, so place the verified 109
# installer at that exact location before bundling.
$response = Invoke-WebRequest -Uri $evergreenUrl -Method Head
$resolvedUrl = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
$match = [regex]::Match(
$resolvedUrl,
"/filestreamingservice/files/(?<guid>[^/]+)/(?<filename>[^/?]+)"
)
if (!$match.Success) {
throw "Unexpected Evergreen WebView2 URL: $resolvedUrl"
# Microsoft no longer publishes old Fixed Version downloads. The archive is
# accepted only when both its pinned hash and original Microsoft signature match.
$signature = Get-AuthenticodeSignature -LiteralPath $archivePath
if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or
$null -eq $signature.SignerCertificate -or
$signature.SignerCertificate.Subject -notmatch "Microsoft Corporation") {
throw "WebView2 fixed runtime does not have a valid Microsoft signature."
}
$cacheDirectory = Join-Path $CacheRoot (Join-Path "x64" $match.Groups["guid"].Value)
$cachePath = Join-Path $cacheDirectory $match.Groups["filename"].Value
New-Item -ItemType Directory -Force -Path $cacheDirectory | Out-Null
Copy-Item -LiteralPath $downloadPath -Destination $cachePath -Force
$extractDirectory = Join-Path $DownloadDirectory "dbx-webview2-fixed-runtime-$runtimeVersion"
if (Test-Path $extractDirectory) {
Remove-Item -LiteralPath $extractDirectory -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $extractDirectory | Out-Null
$cacheHash = (Get-FileHash -LiteralPath $cachePath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($cacheHash -ne $runtimeSha256) {
throw "Cached WebView2 Runtime SHA-256 mismatch. Expected $runtimeSha256, got $cacheHash."
$expand = Join-Path $env:SystemRoot "System32\expand.exe"
& $expand $archivePath "-F:*" $extractDirectory
if ($LASTEXITCODE -ne 0) {
throw "Failed to extract WebView2 fixed runtime archive (exit code $LASTEXITCODE)."
}
Write-Host "Prepared WebView2 Runtime $runtimeVersion at $cachePath"
$extractedRuntime = Join-Path $extractDirectory $runtimeFolderName
$runtimeExecutable = Join-Path $extractedRuntime "msedgewebview2.exe"
if (!(Test-Path $runtimeExecutable)) {
throw "Extracted WebView2 runtime is missing msedgewebview2.exe."
}
if (Test-Path $RuntimeDirectory) {
Remove-Item -LiteralPath $RuntimeDirectory -Recurse -Force
}
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $RuntimeDirectory) | Out-Null
Move-Item -LiteralPath $extractedRuntime -Destination $RuntimeDirectory
Write-Host "Prepared WebView2 fixed runtime $runtimeVersion at $RuntimeDirectory"

View File

@ -0,0 +1,372 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import { basename, join } from "node:path";
import { tmpdir } from "node:os";
const REGISTRY_ASSET = "agent-registry.json";
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
const PLATFORMS = [
"macos-aarch64",
"macos-x64",
"linux-aarch64",
"linux-x64",
"windows-aarch64",
"windows-x64",
];
function artifactFilename(url) {
return basename(url.split(/[?#]/, 1)[0]);
}
function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
function releaseAssetMap(release) {
return new Map((release.assets ?? []).map((asset) => [asset.name, asset]));
}
function requireReleaseAsset(assets, artifact, context) {
const name = artifactFilename(artifact.url);
const releaseAsset = assets.get(name);
if (!releaseAsset) {
throw new Error(`${context} is missing from the previous GitHub release: ${name}`);
}
if (!artifact.sha256) {
throw new Error(`${context} is missing sha256 in the previous agent registry: ${name}`);
}
if (releaseAsset.digest !== `sha256:${artifact.sha256}`) {
throw new Error(`${context} digest mismatch between the registry and GitHub release: ${name}`);
}
return { name, sha256: artifact.sha256, size: artifact.size, releaseAsset };
}
export function collectReusableAssetPlan({ registry, release, versions, modules, reuseJre }) {
const assets = releaseAssetMap(release);
const driverAssets = [];
const jreAssets = [];
for (const moduleName of modules) {
const driver = registry.drivers?.[moduleName];
if (!driver) {
throw new Error(`Previous agent registry is missing reusable module: ${moduleName}`);
}
if (driver.version !== versions[moduleName]) {
throw new Error(`Previous agent version mismatch for ${moduleName}: registry=${driver.version}, expected=${versions[moduleName]}`);
}
const reusableJar = driver.jar && driver.jar.size > 0;
if (reusableJar) {
driverAssets.push({
...requireReleaseAsset(assets, driver.jar, `${moduleName} Java package`),
moduleName,
kind: "jar",
platform: "",
});
}
const nativePlatforms = Object.keys(driver.native ?? {}).sort();
if (NATIVE_MODULES.has(moduleName)) {
const missingPlatforms = PLATFORMS.filter((platform) => !nativePlatforms.includes(platform));
const extraPlatforms = nativePlatforms.filter((platform) => !PLATFORMS.includes(platform));
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
throw new Error(
`Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
);
}
}
for (const platform of nativePlatforms) {
driverAssets.push({
...requireReleaseAsset(assets, driver.native[platform], `${moduleName}/${platform} native package`),
moduleName,
kind: "native",
platform,
});
}
if (!reusableJar && nativePlatforms.length === 0) {
throw new Error(`Previous agent registry has no reusable artifacts for module: ${moduleName}`);
}
}
if (reuseJre) {
for (const [jreKey, jre] of Object.entries(registry.jres ?? {})) {
const platforms = Object.keys(jre.platforms ?? {}).sort();
const missingPlatforms = PLATFORMS.filter((platform) => !platforms.includes(platform));
const extraPlatforms = platforms.filter((platform) => !PLATFORMS.includes(platform));
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
throw new Error(
`Previous JRE ${jreKey} artifacts are incomplete: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
);
}
for (const platform of platforms) {
jreAssets.push({
...requireReleaseAsset(assets, jre.platforms[platform], `JRE ${jreKey}/${platform} package`),
jreKey,
platform,
});
}
}
if (jreAssets.length === 0) {
throw new Error("Previous agent registry has no reusable JRE artifacts.");
}
}
return { driverAssets, jreAssets };
}
function verifyDownloadedAsset(path, asset) {
if (!existsSync(path)) {
throw new Error(`Downloaded release asset is missing: ${asset.name}`);
}
const size = statSync(path).size;
if (asset.size != null && size !== asset.size) {
throw new Error(`Downloaded release asset size mismatch for ${asset.name}: got=${size}, expected=${asset.size}`);
}
const digest = sha256(path);
if (digest !== asset.sha256) {
throw new Error(`Downloaded release asset SHA-256 mismatch for ${asset.name}: got=${digest}, expected=${asset.sha256}`);
}
}
function copyWithoutConflict(source, target) {
if (existsSync(target)) {
if (sha256(source) !== sha256(target)) {
throw new Error(`Reused raw artifact conflicts with an existing file: ${basename(target)}`);
}
return;
}
copyFileSync(source, target);
}
function extractRawDriver(packagePath, asset, outputDir = "") {
const extractDir = mkdtempSync(join(tmpdir(), "dbx-agent-package-"));
try {
execFileSync("tar", ["--use-compress-program=unzstd", "-xf", packagePath, "-C", extractDir], { stdio: "inherit" });
const embeddedRegistry = JSON.parse(readFileSync(join(extractDir, REGISTRY_ASSET), "utf8"));
const driver = embeddedRegistry.drivers?.[asset.moduleName];
if (!driver || driver.version !== asset.releaseVersion) {
throw new Error(`Embedded registry mismatch in ${asset.name}`);
}
const embeddedArtifact = asset.kind === "jar" ? driver.jar : driver.native?.[asset.platform];
if (!embeddedArtifact) {
throw new Error(`Embedded registry artifact is missing in ${asset.name}`);
}
const rawName = artifactFilename(embeddedArtifact.url);
const rawPath = join(extractDir, "drivers", rawName);
if (!existsSync(rawPath)) {
throw new Error(`Embedded raw driver is missing in ${asset.name}: ${rawName}`);
}
const rawSize = statSync(rawPath).size;
if (embeddedArtifact.size != null && rawSize !== embeddedArtifact.size) {
throw new Error(`Embedded raw driver size mismatch in ${asset.name}: ${rawName}`);
}
if (embeddedArtifact.sha256 && sha256(rawPath) !== embeddedArtifact.sha256) {
throw new Error(`Embedded raw driver SHA-256 mismatch in ${asset.name}: ${rawName}`);
}
if (outputDir) {
copyWithoutConflict(rawPath, join(outputDir, rawName));
}
} finally {
rmSync(extractDir, { recursive: true, force: true });
}
}
export function extractReusableDriverPackages({ packagesDir, outputDir, versions, modules }) {
mkdirSync(outputDir, { recursive: true });
const filenames = new Set(readdirSync(packagesDir));
let extracted = 0;
for (const moduleName of modules) {
const releaseVersion = versions[moduleName];
if (!releaseVersion) {
throw new Error(`Missing effective previous version for reusable module: ${moduleName}`);
}
const javaName = `dbx-agent-${moduleName}-${releaseVersion}.tar.zst`;
if (filenames.has(javaName)) {
extractRawDriver(join(packagesDir, javaName), {
name: javaName,
moduleName,
kind: "jar",
platform: "",
releaseVersion,
}, outputDir);
extracted += 1;
}
const nativePlatforms = [];
for (const platform of PLATFORMS) {
const nativeName = `dbx-agent-${moduleName}-${releaseVersion}-${platform}.tar.zst`;
if (!filenames.has(nativeName)) continue;
nativePlatforms.push(platform);
extractRawDriver(join(packagesDir, nativeName), {
name: nativeName,
moduleName,
kind: "native",
platform,
releaseVersion,
}, outputDir);
extracted += 1;
}
if (NATIVE_MODULES.has(moduleName) && nativePlatforms.length !== PLATFORMS.length) {
throw new Error(`Reusable native package set is incomplete for ${moduleName}.`);
}
if (!filenames.has(javaName) && nativePlatforms.length === 0) {
throw new Error(`Reusable package is missing for module: ${moduleName}`);
}
}
return extracted;
}
function gh(args, options = {}) {
const result = execFileSync("gh", args, { encoding: "utf8", ...options });
return typeof result === "string" ? result.trim() : "";
}
function downloadReleaseAssets(assets, downloadDir) {
if (assets.length === 0) return;
const args = [
"--fail",
"--location",
"--silent",
"--show-error",
"--retry",
"5",
"--retry-all-errors",
"--retry-delay",
"2",
"--connect-timeout",
"30",
"--parallel",
"--parallel-immediate",
"--parallel-max",
"6",
];
for (const asset of assets) {
if (!asset.browser_download_url) {
throw new Error(`GitHub release asset is missing browser_download_url: ${asset.name}`);
}
args.push("--output", join(downloadDir, asset.name), asset.browser_download_url);
}
execFileSync("curl", args, { stdio: "inherit" });
}
function parseArgs(argv) {
const options = {
repo: "",
tag: "",
versions: {},
modules: [],
reuseJre: false,
outputDir: "",
extractPackagesDir: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const value = argv[++index];
if (value == null) throw new Error(`Missing value for ${arg}`);
if (arg === "--repo") options.repo = value;
else if (arg === "--tag") options.tag = value;
else if (arg === "--versions") options.versions = JSON.parse(value);
else if (arg === "--modules") options.modules = JSON.parse(value);
else if (arg === "--reuse-jre") options.reuseJre = value === "true";
else if (arg === "--output") options.outputDir = value;
else if (arg === "--extract-packages") options.extractPackagesDir = value;
else throw new Error(`Unexpected argument: ${arg}`);
}
const requiredKeys = options.extractPackagesDir ? ["outputDir"] : ["repo", "tag", "outputDir"];
for (const key of requiredKeys) {
if (!options[key]) throw new Error(`--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)} is required.`);
}
return options;
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.extractPackagesDir) {
const count = extractReusableDriverPackages({
packagesDir: options.extractPackagesDir,
outputDir: options.outputDir,
versions: options.versions,
modules: options.modules,
});
console.log(`Extracted ${count} reusable driver artifacts.`);
return;
}
const workDir = mkdtempSync(join(tmpdir(), "dbx-agent-reuse-"));
const downloadDir = join(workDir, "downloads");
mkdirSync(downloadDir);
mkdirSync(options.outputDir, { recursive: true });
try {
const release = JSON.parse(gh(["api", `repos/${options.repo}/releases/tags/${options.tag}`]));
const assets = releaseAssetMap(release);
const registryReleaseAsset = assets.get(REGISTRY_ASSET);
if (!registryReleaseAsset?.digest?.startsWith("sha256:")) {
throw new Error(`Previous GitHub release ${options.tag} is missing a SHA-256 digest for ${REGISTRY_ASSET}.`);
}
downloadReleaseAssets([registryReleaseAsset], downloadDir);
const registryPath = join(downloadDir, REGISTRY_ASSET);
const registryDigest = registryReleaseAsset.digest.slice("sha256:".length);
verifyDownloadedAsset(registryPath, {
name: REGISTRY_ASSET,
sha256: registryDigest,
size: registryReleaseAsset.size,
});
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
const plan = collectReusableAssetPlan({
registry,
release,
versions: options.versions,
modules: options.modules,
reuseJre: options.reuseJre,
});
const plannedAssets = [...plan.driverAssets, ...plan.jreAssets].map((asset) => ({
...asset,
releaseVersion: options.versions[asset.moduleName],
}));
if (plannedAssets.length > 0) {
downloadReleaseAssets(plannedAssets.map((asset) => asset.releaseAsset), downloadDir);
}
for (const asset of plannedAssets) {
const source = join(downloadDir, asset.name);
verifyDownloadedAsset(source, asset);
copyFileSync(source, join(options.outputDir, asset.name));
if (asset.moduleName) {
extractRawDriver(source, asset);
}
}
const outputNames = readdirSync(options.outputDir).sort();
console.log(`Reused ${plan.driverAssets.length} driver packages and ${plan.jreAssets.length} JRE packages from ${options.tag}.`);
console.log(outputNames.join("\n"));
} finally {
rmSync(workDir, { recursive: true, force: true });
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View File

@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import test from "node:test";
import { collectReusableAssetPlan } from "./reuse-agent-release-assets.mjs";
const platforms = [
"macos-aarch64",
"macos-x64",
"linux-aarch64",
"linux-x64",
"windows-aarch64",
"windows-x64",
];
test("collects complete reusable Java, native, and JRE assets", () => {
const access = artifact("dbx-agent-access-0.1.34.tar.zst", "a");
const kingbase = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-agent-kingbase-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const jre = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-jre-21-${platform}.tar.zst`, String(index + 7))]),
);
const registry = {
drivers: {
access: { version: "0.1.34", jar: access },
kingbase: { version: "0.1.40", native: kingbase },
},
jres: { 21: { version: "21.0.12", platforms: jre } },
};
const release = releaseFor([access, ...Object.values(kingbase), ...Object.values(jre)]);
const plan = collectReusableAssetPlan({
registry,
release,
versions: { access: "0.1.34", kingbase: "0.1.40" },
modules: ["access", "kingbase"],
reuseJre: true,
});
assert.equal(plan.driverAssets.length, 7);
assert.equal(plan.jreAssets.length, 6);
assert.deepEqual(plan.driverAssets.map((asset) => asset.moduleName), ["access", ...Array(6).fill("kingbase")]);
});
test("rejects an incomplete reusable native platform set", () => {
const native = Object.fromEntries(
platforms.slice(1).map((platform, index) => [platform, artifact(`dbx-agent-vastbase-0.1.38-${platform}.tar.zst`, String(index + 1))]),
);
const registry = { drivers: { vastbase: { version: "0.1.38", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { vastbase: "0.1.38" },
modules: ["vastbase"],
reuseJre: false,
}),
/missing=macos-aarch64/,
);
});
test("requires all TDengine native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-tdengine-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const registry = { drivers: { tdengine: { version: "0.1.40", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { tdengine: "0.1.40" },
modules: ["tdengine"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
test("requires all Neo4j native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-neo4j-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const registry = { drivers: { neo4j: { version: "0.1.40", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { neo4j: "0.1.40" },
modules: ["neo4j"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
test("ignores zero-size legacy JAR placeholders for native-only modules", () => {
const native = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]),
);
const registry = {
drivers: {
duckdb: {
version: "0.1.2",
jar: {
url: "https://example.invalid/dbx-agent-duckdb-legacy-placeholder.jar",
size: 0,
sha256: "",
},
native,
},
},
jres: {},
};
const plan = collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { duckdb: "0.1.2" },
modules: ["duckdb"],
reuseJre: false,
});
assert.equal(plan.driverAssets.length, 6);
assert.equal(plan.driverAssets.some((asset) => asset.kind === "jar"), false);
});
test("rejects a registry version that differs from the effective baseline", () => {
const access = artifact("dbx-agent-access-0.1.33.tar.zst", "a");
const registry = { drivers: { access: { version: "0.1.33", jar: access } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor([access]),
versions: { access: "0.1.34" },
modules: ["access"],
reuseJre: false,
}),
/registry=0\.1\.33, expected=0\.1\.34/,
);
});
function artifact(name, seed) {
return { url: `https://example.invalid/${name}`, size: 100, sha256: seed.repeat(64).slice(0, 64) };
}
function releaseFor(artifacts) {
return {
assets: artifacts.map((entry) => ({
name: entry.url.split("/").at(-1),
size: entry.size,
digest: `sha256:${entry.sha256}`,
})),
};
}

View File

@ -18,6 +18,13 @@ jobs:
versions: ${{ steps.bump.outputs.versions }}
prev_versions: ${{ steps.bump.outputs.prev_versions }}
prev_tag: ${{ steps.bump.outputs.prev_tag }}
effective_prev_ref: ${{ steps.bump.outputs.effective_prev_ref }}
changed_modules: ${{ steps.bump.outputs.changed_modules }}
java_modules: ${{ steps.bump.outputs.java_modules }}
native_modules: ${{ steps.bump.outputs.native_modules }}
reuse_modules: ${{ steps.bump.outputs.reuse_modules }}
build_jre: ${{ steps.bump.outputs.build_jre }}
reuse_jre: ${{ steps.bump.outputs.reuse_jre }}
steps:
- uses: actions/checkout@v4
with:
@ -121,6 +128,7 @@ jobs:
build-agents:
needs: [bump-versions]
if: ${{ needs.bump-versions.outputs.java_modules != '[]' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -129,7 +137,16 @@ jobs:
distribution: temurin
java-version: "21"
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew shadowJar --parallel
- name: Build changed Java agents
env:
JAVA_MODULES: ${{ needs.bump-versions.outputs.java_modules }}
run: |
mapfile -t MODULES < <(echo "$JAVA_MODULES" | python3 -c 'import json,sys; print("\n".join(json.load(sys.stdin)))')
TASKS=()
for module in "${MODULES[@]}"; do
TASKS+=(":${module}:shadowJar")
done
./gradlew "${TASKS[@]}" --parallel
working-directory: agents
- run: python3 scripts/validate_agent_jars.py
working-directory: agents
@ -141,6 +158,7 @@ jobs:
build-oracle-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'oracle') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -161,7 +179,6 @@ jobs:
["linux-aarch64"]="linux/arm64"
["linux-x64"]="linux/amd64"
["windows-aarch64"]="windows/arm64"
["windows-x64"]="windows/amd64"
)
for platform in "${!TARGETS[@]}"; do
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
@ -173,6 +190,20 @@ jobs:
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
done
ls -lh ../../../release-native
# Go 1.20 is the last release that supports Windows 7; keep other targets on Go 1.22.
- name: Set up Go for Windows 7-compatible Oracle agent
uses: actions/setup-go@v5
with:
go-version: "1.20.14"
cache-dependency-path: agents/drivers/oracle-go/go.sum
- name: Build Windows 7-compatible Oracle agent
shell: bash
working-directory: agents/drivers/oracle-go
run: |
output="../../../release-native/dbx-agent-oracle-windows-x64.exe"
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o "$output" .
go version -m "$output" | tee /tmp/oracle-windows-x64-build-info.txt
grep -q ': go1\.20\.14$' /tmp/oracle-windows-x64-build-info.txt
- uses: actions/upload-artifact@v4
with:
name: oracle-native
@ -180,6 +211,7 @@ jobs:
build-xugu-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'xugu') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -219,6 +251,7 @@ jobs:
build-rabbitmq-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'rabbitmq') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -256,8 +289,49 @@ jobs:
name: rabbitmq-native
path: "release-native/dbx-agent-rabbitmq-*"
build-cassandra-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'cassandra') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
- name: Test Cassandra native agent
working-directory: agents/drivers/cassandra-go
run: go test ./...
- name: Cross-compile Cassandra native agent
shell: bash
run: |
mkdir -p release-native
cd agents/drivers/cassandra-go
declare -A TARGETS=(
["macos-aarch64"]="darwin/arm64"
["macos-x64"]="darwin/amd64"
["linux-aarch64"]="linux/arm64"
["linux-x64"]="linux/amd64"
["windows-aarch64"]="windows/arm64"
["windows-x64"]="windows/amd64"
)
for platform in "${!TARGETS[@]}"; do
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
output="../../../release-native/dbx-agent-cassandra-${platform}"
if [[ "$goos" == "windows" ]]; then
output="${output}.exe"
fi
echo "Building $platform ($goos/$goarch)"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
done
ls -lh ../../../release-native
- uses: actions/upload-artifact@v4
with:
name: cassandra-native
path: "release-native/dbx-agent-cassandra-*"
build-kingbase-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'kingbase') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -295,8 +369,89 @@ jobs:
name: kingbase-native
path: "release-native/dbx-agent-kingbase-*"
build-vastbase-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'vastbase') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
- name: Test Vastbase native agent
working-directory: agents/drivers/vastbase-go
run: go test ./...
- name: Cross-compile Vastbase native agent
shell: bash
run: |
mkdir -p release-native
cd agents/drivers/vastbase-go
declare -A TARGETS=(
["macos-aarch64"]="darwin/arm64"
["macos-x64"]="darwin/amd64"
["linux-aarch64"]="linux/arm64"
["linux-x64"]="linux/amd64"
["windows-aarch64"]="windows/arm64"
["windows-x64"]="windows/amd64"
)
for platform in "${!TARGETS[@]}"; do
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
output="../../../release-native/dbx-agent-vastbase-${platform}"
if [[ "$goos" == "windows" ]]; then
output="${output}.exe"
fi
echo "Building $platform ($goos/$goarch)"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
done
ls -lh ../../../release-native
- uses: actions/upload-artifact@v4
with:
name: vastbase-native
path: "release-native/dbx-agent-vastbase-*"
build-neo4j-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'neo4j') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.24.x"
- name: Test Neo4j native agent
working-directory: agents/drivers/neo4j-go
run: go test ./...
- name: Cross-compile Neo4j native agent
shell: bash
run: |
mkdir -p release-native
cd agents/drivers/neo4j-go
declare -A TARGETS=(
["macos-aarch64"]="darwin/arm64"
["macos-x64"]="darwin/amd64"
["linux-aarch64"]="linux/arm64"
["linux-x64"]="linux/amd64"
["windows-aarch64"]="windows/arm64"
["windows-x64"]="windows/amd64"
)
for platform in "${!TARGETS[@]}"; do
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
output="../../../release-native/dbx-agent-neo4j-${platform}"
if [[ "$goos" == "windows" ]]; then
output="${output}.exe"
fi
echo "Building $platform ($goos/$goarch)"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
done
ls -lh ../../../release-native
- uses: actions/upload-artifact@v4
with:
name: neo4j-native
path: "release-native/dbx-agent-neo4j-*"
build-duckdb-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'duckdb') }}
strategy:
fail-fast: false
matrix:
@ -345,12 +500,19 @@ jobs:
with:
toolchain: nightly-2026-07-22
components: rust-src
- uses: actions/setup-python@v5
if: runner.os == 'Windows'
with:
python-version: "3.13"
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
with:
version: "v0.10.0"
- name: Build DuckDB native driver
shell: bash
run: |
if [ "${{ runner.os }}" = "Windows" ]; then
export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-C target-feature=+crt-static"
fi
if [ -n "${{ matrix.manylinux_image }}" ]; then
docker run --rm \
--user "$(id -u):$(id -g)" \
@ -358,14 +520,14 @@ jobs:
-e CARGO_INCREMENTAL=0 \
-e CARGO_TARGET_DIR=/workspace/target/duckdb-driver \
-v "${{ github.workspace }}:/workspace" \
-w /workspace \
-w /workspace/agents/drivers/duckdb \
"${{ matrix.manylinux_image }}" \
bash -lc '
mkdir -p "$HOME"
curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.97.1
source "$HOME/.cargo/env"
cargo build \
--manifest-path agents/drivers/duckdb/Cargo.toml \
--manifest-path Cargo.toml \
--locked \
--release \
--bin dbx-duckdb-driver \
@ -376,18 +538,24 @@ jobs:
if [ "${{ matrix.win7 }}" = "true" ]; then
BUILD_STD=(-Z build-std=std,panic_abort)
fi
pushd agents/drivers/duckdb
cargo build \
--manifest-path agents/drivers/duckdb/Cargo.toml \
--manifest-path Cargo.toml \
--locked \
--release \
--bin dbx-duckdb-driver \
--target "${{ matrix.target }}" \
"${BUILD_STD[@]}"
popd
fi
mkdir -p release-native
cp \
"target/duckdb-driver/${{ matrix.target }}/release/dbx-duckdb-driver${{ matrix.extension }}" \
"release-native/dbx-agent-duckdb-${{ matrix.platform }}${{ matrix.extension }}"
if [ "${{ runner.os }}" = "Windows" ]; then
python agents/scripts/validate_windows_pe_dependencies.py \
"release-native/dbx-agent-duckdb-${{ matrix.platform }}${{ matrix.extension }}"
fi
if [ -n "${{ matrix.manylinux_image }}" ]; then
docker run --rm \
--user "$(id -u):$(id -g)" \
@ -400,7 +568,120 @@ jobs:
name: duckdb-native-${{ matrix.platform }}
path: "release-native/dbx-agent-duckdb-*"
build-tdengine-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'tdengine') }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-latest
target: aarch64-apple-darwin
platform: macos-aarch64
extension: ""
smoke: true
- runner: macos-15-intel
target: x86_64-apple-darwin
platform: macos-x64
extension: ""
smoke: true
- runner: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
platform: linux-aarch64
extension: ""
manylinux_image: quay.io/pypa/manylinux_2_28_aarch64
- runner: ubuntu-22.04
target: x86_64-unknown-linux-gnu
platform: linux-x64
extension: ""
manylinux_image: quay.io/pypa/manylinux_2_28_x86_64
- runner: windows-2022
target: aarch64-pc-windows-msvc
platform: windows-aarch64
extension: .exe
- runner: windows-2022
target: x86_64-win7-windows-msvc
platform: windows-x64
extension: .exe
smoke: true
win7: true
runs-on: ${{ matrix.runner }}
env:
CARGO_INCREMENTAL: "0"
CARGO_TARGET_DIR: ${{ github.workspace }}/target/tdengine-driver
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.97.1
if: runner.os != 'Linux' && matrix.win7 != true
with:
targets: ${{ matrix.target }}
- name: Setup Rust for Windows 7
if: matrix.win7 == true
uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly-2026-07-22
components: rust-src
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
with:
version: "v0.10.0"
- name: Build TDengine native driver
shell: bash
run: |
if [ -n "${{ matrix.manylinux_image }}" ]; then
docker run --rm \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp/dbx-rust-home \
-e CARGO_INCREMENTAL=0 \
-e CARGO_TARGET_DIR=/workspace/target/tdengine-driver \
-v "${{ github.workspace }}:/workspace" \
-w /workspace \
"${{ matrix.manylinux_image }}" \
bash -lc '
mkdir -p "$HOME"
curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.97.1
source "$HOME/.cargo/env"
cargo build \
--manifest-path agents/drivers/tdengine/Cargo.toml \
--locked \
--release \
--bin dbx-tdengine-driver \
--target "${{ matrix.target }}"
'
else
BUILD_STD=()
if [ "${{ matrix.win7 }}" = "true" ]; then
BUILD_STD=(-Z build-std=std,panic_abort)
fi
cargo build \
--manifest-path agents/drivers/tdengine/Cargo.toml \
--locked \
--release \
--bin dbx-tdengine-driver \
--target "${{ matrix.target }}" \
"${BUILD_STD[@]}"
fi
mkdir -p release-native
cp \
"target/tdengine-driver/${{ matrix.target }}/release/dbx-tdengine-driver${{ matrix.extension }}" \
"release-native/dbx-agent-tdengine-${{ matrix.platform }}${{ matrix.extension }}"
if [ -n "${{ matrix.manylinux_image }}" ]; then
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "${{ github.workspace }}/release-native:/driver:ro" \
"${{ matrix.manylinux_image }}" \
"/driver/dbx-agent-tdengine-${{ matrix.platform }}" < /dev/null
elif [ "${{ matrix.smoke }}" = "true" ]; then
"release-native/dbx-agent-tdengine-${{ matrix.platform }}${{ matrix.extension }}" < /dev/null
fi
- uses: actions/upload-artifact@v4
with:
name: tdengine-native-${{ matrix.platform }}
path: "release-native/dbx-agent-tdengine-*"
build-jre:
needs: [bump-versions]
if: ${{ needs.bump-versions.outputs.build_jre == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
@ -474,8 +755,41 @@ jobs:
name: jre-${{ matrix.jre-key }}
path: "dbx-jre-*.tar.zst"
reuse-previous-assets:
name: Reuse unchanged agent artifacts
needs: [bump-versions]
if: ${{ needs.bump-versions.outputs.reuse_modules != '[]' || needs.bump-versions.outputs.reuse_jre == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install artifact tools
run: |
sudo apt-get update
sudo apt-get install -y zstd
- name: Download and verify previous release artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PREV_TAG: ${{ needs.bump-versions.outputs.prev_tag }}
PREV_VERSIONS: ${{ needs.bump-versions.outputs.prev_versions }}
REUSE_MODULES: ${{ needs.bump-versions.outputs.reuse_modules }}
REUSE_JRE: ${{ needs.bump-versions.outputs.reuse_jre }}
run: |
node .github/scripts/reuse-agent-release-assets.mjs \
--repo "$GITHUB_REPOSITORY" \
--tag "$PREV_TAG" \
--versions "$PREV_VERSIONS" \
--modules "$REUSE_MODULES" \
--reuse-jre "$REUSE_JRE" \
--output reused-release
- uses: actions/upload-artifact@v4
with:
name: reused-agent-artifacts
path: reused-release/*
retention-days: 1
release:
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-kingbase-native, build-duckdb-native, build-jre]
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-cassandra-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-duckdb-native, build-tdengine-native, build-jre, reuse-previous-assets]
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: ubuntu-latest
steps:
- name: Create DBX bot release token
@ -499,18 +813,32 @@ jobs:
with:
path: artifacts
- name: Install artifact tools
run: |
sudo apt-get update
sudo apt-get install -y zstd
- name: Flatten artifacts
run: |
mkdir -p release
find artifacts/agent-jars -name '*.jar' -exec cp {} release/ \;
find artifacts/oracle-native -type f -name 'dbx-agent-oracle-*' -exec cp {} release/ \;
find artifacts/xugu-native -type f -name 'dbx-agent-xugu-*' -exec cp {} release/ \;
find artifacts/rabbitmq-native -type f -name 'dbx-agent-rabbitmq-*' -exec cp {} release/ \;
find artifacts/kingbase-native -type f -name 'dbx-agent-kingbase-*' -exec cp {} release/ \;
find artifacts/duckdb-native-* -type f -name 'dbx-agent-duckdb-*' -exec cp {} release/ \;
find artifacts -type f -name 'dbx-agent-*.jar' -exec cp {} release/ \;
find artifacts -type f -name 'dbx-agent-*' ! -name '*.jar' ! -name '*.tar.zst' -exec cp {} release/ \;
find artifacts -type f -name 'dbx-agent-*.tar.zst' -exec cp {} release/ \;
find artifacts -name 'dbx-jre-*.tar.zst' -exec cp {} release/ \;
ls -lh release/
- name: Extract reused raw agent artifacts
if: ${{ needs.bump-versions.outputs.reuse_modules != '[]' }}
env:
PREV_VERSIONS: ${{ needs.bump-versions.outputs.prev_versions }}
REUSE_MODULES: ${{ needs.bump-versions.outputs.reuse_modules }}
run: |
node .github/scripts/reuse-agent-release-assets.mjs \
--extract-packages release \
--versions "$PREV_VERSIONS" \
--modules "$REUSE_MODULES" \
--output release
- name: Add versions to agent artifact filenames
env:
MODULE_VERSIONS: ${{ needs.bump-versions.outputs.versions }}
@ -578,9 +906,13 @@ jobs:
local name="$1"
case "$name" in
kingbase) echo "人大金仓 KingbaseES" ;;
vastbase) echo "Vastbase" ;;
duckdb) echo "DuckDB" ;;
xugu) echo "虚谷 XuguDB" ;;
rabbitmq) echo "RabbitMQ" ;;
cassandra) echo "Apache Cassandra" ;;
neo4j) echo "Neo4j" ;;
tdengine) echo "TDengine" ;;
*) echo "$name" ;;
esac
}
@ -644,7 +976,7 @@ jobs:
[ -n "$DRIVERS" ] && DRIVERS="${DRIVERS},"$'\n'
DRIVERS="${DRIVERS}$(generate_jar_entry "$name" "$label" "$f" "$jre_key" "$version" "$external_driver" "$native_json")"
done
for name in oracle xugu kingbase duckdb rabbitmq; do
for name in oracle xugu kingbase vastbase neo4j duckdb rabbitmq cassandra tdengine; do
version=$(get_module_version "$name")
[ -f "release/dbx-agent-${name}-${version}.jar" ] && continue
native_json=$(generate_native_platforms "$name" "$version")
@ -681,8 +1013,6 @@ jobs:
- name: Build single-driver packages
run: |
sudo apt-get update
sudo apt-get install -y zstd
python3 agents/scripts/build_driver_zips.py release --cleanup-sources
python3 -m json.tool release/agent-registry.json > /dev/null
echo "=== final agent-registry.json ==="
@ -711,10 +1041,14 @@ jobs:
local name="$1"
case "$name" in
kingbase) echo "人大金仓 KingbaseES" ;;
vastbase) echo "Vastbase" ;;
duckdb) echo "DuckDB" ;;
oracle) echo "Oracle" ;;
xugu) echo "虚谷 XuguDB" ;;
rabbitmq) echo "RabbitMQ" ;;
cassandra) echo "Apache Cassandra" ;;
neo4j) echo "Neo4j" ;;
tdengine) echo "TDengine" ;;
*) echo "$name" ;;
esac
}
@ -737,6 +1071,12 @@ jobs:
LOG_PATH="agents/drivers/oracle-go/"
elif [ "$name" = "kingbase" ]; then
LOG_PATH="agents/drivers/kingbase-go/"
elif [ "$name" = "vastbase" ]; then
LOG_PATH="agents/drivers/vastbase-go/"
elif [ "$name" = "cassandra" ]; then
LOG_PATH="agents/drivers/cassandra-go/"
elif [ "$name" = "neo4j" ]; then
LOG_PATH="agents/drivers/neo4j-go/"
elif [ -d "agents/drivers/$name" ]; then
LOG_PATH="agents/drivers/$name/"
else

View File

@ -21,6 +21,12 @@ jobs:
env:
# The workspace intentionally contains platform-specific CLI/MCP packages for every release target.
NPM_CONFIG_LOGLEVEL: error
# Same two-layer cache as rust-test/rust-fmt-clippy: sccache cannot
# reuse Cargo incremental artifacts, so avoid generating them in CI.
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
steps:
- uses: actions/checkout@v5
@ -38,9 +44,98 @@ jobs:
- name: Install frontend dependencies
run: pnpm --filter dbx... install --frozen-lockfile
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
with:
version: "v0.10.0"
- name: Configure S3 sccache
if: env.SCCACHE_GHA_ENABLED != 'true'
shell: bash
env:
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
run: |
{
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
echo "SCCACHE_REGION=${CACHE_REGION}"
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
} >> "$GITHUB_ENV"
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./ -> target"
# Own key: this job builds one crate with --no-default-features,
# a different fingerprint than either Rust job's feature set, so
# sharing their key would only ever miss and waste cache space.
shared-key: ci-frontend-docs-export-smoke-x86_64-unknown-linux-gnu
# Preserve completed dependency builds when a later step fails.
cache-on-failure: true
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
save-if: ${{ github.ref == 'refs/heads/main' }}
# exportSmoke.spec.ts shells out to this example to build a real
# to_standalone_html fixture. Built here, before `pnpm check`, so the
# test's own `cargo run` (same flags — see the spec's comment) is a
# cache hit rather than a fresh compile of dbx-core inside a vitest
# hook. `--no-default-features`: the docs module needs none of
# duckdb-sidecar, mq-admin, sqlite-sqlcipher or system-fonts, so this
# avoids requiring any system dev packages this job doesn't already
# install.
- name: Build the docs export smoke fixture example
run: cargo build -p dbx-core --locked --no-default-features --example docs_export_smoke
# Guards that the committed bundle (crates/dbx-core/assets/docs-export.*)
# matches the sources under apps/desktop/ it was built from. This job is
# what runs on a frontend-only change, so the guard has to run here too —
# rust-test is gated on crates/**/src-tauri/**/Cargo.*, none of which a
# frontend-only PR touches. Same `--no-default-features` fingerprint as
# the prebuild step above, so this is a cache hit, not a fresh compile.
- name: Guard the committed docs export bundle
run: cargo test -p dbx-core --locked --no-default-features --lib docs::export
- name: Show sccache stats
if: always()
continue-on-error: true
run: ${SCCACHE_PATH} --show-stats
- name: Frontend check
run: pnpm check
# Advisory, not a guard: the manifest guard above hashes INPUTS, so it
# cannot see a hand-edited artefact (no source hash moves) or a
# toolchain bump (vite, tailwindcss and @vitejs/plugin-vue are
# plugins, not modules — they never enter the graph). Rebuilding and
# diffing checks the relationship between inputs and output instead,
# closing both gaps. Must run last: it overwrites the committed
# bundle, and both the guard above and `pnpm check` above (whose
# exportSmoke.spec.ts executes the *committed* bundle) need it
# untouched to be testing what is actually checked in.
- name: Rebuild the docs export bundle
run: pnpm build:docs-export
# continue-on-error for one cycle: every reproducibility observation
# so far was same-platform, and this job is ubuntu-22.04 x86_64 like
# every contributor's toolchain is lockfile-pinned to expect. If a
# byte ever differs cross-platform, surface it in the job summary
# rather than reddening every PR at once on a repo we contribute to,
# not maintain.
- name: Report any docs export bundle drift
run: git diff --exit-code -- crates/dbx-core/assets/
continue-on-error: true
github-scripts:
needs: changes
if: needs.changes.outputs.github_scripts == 'true'
@ -101,6 +196,7 @@ jobs:
timeout-minutes: 90
env:
CARGO_INCREMENTAL: "0"
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
steps:
- uses: actions/checkout@v5
@ -118,36 +214,119 @@ jobs:
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Setup Rust for standard Windows
uses: dtolnay/rust-toolchain@1.97.1
- name: Check standard Windows dependency path
run: cargo check --locked --package dbx --no-default-features --target x86_64-pc-windows-msvc
- name: Setup Rust for Windows 7
uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly-2026-07-22
components: rust-src
- name: Prepare Win7-compatible WebView2 loader
shell: pwsh
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
- name: Prepare WebView2 109 fixed runtime
shell: pwsh
run: ./.github/scripts/prepare-webview2-win7-runtime.ps1
- name: Probe WebView2 109 fixed runtime
shell: pwsh
run: ./.github/scripts/assert-webview2-win7-runtime.ps1
- name: Build frontend
run: pnpm build
- name: Build DBX for Windows 7
run: cargo build --locked --package dbx --release --target x86_64-win7-windows-msvc -Z build-std=std,panic_abort
- name: Prepare WebView2 109 offline runtime
shell: pwsh
run: ./.github/scripts/prepare-webview2-win7-runtime.ps1
run: |
$env:TAURI_CONFIG = Get-Content src-tauri/tauri.webview2-win7-fixed.conf.json -Raw
cargo build --locked --package dbx --release --features custom-protocol --target x86_64-win7-windows-msvc -Z build-std=std,panic_abort
- name: Bundle Windows 7 offline installer
- name: Audit Windows 7 PE imports
shell: pwsh
run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
- name: Bundle Windows 7 fixed-runtime installer
shell: pwsh
run: |
$bundleDir = "target/x86_64-win7-windows-msvc/release/bundle/nsis"
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-offline.conf.json
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (!$installer) {
Write-Error "Missing Windows 7 WebView2 offline installer in ${bundleDir}"
Write-Error "Missing Windows 7 fixed-runtime installer in ${bundleDir}"
exit 1
}
Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256
- name: Audit Windows 7 installer contents
shell: pwsh
run: |
$installer = Get-ChildItem "target/x86_64-win7-windows-msvc/release/bundle/nsis" -Filter "*.exe" |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
./.github/scripts/assert-win7-installer-content.ps1 -InstallerPath $installer.FullName
- name: Upload Windows 7 test installer
uses: actions/upload-artifact@v4
with:
name: DBX-win7-fixed-runtime-test
path: target/x86_64-win7-windows-msvc/release/bundle/nsis/*.exe
if-no-files-found: error
retention-days: 7
duckdb-windows-driver:
needs: changes
if: needs.changes.outputs.duckdb_windows == 'true'
runs-on: windows-2022
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
CARGO_TARGET_DIR: ${{ github.workspace }}/target/duckdb-driver
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@v5
- name: Setup Rust for Windows 7
uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly-2026-07-22
components: rust-src
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
with:
version: "v0.10.0"
- name: Build DuckDB Windows driver
shell: bash
run: |
pushd agents/drivers/duckdb
cargo build \
--locked \
--release \
--bin dbx-duckdb-driver \
--target x86_64-win7-windows-msvc \
-Z build-std=std,panic_abort
popd
- name: Validate DuckDB Windows driver
shell: bash
run: |
DRIVER="target/duckdb-driver/x86_64-win7-windows-msvc/release/dbx-duckdb-driver.exe"
python agents/scripts/validate_windows_pe_dependencies.py "$DRIVER"
"$DRIVER" < /dev/null
rust-fmt-clippy:
needs: changes
if: needs.changes.outputs.rust == 'true'
@ -332,8 +511,8 @@ jobs:
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
cache: maven
java-version: "21"
cache: gradle
- name: JDBC plugin version guard
env:
@ -370,6 +549,7 @@ jobs:
rust_full: ${{ steps.rust-mode.outputs.full }}
jdbc: ${{ steps.filter.outputs.jdbc }}
agents: ${{ steps.filter.outputs.agents }}
duckdb_windows: ${{ steps.filter.outputs.duckdb_windows }}
nix: ${{ steps.filter.outputs.nix }}
windows_win7_bundle: ${{ steps.filter.outputs.windows_win7_bundle }}
github_scripts: ${{ steps.filter.outputs.github_scripts }}
@ -391,6 +571,8 @@ jobs:
- 'package.json'
- '.oxfmtrc.json'
- 'scripts/run-check.mjs'
- 'crates/dbx-core/src/docs/**'
- 'crates/dbx-core/assets/docs-export.*'
- '.github/workflows/ci.yml'
packages:
- 'packages/cli/**'
@ -406,6 +588,7 @@ jobs:
rust:
- 'crates/**'
- 'src-tauri/**'
- 'vendor/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain*'
@ -421,6 +604,11 @@ jobs:
- '.github/scripts/bump-agent-versions.test.mjs'
- '.github/workflows/agents-release.yml'
- '.github/workflows/ci.yml'
duckdb_windows:
- 'agents/drivers/duckdb/**'
- 'agents/scripts/validate_windows_pe_dependencies.py'
- '.github/workflows/agents-release.yml'
- '.github/workflows/ci.yml'
nix:
# These advisory checks validate the pnpm and Cargo dependency closures.
- 'package.json'
@ -436,16 +624,27 @@ jobs:
- '.github/workflows/ci.yml'
- '.github/workflows/update-nix-pnpm-hash.yml'
windows_win7_bundle:
- '.github/scripts/assert-win7-pe-compat.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-offline.conf.json'
- '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-core/src/db/postgres.rs'
- 'crates/dbx-core/src/update.rs'
- 'Cargo.toml'
- 'Cargo.lock'
- 'vendor/ctor/**'
- 'vendor/dirs-sys/**'
- 'vendor/pageant/**'
- 'vendor/wry/**'
github_scripts:
- '.github/scripts/**'
- '.github/workflows/ci.yml'
@ -470,13 +669,13 @@ jobs:
while IFS= read -r file; do
case "$file" in
Cargo.toml|Cargo.lock|rust-toolchain*|.github/workflows/ci.yml|*/Cargo.toml)
Cargo.toml|Cargo.lock|rust-toolchain*|.github/workflows/ci.yml|*/Cargo.toml|vendor/*)
full=true
break
;;
esac
done < <(git diff --name-only "$BASE_SHA" HEAD -- Cargo.toml Cargo.lock 'rust-toolchain*' crates src-tauri .github/workflows/ci.yml)
done < <(git diff --name-only "$BASE_SHA" HEAD -- Cargo.toml Cargo.lock 'rust-toolchain*' crates src-tauri vendor .github/workflows/ci.yml)
fi
echo "full=$full" >> "$GITHUB_OUTPUT"
@ -502,7 +701,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22.x"
go-version: "1.24.x"
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
@ -523,6 +722,9 @@ jobs:
- name: DuckDB driver tests
run: cargo test --manifest-path drivers/duckdb/Cargo.toml --locked
- name: TDengine driver tests
run: cargo test --manifest-path drivers/tdengine/Cargo.toml --locked
- name: Agent validation
run: python3 scripts/validate_agents.py
@ -538,6 +740,18 @@ jobs:
run: go test ./...
working-directory: agents/drivers/rabbitmq
- name: Cassandra native agent tests
run: go test ./...
working-directory: agents/drivers/cassandra-go
- name: Vastbase native agent tests
run: go test ./...
working-directory: agents/drivers/vastbase-go
- name: Neo4j native agent tests
run: go test ./...
working-directory: agents/drivers/neo4j-go
- name: Oracle native agent build
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-oracle-linux-x64 .
working-directory: agents/drivers/oracle-go
@ -550,6 +764,108 @@ jobs:
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-rabbitmq-linux-x64 .
working-directory: agents/drivers/rabbitmq
- name: Cassandra native agent build
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-cassandra-linux-x64 .
working-directory: agents/drivers/cassandra-go
- name: Vastbase native agent build
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-vastbase-linux-x64 .
working-directory: agents/drivers/vastbase-go
- name: Neo4j native agent build
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-neo4j-linux-x64 .
working-directory: agents/drivers/neo4j-go
- name: TDengine native agent build
run: cargo build --manifest-path drivers/tdengine/Cargo.toml --locked --release --bin dbx-tdengine-driver
- name: TDengine native agent integration tests
shell: bash
run: |
set -euo pipefail
while IFS='|' read -r version image; do
name="dbx-tdengine-${version//./-}"
docker rm -fv "$name" >/dev/null 2>&1 || true
docker run -d --name "$name" \
--memory 4g \
--ulimit nofile=65535:65535 \
-p 6030:6030 \
-p 6041:6041 \
"$image"
cleanup() {
docker rm -fv "$name" >/dev/null 2>&1 || true
}
trap cleanup EXIT
ready=false
dnodes=""
for _ in $(seq 1 90); do
dnodes="$(docker exec "$name" taos -s 'SHOW DNODES' 2>/dev/null || true)"
if grep -Eq '\|[[:space:]]*ready[[:space:]]*\|' <<<"$dnodes"; then
ready=true
break
fi
sleep 2
done
if [ "$ready" != "true" ]; then
printf '%s\n' "$dnodes"
docker logs "$name"
exit 1
fi
TDENGINE_INTEGRATION=1 \
TDENGINE_TEST_HOST=127.0.0.1 \
TDENGINE_TEST_PORT=6041 \
cargo test --manifest-path drivers/tdengine/Cargo.toml --locked --test live -- --nocapture
cleanup
trap - EXIT
done <<'EOF'
2.4.0.14|tdengine/tdengine:2.4.0.14
2.6.0.34|tdengine/tdengine:2.6.0.34
3.0.7.1|tdengine/tdengine:3.0.7.1
3.3.6.13|tdengine/tdengine:3.3.6.13
3.4.2.2|tdengine/tsdb:3.4.2.2
EOF
- name: Cassandra native agent integration tests
shell: bash
working-directory: agents/drivers/cassandra-go
run: |
set -euo pipefail
for version in 3.11.19 5.0.6; do
name="dbx-cassandra-${version//./-}"
docker rm -fv "$name" >/dev/null 2>&1 || true
docker run -d --name "$name" \
-e CASSANDRA_CLUSTER_NAME="DBX Cassandra CI $version" \
-e CASSANDRA_DC=dc1 \
-e CASSANDRA_RACK=rack1 \
-e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch \
-e CASSANDRA_NUM_TOKENS=16 \
-e MAX_HEAP_SIZE=512M \
-e HEAP_NEWSIZE=100M \
-p 9042:9042 \
"cassandra:$version"
cleanup() {
docker rm -fv "$name" >/dev/null 2>&1 || true
}
trap cleanup EXIT
ready=false
for _ in $(seq 1 100); do
if docker exec "$name" cqlsh -e 'SELECT release_version FROM system.local' >/dev/null 2>&1; then
ready=true
break
fi
sleep 3
done
if [ "$ready" != "true" ]; then
docker logs "$name"
exit 1
fi
CASSANDRA_TEST_HOST=127.0.0.1 \
CASSANDRA_TEST_PORT=9042 \
go test -run '^TestCassandraIntegration$' -count=1 ./...
cleanup
trap - EXIT
done
- name: RabbitMQ native agent integration tests
shell: bash
working-directory: agents/drivers/rabbitmq

View File

@ -87,16 +87,16 @@ jobs:
shell: bash
run: |
TMP_DIR="$(mktemp -d)"
cp plugins/jdbc/pom.xml "$TMP_DIR/pom.xml"
cp plugins/jdbc/build.gradle "$TMP_DIR/build.gradle"
cp plugins/jdbc/manifest.json "$TMP_DIR/manifest.json"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin main
git switch -C jdbc-plugin-version-bump origin/main
cp "$TMP_DIR/pom.xml" plugins/jdbc/pom.xml
cp "$TMP_DIR/build.gradle" plugins/jdbc/build.gradle
cp "$TMP_DIR/manifest.json" plugins/jdbc/manifest.json
git add plugins/jdbc/pom.xml plugins/jdbc/manifest.json
git add plugins/jdbc/build.gradle plugins/jdbc/manifest.json
if git diff --cached --quiet; then
echo "JDBC plugin version ${{ steps.bump.outputs.new_version }} is already on main."
exit 0
@ -357,6 +357,7 @@ jobs:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
steps:
- uses: actions/checkout@v5
@ -377,6 +378,9 @@ jobs:
toolchain: nightly-2026-07-22
components: rust-src
# sccache must be set up before the WebView2 loader/runtime scripts: they
# run `cargo fetch` under the job-wide RUSTC_WRAPPER=sccache, so installing
# sccache only after them made cargo abort with "program not found".
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
with:
@ -403,6 +407,18 @@ jobs:
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
} >> "$GITHUB_ENV"
- name: Prepare Win7-compatible WebView2 loader
shell: pwsh
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
- name: Prepare WebView2 109 fixed runtime
shell: pwsh
run: ./.github/scripts/prepare-webview2-win7-runtime.ps1
- name: Probe WebView2 109 fixed runtime
shell: pwsh
run: ./.github/scripts/assert-webview2-win7-runtime.ps1
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
@ -416,13 +432,16 @@ jobs:
run: pnpm build
- name: Build DBX for Windows 7
run: cargo build --locked --package dbx --release --target x86_64-win7-windows-msvc -Z build-std=std,panic_abort
- name: Prepare WebView2 109 offline runtime
shell: pwsh
run: ./.github/scripts/prepare-webview2-win7-runtime.ps1
run: |
$env:TAURI_CONFIG = Get-Content src-tauri/tauri.webview2-win7-fixed.conf.json -Raw
cargo build --locked --package dbx --release --features custom-protocol --target x86_64-win7-windows-msvc -Z build-std=std,panic_abort
- name: Bundle and upload Windows 7 offline installer
- name: Audit Windows 7 PE imports
shell: pwsh
run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
- name: Bundle and upload Windows 7 fixed-runtime installer
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -438,16 +457,18 @@ jobs:
exit 1
}
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-offline.conf.json
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (!$installer) {
Write-Error "Missing Windows 7 WebView2 offline installer in ${bundleDir}"
Write-Error "Missing Windows 7 fixed-runtime installer in ${bundleDir}"
exit 1
}
./.github/scripts/assert-win7-installer-content.ps1 -InstallerPath $installer.FullName
Copy-Item $installer.FullName $offlineName -Force
gh release upload "${env:GITHUB_REF_NAME}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber
@ -599,8 +620,8 @@ jobs:
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
cache: maven
java-version: "21"
cache: gradle
- name: Apply automatic JDBC plugin version bump
shell: bash
@ -610,7 +631,7 @@ jobs:
id: jdbc-plugin
shell: bash
run: |
VERSION="$(grep -m1 '<version>' plugins/jdbc/pom.xml | sed -E 's/.*<version>([^<]+)<.*/\1/')"
VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Package JDBC plugin

View File

@ -0,0 +1,58 @@
name: Windows Diagnostic Build
on:
workflow_dispatch:
inputs:
target_ref:
description: "Branch, tag, or SHA to build"
required: true
default: "codex/windows-startup-diagnostics"
permissions:
contents: read
jobs:
build-windows-portable:
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.target_ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Build Windows executable
env:
CXXFLAGS: /std:c++17 /EHsc
run: pnpm tauri build --target x86_64-pc-windows-msvc --no-bundle --ci
- name: Package diagnostic portable ZIP
shell: pwsh
run: |
$portableDir = "diagnostic-portable\DBX-diagnostic-x64"
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
Copy-Item "target\x86_64-pc-windows-msvc\release\dbx.exe" (Join-Path $portableDir "DBX.exe") -Force
Copy-Item "LICENSE" (Join-Path $portableDir "LICENSE") -Force
Copy-Item "README.md" (Join-Path $portableDir "README.md") -Force
Set-Content -Path (Join-Path $portableDir "portable.dbx") -Value "" -NoNewline
Compress-Archive -Path (Join-Path $portableDir "*") -DestinationPath "DBX-diagnostic-x64-portable.zip" -Force
- name: Upload diagnostic artifact
uses: actions/upload-artifact@v4
with:
name: DBX-diagnostic-x64-portable
path: DBX-diagnostic-x64-portable.zip

4
.gitignore vendored
View File

@ -15,14 +15,18 @@ CLAUDE.md
dist/
coverage/
/target/
src-tauri/webview2-fixed-runtime/
plugins/jdbc/target/
plugins/jdbc/dependency-reduced-pom.xml
plugins/jdbc/lib/*.jar
plugins/jdbc/.gradle/
plugins/jdbc/build/
plugins/jdbc/dist/
docs/.next/
docs/out/
docs/.source/
docs/superpowers/
/docs/aegis/
src-tauri/*.dylib
# OS files

318
Cargo.lock generated
View File

@ -41,9 +41,9 @@ dependencies = [
[[package]]
name = "aes"
version = "0.9.1"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
dependencies = [
"cipher 0.5.2",
"cpubits",
@ -71,7 +71,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22c0c90bbe8d4f77c3ca9ddabe41a1f8382d6fc1f7cea89459d0f320371f972"
dependencies = [
"aead 0.6.0-rc.10",
"aes 0.9.1",
"aes 0.9.2",
"cipher 0.5.2",
"ctr 0.10.1",
"ghash 0.6.0",
@ -256,9 +256,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.42"
version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
dependencies = [
"compression-codecs",
"compression-core",
@ -395,6 +395,35 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "async-tungstenite"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cca750b12e02c389c1694d35c16539f88b8bbaa5945934fdc1b41a776688589"
dependencies = [
"futures-io",
"futures-util",
"log",
"pin-project-lite",
"rustls-native-certs 0.7.3",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.25.0",
"tungstenite 0.21.0",
]
[[package]]
name = "async_io_stream"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c"
dependencies = [
"futures",
"pharos",
"rustc_version",
"tokio",
]
[[package]]
name = "asynchronous-codec"
version = "0.6.2"
@ -897,9 +926,9 @@ dependencies = [
[[package]]
name = "camino"
version = "1.2.4"
version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
dependencies = [
"serde_core",
]
@ -1621,7 +1650,7 @@ dependencies = [
[[package]]
name = "dbx"
version = "0.5.73"
version = "0.5.79"
dependencies = [
"anyhow",
"axum",
@ -1646,7 +1675,7 @@ dependencies = [
"reqwest 0.12.28",
"russh",
"rust_decimal",
"rustls 0.23.42",
"rustls 0.23.43",
"semver",
"serde",
"serde_json",
@ -1669,12 +1698,13 @@ dependencies = [
"tokio-postgres",
"tokio-util",
"uuid",
"windows-sys 0.61.2",
"zip",
]
[[package]]
name = "dbx-cli"
version = "0.4.51"
version = "0.4.57"
dependencies = [
"async-trait",
"dbx-core",
@ -1725,10 +1755,11 @@ dependencies = [
"redis",
"regex",
"reqwest 0.12.28",
"rumqttc",
"rusqlite",
"russh",
"rust_decimal",
"rustls 0.23.42",
"rustls 0.23.43",
"rustls-pemfile 2.2.0",
"serde",
"serde_json",
@ -1759,14 +1790,14 @@ dependencies = [
[[package]]
name = "dbx-mcp"
version = "0.4.51"
version = "0.4.57"
dependencies = [
"async-trait",
"dbx-core",
"dirs",
"reqwest 0.12.28",
"rmcp",
"schemars 1.2.1",
"schemars 1.2.2",
"serde",
"serde_json",
"tempfile",
@ -1777,7 +1808,7 @@ dependencies = [
[[package]]
name = "dbx-web"
version = "0.5.73"
version = "0.5.79"
dependencies = [
"aes-gcm 0.10.3",
"argon2",
@ -1791,7 +1822,7 @@ dependencies = [
"pbkdf2 0.12.2",
"redis",
"reqwest 0.12.28",
"rustls 0.23.42",
"rustls 0.23.43",
"serde",
"serde_json",
"sha2 0.10.9",
@ -1997,8 +2028,6 @@ dependencies = [
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
@ -2020,13 +2049,13 @@ dependencies = [
[[package]]
name = "displaydoc"
version = "0.2.6"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@ -2233,7 +2262,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 1.1.3+spec-1.1.0",
"toml 1.1.4+spec-1.1.0",
"vswhom",
"winreg",
]
@ -2343,11 +2372,10 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "event-listener"
version = "5.4.1"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
@ -2483,6 +2511,17 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
@ -3279,9 +3318,9 @@ dependencies = [
[[package]]
name = "http"
version = "1.4.2"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
@ -3371,7 +3410,7 @@ dependencies = [
"http",
"hyper",
"hyper-util",
"rustls 0.23.42",
"rustls 0.23.43",
"tokio",
"tokio-rustls 0.26.4",
"tower-service",
@ -4441,7 +4480,7 @@ dependencies = [
"percent-encoding",
"rand 0.9.5",
"rustc_version_runtime",
"rustls 0.23.42",
"rustls 0.23.43",
"serde",
"serde_bytes",
"serde_with",
@ -4554,7 +4593,7 @@ dependencies = [
"mysql_common",
"percent-encoding",
"rand 0.10.2",
"rustls 0.23.42",
"rustls 0.23.43",
"serde",
"socket2",
"thiserror 2.0.19",
@ -5223,8 +5262,6 @@ dependencies = [
[[package]]
name = "pageant"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5"
dependencies = [
"base16ct",
"byteorder",
@ -5236,8 +5273,8 @@ dependencies = [
"sha2 0.11.0",
"thiserror 2.0.19",
"tokio",
"windows 0.61.3",
"windows 0.62.2",
"windows-strings 0.5.1",
]
[[package]]
@ -5437,6 +5474,16 @@ dependencies = [
"indexmap 2.14.0",
]
[[package]]
name = "pharos"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414"
dependencies = [
"futures",
"rustc_version",
]
[[package]]
name = "phf"
version = "0.13.1"
@ -5523,7 +5570,7 @@ version = "0.8.0-rc.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a777c6e26664bc9504b3ce3f6133f8f20d9071f130a4f9fcbd3186959d8dd6"
dependencies = [
"aes 0.9.1",
"aes 0.9.2",
"aes-gcm 0.11.0-rc.3",
"cbc 0.2.1",
"der 0.8.1",
@ -5921,7 +5968,7 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.42",
"rustls 0.23.43",
"socket2",
"thiserror 2.0.19",
"tokio",
@ -5942,7 +5989,7 @@ dependencies = [
"rand_pcg",
"ring",
"rustc-hash",
"rustls 0.23.42",
"rustls 0.23.43",
"rustls-pki-types",
"slab",
"thiserror 2.0.19",
@ -6141,7 +6188,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"rand 0.9.5",
"rustls 0.23.42",
"rustls 0.23.43",
"rustls-native-certs 0.8.4",
"ryu",
"sha1_smol",
@ -6256,7 +6303,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.42",
"rustls 0.23.43",
"rustls-pki-types",
"serde",
"serde_json",
@ -6297,7 +6344,7 @@ dependencies = [
"log",
"percent-encoding",
"pin-project-lite",
"rustls 0.23.42",
"rustls 0.23.43",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
@ -6412,7 +6459,7 @@ dependencies = [
"pastey",
"pin-project-lite",
"rmcp-macros",
"schemars 1.2.1",
"schemars 1.2.2",
"serde",
"serde_json",
"thiserror 2.0.19",
@ -6454,6 +6501,25 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rumqttc"
version = "0.24.0"
dependencies = [
"async-tungstenite",
"bytes",
"flume",
"futures-util",
"http",
"log",
"rustls-native-certs 0.7.3",
"rustls-pemfile 2.2.0",
"rustls-webpki 0.102.8",
"thiserror 1.0.69",
"tokio",
"tokio-rustls 0.25.0",
"ws_stream_tungstenite",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
@ -6476,7 +6542,7 @@ checksum = "324b92f459d3e42da294e14e8eb150d2215fcfb7c966838bc1127cd68bc05a0d"
dependencies = [
"aead 0.6.0-rc.10",
"aes 0.8.4",
"aes 0.9.1",
"aes 0.9.2",
"aes-gcm 0.11.0-rc.3",
"aws-lc-rs",
"bitflags 2.13.1",
@ -6675,9 +6741,23 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
dependencies = [
"log",
"ring",
"rustls-pki-types",
"rustls-webpki 0.102.8",
"subtle",
"zeroize",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"log",
@ -6701,6 +6781,19 @@ dependencies = [
"security-framework 2.11.1",
]
[[package]]
name = "rustls-native-certs"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
dependencies = [
"openssl-probe 0.1.6",
"rustls-pemfile 2.2.0",
"rustls-pki-types",
"schannel",
"security-framework 2.11.1",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
@ -6752,7 +6845,7 @@ dependencies = [
"jni 0.22.4",
"log",
"once_cell",
"rustls 0.23.42",
"rustls 0.23.43",
"rustls-native-certs 0.8.4",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.13",
@ -6778,6 +6871,17 @@ dependencies = [
"untrusted 0.9.0",
]
[[package]]
name = "rustls-webpki"
version = "0.102.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted 0.9.0",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@ -6865,14 +6969,14 @@ dependencies = [
[[package]]
name = "schemars"
version = "1.2.1"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive 1.2.1",
"schemars_derive 1.2.2",
"serde",
"serde_json",
]
@ -6885,20 +6989,20 @@ checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"serde_derive_internals 0.29.1",
"syn 2.0.119",
]
[[package]]
name = "schemars_derive"
version = "1.2.1"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.119",
"serde_derive_internals 0.30.0",
"syn 3.0.3",
]
[[package]]
@ -7077,6 +7181,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "serde_json"
version = "1.0.151"
@ -7156,7 +7271,7 @@ dependencies = [
"indexmap 1.9.3",
"indexmap 2.14.0",
"schemars 0.9.0",
"schemars 1.2.1",
"schemars 1.2.2",
"serde_core",
"serde_json",
"serde_with_macros",
@ -7490,6 +7605,9 @@ name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
@ -7813,9 +7931,9 @@ dependencies = [
[[package]]
name = "tao-macros"
version = "0.1.3"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849"
dependencies = [
"proc-macro2",
"quote",
@ -8048,7 +8166,7 @@ dependencies = [
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"toml 1.1.4+spec-1.1.0",
"url",
]
@ -8137,7 +8255,7 @@ dependencies = [
"osakit",
"percent-encoding",
"reqwest 0.13.4",
"rustls 0.23.42",
"rustls 0.23.43",
"semver",
"serde",
"serde_json",
@ -8250,7 +8368,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"toml 1.1.4+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@ -8265,7 +8383,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
dependencies = [
"dunce",
"embed-resource",
"toml 1.1.3+spec-1.1.0",
"toml 1.1.4+spec-1.1.0",
]
[[package]]
@ -8498,13 +8616,13 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.7.1"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@ -8550,7 +8668,7 @@ checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144"
dependencies = [
"const-oid 0.9.6",
"ring",
"rustls 0.23.42",
"rustls 0.23.43",
"tokio",
"tokio-postgres",
"tokio-rustls 0.26.4",
@ -8567,13 +8685,24 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
dependencies = [
"rustls 0.22.4",
"rustls-pki-types",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.42",
"rustls 0.23.43",
"tokio",
]
@ -8597,7 +8726,7 @@ dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
"tungstenite 0.29.0",
]
[[package]]
@ -8645,9 +8774,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.1.3+spec-1.1.0"
version = "1.1.4+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
dependencies = [
"indexmap 2.14.0",
"serde_core",
@ -8723,9 +8852,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow 1.0.4",
]
@ -8857,9 +8986,9 @@ dependencies = [
[[package]]
name = "tray-icon"
version = "0.24.1"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e"
dependencies = [
"crossbeam-channel",
"dirs",
@ -8894,6 +9023,27 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.7",
"rustls 0.22.4",
"rustls-pki-types",
"sha1 0.10.7",
"thiserror 1.0.69",
"url",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
@ -9120,6 +9270,12 @@ dependencies = [
"url",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@ -10210,8 +10366,6 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "wry"
version = "0.55.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
dependencies = [
"base64 0.22.1",
"block2",
@ -10251,6 +10405,26 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "ws_stream_tungstenite"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a198f414f083fb19fcc1bffcb0fa0cf46d33ccfa229adf248cac12c180e91609"
dependencies = [
"async-tungstenite",
"async_io_stream",
"bitflags 2.13.1",
"futures-core",
"futures-io",
"futures-sink",
"futures-util",
"pharos",
"rustc_version",
"tokio",
"tracing",
"tungstenite 0.21.0",
]
[[package]]
name = "wyz"
version = "0.5.1"

View File

@ -1,12 +1,24 @@
[workspace]
resolver = "2"
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli"]
exclude = ["vendor/ctor"]
exclude = ["vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/wry"]
[patch.crates-io]
# Tauri 2.11 uses ctor 0.8, which excludes Rust's win7 vendor. This vendors
# upstream rust-ctor#443 until tauri-utils moves to ctor 1.x.
ctor = { path = "vendor/ctor" }
# rumqttc 0.24 hardcodes MQTT 3.1.1; this patch adds MQTT 3.1 CONNECT encoding.
rumqttc = { path = "vendor/rumqttc" }
# Keep two small upstream crates on Win7-compatible API calls. Their current
# Windows bindings otherwise import COMBASE or WinRT APIs unavailable on Win7.
dirs-sys = { path = "vendor/dirs-sys" }
pageant = { path = "vendor/pageant" }
# Wry 0.55 probes and creates WebView2 with a null browser folder. Pass the
# bundled Fixed Runtime path explicitly so Windows 7 does not fall back to an
# unavailable system Runtime. The vendored copy also intercepts the WebView2
# F6 "Focus Next Pane" accelerator to prevent a black screen on frameless
# Overlay-titlebar windows (see vendor/wry/src/webview2/mod.rs).
wry = { path = "vendor/wry" }
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }

View File

@ -16,8 +16,8 @@ export CONFIRM
node_modules/.modules.yaml: package.json pnpm-lock.yaml
$(PNPM) install --frozen-lockfile
docs/node_modules/.modules.yaml: docs/package.json docs/pnpm-lock.yaml
cd docs && $(PNPM) install --frozen-lockfile --ignore-workspace
docs/node_modules/.modules.yaml: docs/package.json docs/pnpm-lock.yaml docs/pnpm-workspace.yaml $(wildcard docs/patches/*.patch)
cd docs && $(PNPM) install --frozen-lockfile
help:
@printf '%s\n' 'DBX development targets:'
@ -59,7 +59,7 @@ install:
$(PNPM) install --frozen-lockfile
docs-install:
cd docs && $(PNPM) install --frozen-lockfile --ignore-workspace
cd docs && $(PNPM) install --frozen-lockfile
check-tauri-dev-port:
@if lsof -nP -iTCP:$(TAURI_DEV_PORT) -sTCP:LISTEN >/dev/null 2>&1; then \

View File

@ -1,15 +1,12 @@
[![MCP Toplist](https://mcptoplist.com/badge/io.github.t8y2%2Fdbx.svg)](https://mcptoplist.com/server/io.github.t8y2%2Fdbx)
<div align="center">
<p style="font-size: 18px; white-space: nowrap;"><strong>70+ databases in 20 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
<p>
<img src="https://dl.dbxio.com/assets/readme-hero-20260707.png" alt="DBX screenshot" width="820" />
<img src="https://dl.dbxio.com/assets/readme-hero-20260806.png" alt="DBX screenshot" width="820" />
</p>
<p>
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/github/downloads/t8y2/dbx/total?style=for-the-badge&color=blue" /></a>
<a href="https://github.com/t8y2/dbx/graphs/contributors"><img src="https://img.shields.io/github/contributors/t8y2/dbx?style=for-the-badge" /></a>
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=&group_code=1087880322"><img src="https://img.shields.io/badge/QQ_群-1087880322-EB1923?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iODYiIHdpZHRoPSI4NiIgdmlld0JveD0iMCAwIDEyMCAxNDUiPjxwYXRoIGZpbGw9IiNmYWFiMDciIGQ9Ik02MC41MDMgMTQyLjIzN2MtMTIuNTMzIDAtMjQuMDM4LTQuMTk1LTMxLjQ0NS0xMC40Ni0zLjc2MiAxLjEyNC04LjU3NCAyLjkzMi0xMS42MSA1LjE3NS0yLjYgMS45MTgtMi4yNzUgMy44NzQtMS44MDcgNC42NjMgMi4wNTYgMy40NyAzNS4yNzMgMi4yMTYgNDQuODYyIDEuMTM2em0wIDBjMTIuNTM1IDAgMjQuMDM5LTQuMTk1IDMxLjQ0Ny0xMC40NiAzLjc2IDEuMTI0IDguNTczIDIuOTMyIDExLjYxIDUuMTc1IDIuNTk4IDEuOTE4IDIuMjc0IDMuODc0IDEuODA1IDQuNjYzLTIuMDU2IDMuNDctMzUuMjcyIDIuMjE2LTQ0Ljg2MiAxLjEzNnptMCAwIi8+PHBhdGggZD0iTTYwLjU3NiA2Ny4xMTljMjAuNjk4LS4xNCAzNy4yODYtNC4xNDcgNDIuOTA3LTUuNjgzIDEuMzQtLjM2NyAyLjA1Ni0xLjAyNCAyLjA1Ni0xLjAyNC4wMDUtLjE4OS4wODUtMy4zNy4wODUtNS4wMUMxMDUuNjI0IDI3Ljc2OCA5Mi41OC4wMDEgNjAuNSAwIDI4LjQyLjAwMSAxNS4zNzUgMjcuNzY5IDE1LjM3NSA1NS40MDFjMCAxLjY0Mi4wOCA0LjgyMi4wODYgNS4wMSAwIDAgLjU4My42MTUgMS42NS45MTMgNS4xOSAxLjQ0NCAyMi4wOSA1LjY1IDQzLjMxMiA1Ljc5NXptNTYuMjQ1IDIzLjAyYy0xLjI4My00LjEyOS0zLjAzNC04Ljk0NC00LjgwOC0xMy41NjggMCAwLTEuMDItLjEyNi0xLjUzNy4wMjMtMTUuOTEzIDQuNjIzLTM1LjIwMiA3LjU3LTQ5LjkgNy4zOTJoLS4xNTNjLTE0LjYxNi4xNzUtMzMuNzc0LTIuNzM3LTQ5LjYzNC03LjMxNS0uNjA2LS4xNzUtMS44MDItLjEtMS44MDItLjEtMS43NzQgNC42MjQtMy41MjUgOS40NC00LjgwOCAxMy41NjgtNi4xMTkgMTkuNjktNC4xMzYgMjcuODM4LTIuNjI3IDI4LjAyIDMuMjM5LjM5MiAxMi42MDYtMTQuODIxIDEyLjYwNi0xNC44MjEgMCAxNS40NTkgMTMuOTU3IDM5LjE5NSA0NS45MTggMzkuNDEzaC44NDhjMzEuOTYtLjIxOCA0NS45MTctMjMuOTU0IDQ1LjkxNy0zOS40MTMgMCAwIDkuMzY4IDE1LjIxMyAxMi42MDcgMTQuODIyIDEuNTA4LS4xODMgMy40OTEtOC4zMzItMi42MjctMjguMDIxIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTQ5LjA4NSA0MC44MjRjLTQuMzUyLjE5Ny04LjA3LTQuNzYtOC4zMDQtMTEuMDYzLS4yMzYtNi4zMDUgMy4wOTgtMTEuNTc2IDcuNDUtMTEuNzczIDQuMzQ3LS4xOTUgOC4wNjQgNC43NiA4LjMgMTEuMDY1LjIzOCA2LjMwNi0zLjA5NyAxMS41NzctNy40NDYgMTEuNzcxbTMxLjEzMy0xMS4wNjNjLS4yMzMgNi4zMDItMy45NTEgMTEuMjYtOC4zMDMgMTEuMDYzLTQuMzUtLjE5NS03LjY4NC01LjQ2NS03LjQ0Ni0xMS43Ny4yMzYtNi4zMDUgMy45NTItMTEuMjYgOC4zLTExLjA2NiA0LjM1Mi4xOTcgNy42ODYgNS40NjggNy40NDkgMTEuNzczIi8+PHBhdGggZmlsbD0iI2ZhYWIwNyIgZD0iTTg3Ljk1MiA0OS43MjVDODYuNzkgNDcuMTUgNzUuMDc3IDQ0LjI4IDYwLjU3OCA0NC4yOGgtLjE1NmMtMTQuNSAwLTI2LjIxMiAyLjg3LTI3LjM3NSA1LjQ0NmEuODYzLjg2MyAwIDAwLS4wODUuMzY3Ljg4Ljg4IDAgMDAuMTYuNDk2Yy45OCAxLjQyNyAxMy45ODUgOC40ODcgMjcuMyA4LjQ4N2guMTU2YzEzLjMxNCAwIDI2LjMxOS03LjA1OCAyNy4yOTktOC40ODdhLjg3My44NzMgMCAwMC4xNi0uNDk4Ljg1Ni44NTYgMCAwMC0uMDg1LS4zNjUiLz48cGF0aCBkPSJNNTQuNDM0IDI5Ljg1NGMuMTk5IDIuNDktMS4xNjcgNC43MDItMy4wNDYgNC45NDMtMS44ODMuMjQyLTMuNTY4LTEuNTgtMy43NjgtNC4wNy0uMTk3LTIuNDkyIDEuMTY3LTQuNzA0IDMuMDQzLTQuOTQ0IDEuODg2LS4yNDQgMy41NzQgMS41OCAzLjc3MSA0LjA3bTExLjk1Ni44MzNjLjM4NS0uNjg5IDMuMDA0LTQuMzEyIDguNDI3LTIuOTkzIDEuNDI1LjM0NyAyLjA4NC44NTcgMi4yMjMgMS4wNTcuMjA1LjI5Ni4yNjIuNzE4LjA1MyAxLjI4Ni0uNDEyIDEuMTI2LTEuMjYzIDEuMDk1LTEuNzM0Ljg3NS0uMzA1LS4xNDItNC4wODItMi42Ni03LjU2MiAxLjA5Ny0uMjQuMjU3LS42NjguMzQ2LTEuMDczLjA0LS40MDctLjMwOC0uNTc0LS45My0uMzM0LTEuMzYyIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYwLjU3NiA4My4wOGgtLjE1M2MtOS45OTYuMTItMjIuMTE2LTEuMjA0LTMzLjg1NC0zLjUxOC0xLjAwNCA1LjgxOC0xLjYxIDEzLjEzMi0xLjA5IDIxLjg1MyAxLjMxNiAyMi4wNDMgMTQuNDA3IDM1LjkgMzQuNjE0IDM2LjFoLjgyYzIwLjIwOC0uMiAzMy4yOTgtMTQuMDU3IDM0LjYxNi0zNi4xLjUyLTguNzIzLS4wODctMTYuMDM1LTEuMDkyLTIxLjg1NC0xMS43MzkgMi4zMTUtMjMuODYyIDMuNjQtMzMuODYgMy41MTgiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMzIuMTAyIDgxLjIzNXYyMS42OTNzOS45MzcgMi4wMDQgMTkuODkzLjYxNlY4My41MzVjLTYuMzA3LS4zNTctMTMuMTA5LTEuMTUyLTE5Ljg5My0yLjMiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMTA1LjUzOSA2MC40MTJzLTE5LjMzIDYuMTAyLTQ0Ljk2MyA2LjI3NWgtLjE1M2MtMjUuNTkxLS4xNzItNDQuODk2LTYuMjU1LTQ0Ljk2Mi02LjI3NUw4Ljk4NyA3Ni41N2MxNi4xOTMgNC44ODIgMzYuMjYxIDguMDI4IDUxLjQzNiA3Ljg0NWguMTUzYzE1LjE3NS4xODMgMzUuMjQyLTIuOTYzIDUxLjQzNy03Ljg0NXptMCAwIi8+PC9zdmc+" alt="Join QQ Group" /></a>
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-Join-07C160?style=for-the-badge&logo=wechat&logoColor=white" alt="Join WeChat Group" /></a>
<a href="https://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="Join Discord" /></a>
@ -48,6 +45,7 @@
<img src="https://img.shields.io/badge/JDBC-4B5563?logoColor=white" />
<img src="https://img.shields.io/badge/and%20more...-555555?logoColor=white" />
<a href="https://cnb.cool/dbxio.com/dbx"><img src="https://img.shields.io/badge/CNB-dbx-F76945?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAppJREFUOE9tk01rE1EUhs+5H5lJ0iQT3FRX2Yi4avoLkv4CWxBcNt11oZiuFKGmXQhFN6kuRESSgitXKf6A5g+I6cqFLiKCiptOkraZ5H4cuZOkH9oLw+UO8z7nvGfei3DFohoEQLAKCGWyGJCSbQZsD3ei7r+f45WATV6xmjdISyAlYLaDliveux+ti5ozwLAaFPhIlk6G6f18sxuO7l9bBiWbVoncDGCMWMl++PI/gJ7IohkmD0zkBxT5oRn5LTpN7ULqBByEtFywWvRyHz8Fx3dvN0UwuMPyR0uJFycddH7tKPHZRn7BjnwwkQcUud0HO/K7oOENcTYPRCEAFERmsCpyfZDZfpdnBosOUKGx13BC60QTIdjpmSzrEWDR+UagjsgMciLXAwfhc4MNpKfQtOPE6mVh0gEOaeTXtWXXEdADpJAR/GaZ/rrM9Us82wORPd5zHbRp7JVcyxR5PRP5LTPy6uo0HXIlG9awMgLuEUABkBYI7VLyxs+yyPW3+Nyg4zqoG+UV7TDRlMBbWA/D43u3qlbLGmgRWM2BjFxjQjWQGQBuu4bYYvrm14JMnQSXcjBcny9A5DXIiDJp9/8FWCXAAOaFUEfILTgIcttOvf+2NJnLdI0fBFUyskZKBGTEJEAmBuzr9LCSUDwGQAwwgMzu+m9/VWOAeZSsk+YP48C4li/sRok1nlBd5PogBjgxNw70PfHyqBADyEVXicasZXCVpxYiLfO+HxVZDHCVYwvu2ebPT7fOLNAm65AWLnEAzvsEcOi9/lPU1aACXDfOLdhD9kxNszGdweQGYstqXgItJwDFNxKvwrp57G8h0zVwldHuA0IFt8El83yIs2FSDcpgxDJpUUTwK7gTduN3AK5iG7ehc/E2/gUPD3q3eY4awwAAAABJRU5ErkJggg==" alt="CNB" /></a>
<a href="https://mcptoplist.com/server/io.github.t8y2%2Fdbx"><img src="https://mcptoplist.com/badge/io.github.t8y2%2Fdbx.svg" alt="MCP Toplist" /></a>
</p>
<p>
English | <a href="README.zh-CN.md">前往中文版本</a>
@ -92,7 +90,7 @@
### 70+ Databases, One Tool
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
### Query Editor
@ -443,6 +441,19 @@ DBX is free and open source, but ongoing maintenance, database compatibility tes
<a href="https://easysearch.cn" target="_blank">Visit Easysearch</a>
</td>
</tr>
<tr>
<td align="center" width="180">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
</a>
</td>
<td>
Atlas Cloud gives developers one unified API for 400+ AI models across chat, image, video, and audio.
</td>
<td align="center" width="140">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">Visit Atlas Cloud</a>
</td>
</tr>
</table>
- [Support DBX](https://my.feishu.cn/wiki/WMTkwdATDiiu4rk14JMcoyhTnoh) — voluntary donations via WeChat or Alipay

View File

@ -2,12 +2,11 @@
<p style="font-size: 18px; white-space: nowrap;"><strong>20 MB 驾驭 70+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
<p>
<img src="https://dl.dbxio.com/assets/readme-hero-20260707.png" alt="DBX 截图" width="820" />
<img src="https://dl.dbxio.com/assets/readme-hero-20260806.png" alt="DBX 截图" width="820" />
</p>
<p>
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/github/downloads/t8y2/dbx/total?style=for-the-badge&color=blue" /></a>
<a href="https://github.com/t8y2/dbx/graphs/contributors"><img src="https://img.shields.io/github/contributors/t8y2/dbx?style=for-the-badge" /></a>
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=&group_code=1087880322"><img src="https://img.shields.io/badge/QQ_群-1087880322-EB1923?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iODYiIHdpZHRoPSI4NiIgdmlld0JveD0iMCAwIDEyMCAxNDUiPjxwYXRoIGZpbGw9IiNmYWFiMDciIGQ9Ik02MC41MDMgMTQyLjIzN2MtMTIuNTMzIDAtMjQuMDM4LTQuMTk1LTMxLjQ0NS0xMC40Ni0zLjc2MiAxLjEyNC04LjU3NCAyLjkzMi0xMS42MSA1LjE3NS0yLjYgMS45MTgtMi4yNzUgMy44NzQtMS44MDcgNC42NjMgMi4wNTYgMy40NyAzNS4yNzMgMi4yMTYgNDQuODYyIDEuMTM2em0wIDBjMTIuNTM1IDAgMjQuMDM5LTQuMTk1IDMxLjQ0Ny0xMC40NiAzLjc2IDEuMTI0IDguNTczIDIuOTMyIDExLjYxIDUuMTc1IDIuNTk4IDEuOTE4IDIuMjc0IDMuODc0IDEuODA1IDQuNjYzLTIuMDU2IDMuNDctMzUuMjcyIDIuMjE2LTQ0Ljg2MiAxLjEzNnptMCAwIi8+PHBhdGggZD0iTTYwLjU3NiA2Ny4xMTljMjAuNjk4LS4xNCAzNy4yODYtNC4xNDcgNDIuOTA3LTUuNjgzIDEuMzQtLjM2NyAyLjA1Ni0xLjAyNCAyLjA1Ni0xLjAyNC4wMDUtLjE4OS4wODUtMy4zNy4wODUtNS4wMUMxMDUuNjI0IDI3Ljc2OCA5Mi41OC4wMDEgNjAuNSAwIDI4LjQyLjAwMSAxNS4zNzUgMjcuNzY5IDE1LjM3NSA1NS40MDFjMCAxLjY0Mi4wOCA0LjgyMi4wODYgNS4wMSAwIDAgLjU4My42MTUgMS42NS45MTMgNS4xOSAxLjQ0NCAyMi4wOSA1LjY1IDQzLjMxMiA1Ljc5NXptNTYuMjQ1IDIzLjAyYy0xLjI4My00LjEyOS0zLjAzNC04Ljk0NC00LjgwOC0xMy41NjggMCAwLTEuMDItLjEyNi0xLjUzNy4wMjMtMTUuOTEzIDQuNjIzLTM1LjIwMiA3LjU3LTQ5LjkgNy4zOTJoLS4xNTNjLTE0LjYxNi4xNzUtMzMuNzc0LTIuNzM3LTQ5LjYzNC03LjMxNS0uNjA2LS4xNzUtMS44MDItLjEtMS44MDItLjEtMS43NzQgNC42MjQtMy41MjUgOS40NC00LjgwOCAxMy41NjgtNi4xMTkgMTkuNjktNC4xMzYgMjcuODM4LTIuNjI3IDI4LjAyIDMuMjM5LjM5MiAxMi42MDYtMTQuODIxIDEyLjYwNi0xNC44MjEgMCAxNS40NTkgMTMuOTU3IDM5LjE5NSA0NS45MTggMzkuNDEzaC44NDhjMzEuOTYtLjIxOCA0NS45MTctMjMuOTU0IDQ0LjkxNy0zOS40MTMgMCAwIDkuMzY4IDE1LjIxMyAxMi42MDcgMTQuODIyIDEuNTA4LS4xODMgMy40OTEtOC4zMzItMi42MjctMjguMDIxIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTQ5LjA4NSA0MC44MjRjLTQuMzUyLjE5Ny04LjA3LTQuNzYtOC4zMDQtMTEuMDYzLS4yMzYtNi4zMDUgMy4wOTgtMTEuNTc2IDcuNDUtMTEuNzczIDQuMzQ3LS4xOTUgOC4wNjQgNC43NiA4LjMgMTEuMDY1LjIzOCA2LjMwNi0zLjA5NyAxMS41NzctNy40NDYgMTEuNzcxbTMxLjEzMy0xMS4wNjNjLS4yMzMgNi4zMDItMy45NTEgMTEuMjYtOC4zMDMgMTEuMDYzLTQuMzUtLjE5NS03LjY4NC01LjQ2NS03LjQ0Ni0xMS43Ny4yMzYtNi4zMDUgMy45NTItMTEuMjYgOC4zLTExLjA2NiA0LjM1Mi4xOTcgNy42ODYgNS40NjggNy40NDkgMTEuNzczIi8+PHBhdGggZmlsbD0iI2ZhYWIwNyIgZD0iTTg3Ljk1MiA0OS43MjVDODYuNzkgNDcuMTUgNzUuMDc3IDQ0LjI4IDYwLjU3OCA0NC4yOGgtLjE1NmMtMTQuNSAwLTI2LjIxMiAyLjg3LTI3LjM3NSA1LjQ0NmEuODYzLjg2MyAwIDAwLS4wODUuMzY3Ljg4Ljg4IDAgMDAuMTYuNDk2Yy45OCAxLjQyNyAxMy45ODUgOC40ODcgMjcuMyA4LjQ4N2guMTU2YzEzLjMxNCAwIDI2LjMxOS03LjA1OCAyNy4yOTktOC40ODdhLjg3My44NzMgMCAwMC4xNi0uNDk4Ljg1Ni44NTYgMCAwMC0uMDg1LS4zNjUiLz48cGF0aCBkPSJNNTQuNDM0IDI5Ljg1NGMuMTk5IDIuNDktMS4xNjcgNC43MDItMy4wNDYgNC45NDMtMS44ODMuMjQyLTMuNTY4LTEuNTgtMy43NjgtNC4wNy0uMTk3LTIuNDkyIDEuMTY3LTQuNzA0IDMuMDQzLTQuOTQ0IDEuODg2LS4yNDQgMy41NzQgMS41OCAzLjc3MSA0LjA3bTExLjk1Ni44MzNjLjM4NS0uNjg5IDMuMDA0LTQuMzEyIDguNDI3LTIuOTkzIDEuNDI1LjM0NyAyLjA4NC44NTcgMi4yMjMgMS4wNTcuMjA1LjI5Ni4yNjIuNzE4LjA1MyAxLjI4Ni0uNDEyIDEuMTI2LTEuMjYzIDEuMDk1LTEuNzM0Ljg3NS0uMzA1LS4xNDItNC4wODItMi42Ni03LjU2MiAxLjA5Ny0uMjQuMjU3LS42NjguMzQ2LTEuMDczLjA0LS40MDctLjMwOC0uNTc0LS45My0uMzM0LTEuMzYyIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYwLjU3NiA4My4wOGgtLjE1M2MtOS45OTYuMTItMjIuMTE2LTEuMjA0LTMzLjg1NC0zLjUxOC0xLjAwNCA1LjgxOC0xLjYxIDEzLjEzMi0xLjA5IDIxLjg1MyAxLjMxNiAyMi4wNDMgMTQuNDA3IDM1LjkgMzQuNjE0IDM2LjFoLjgyYzIwLjIwOC0uMiAzMy4yOTgtMTQuMDU3IDM0LjYxNi0zNi4xLjUyLTguNzIzLS4wODctMTYuMDM1LTEuMDkyLTIxLjg1NC0xMS43MzkgMi4zMTUtMjMuODYyIDMuNjQtMzMuODYgMy41MTgiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMzIuMTAyIDgxLjIzNXYyMS42OTNzOS45MzcgMi4wMDQgMTkuODkzLjYxNlY4My41MzVjLTYuMzA3LS4zNTctMTMuMTA5LTEuMTUyLTE5Ljg5My0yLjMiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMTA1LjUzOSA2MC40MTJzLTE5LjMzIDYuMTAyLTQ0Ljk2MyA2LjI3NWgtLjE1M2MtMjUuNTkxLS4xNzItNDQuODk2LTYuMjU1LTQ0Ljk2Mi02LjI3NUw4Ljk4NyA3Ni41N2MxNi4xOTMgNC44ODIgMzYuMjYxIDguMDI4IDUxLjQzNiA3Ljg0NWguMTUzYzE1LjE3NS4xODMgMzUuMjQyLTIuOTYzIDUxLjQzNy03Ljg0NXptMCAwIi8+PC9zdmc+" alt="加入 QQ 群" /></a>
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信交流群-点击加入-07C160?style=for-the-badge&logo=wechat&logoColor=white" alt="加入微信交流群" /></a>
<a href="https://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="加入 Discord" /></a>
@ -46,6 +45,7 @@
<img src="https://img.shields.io/badge/JDBC-4B5563?logoColor=white" />
<img src="https://img.shields.io/badge/and%20more...-555555?logoColor=white" />
<a href="https://cnb.cool/dbxio.com/dbx"><img src="https://img.shields.io/badge/CNB-dbx-F76945?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAppJREFUOE9tk01rE1EUhs+5H5lJ0iQT3FRX2Yi4avoLkv4CWxBcNt11oZiuFKGmXQhFN6kuRESSgitXKf6A5g+I6cqFLiKCiptOkraZ5H4cuZOkH9oLw+UO8z7nvGfei3DFohoEQLAKCGWyGJCSbQZsD3ei7r+f45WATV6xmjdISyAlYLaDliveux+ti5ozwLAaFPhIlk6G6f18sxuO7l9bBiWbVoncDGCMWMl++PI/gJ7IohkmD0zkBxT5oRn5LTpN7ULqBByEtFywWvRyHz8Fx3dvN0UwuMPyR0uJFycddH7tKPHZRn7BjnwwkQcUud0HO/K7oOENcTYPRCEAFERmsCpyfZDZfpdnBosOUKGx13BC60QTIdjpmSzrEWDR+UagjsgMciLXAwfhc4MNpKfQtOPE6mVh0gEOaeTXtWXXEdADpJAR/GaZ/rrM9Us82wORPd5zHbRp7JVcyxR5PRP5LTPy6uo0HXIlG9awMgLuEUABkBYI7VLyxs+yyPW3+Nyg4zqoG+UV7TDRlMBbWA/D43u3qlbLGmgRWM2BjFxjQjWQGQBuu4bYYvrm14JMnQSXcjBcny9A5DXIiDJp9/8FWCXAAOaFUEfILTgIcttOvf+2NJnLdI0fBFUyskZKBGTEJEAmBuzr9LCSUDwGQAwwgMzu+m9/VWOAeZSsk+YP48C4li/sRok1nlBd5PogBjgxNw70PfHyqBADyEVXicasZXCVpxYiLfO+HxVZDHCVYwvu2ebPT7fOLNAm65AWLnEAzvsEcOi9/lPU1aACXDfOLdhD9kxNszGdweQGYstqXgItJwDFNxKvwrp57G8h0zVwldHuA0IFt8El83yIs2FSDcpgxDJpUUTwK7gTduN3AK5iG7ehc/E2/gUPD3q3eY4awwAAAABJRU5ErkJggg==" alt="CNB" /></a>
<a href="https://mcptoplist.com/server/io.github.t8y2%2Fdbx"><img src="https://mcptoplist.com/badge/io.github.t8y2%2Fdbx.svg" alt="MCP Toplist" /></a>
</p>
<p>
<a href="README.md">English</a> | 简体中文
@ -90,7 +90,7 @@
### 70+ 种数据库,一个工具搞定
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
### 查询编辑器
@ -225,11 +225,15 @@ flatpak install flatpark com.dbxio.dbx
之后通过常规的 `flatpak update` 即可获取更新。详见 [FlatPark 上的 DBX 页面](https://flatpark.org/apps/com.dbxio.dbx/)。
**星火应用商店 / AmberPM (Linux)**
**Spark Store 星火应用商店(Linux)**
通过[星火应用商店](https://spk-resolv.spark-app.store/?spk=spk://store/development/dbx)一键安装,后续可直接在商店客户端中获取更新。
银河麒麟 V10、统信 UOS 等系统推荐选择 **APM 版本**以减少发行版依赖差异导致的安装或启动问题。APM 在兼容环境中运行 DBX如果为 Agent/JDBC 驱动选择宿主机 Java需要在路径前添加 `/host`,例如将 `/usr/bin/java` 填写为 `/host/usr/bin/java`
<a href="https://spk-resolv.spark-app.store/?spk=spk://store/development/dbx" target="_blank" rel="noopener noreferrer">
<img src="https://spk-json.spark-app.store/install-from-spark-store.png" width="200"/>
</a>
银河麒麟 V10、统信 UOS 等系统推荐选择 **APMAmberPM版本**以减少发行版依赖差异导致的安装或启动问题。APM 在兼容环境中运行 DBX如果为 Agent/JDBC 驱动选择宿主机 Java需要在路径前添加 `/host`,例如将 `/usr/bin/java` 填写为 `/host/usr/bin/java`
## 自托管 (Docker)
@ -439,6 +443,19 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
<a href="https://easysearch.cn" target="_blank">访问 Easysearch</a>
</td>
</tr>
<tr>
<td align="center" width="180">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
</a>
</td>
<td>
Atlas Cloud 为开发者提供统一的多模态 AI API可通过一个接口访问聊天、图像、视频和音频等 400+ 模型。
</td>
<td align="center" width="140">
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">访问 Atlas Cloud</a>
</td>
</tr>
</table>
- [支持 DBX](https://my.feishu.cn/wiki/WMTkwdATDiiu4rk14JMcoyhTnoh) —— 通过微信或支付宝自愿捐助

View File

@ -13,7 +13,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
| access | Microsoft Access | UCanAccess |
| dameng | 达梦 DM8 | DM JDBC |
| kingbase | 人大金仓 KingbaseES | gokb Go native agent |
| vastbase | Vastbase | Vastbase JDBC |
| vastbase | Vastbase | openGauss Go native agent |
| uxdb | UXDB | UXDB JDBC |
| goldendb | GoldenDB | MySQL Connector/J |
| databend | Databend | Databend JDBC |
@ -33,12 +33,12 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
| hive | Apache Hive | Hive JDBC |
| db2 | IBM DB2 | DB2 JDBC |
| informix | IBM Informix | Informix JDBC |
| neo4j | Neo4j | Neo4j JDBC |
| cassandra | Apache Cassandra | Cassandra JDBC |
| neo4j | Neo4j | Official Neo4j Go Driver native agent |
| cassandra | Apache Cassandra 2.1+ | Apache cassandra-gocql-driver native agent |
| bigquery | Google BigQuery | BigQuery JDBC |
| kylin | Apache Kylin | Kylin JDBC |
| sundb | SunDB | SunDB JDBC |
| tdengine | TDengine | taos-jdbcdriver (WebSocket, REST fallback) |
| tdengine | TDengine 2.4+ | taos-connector-rust native WebSocket agent |
| yashandb | 崖山 YashanDB | YashanDB JDBC |
| xugu | 虚谷 XuguDB | XuguDB Go native agent |
| iotdb | Apache IoTDB | IoTDB JDBC |
@ -49,7 +49,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
## Multi-JRE Support
Most Java agents target JRE 21. Native agents, such as `duckdb`, `oracle`, `kingbase`, `xugu`, and `rabbitmq`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
## JDBC Connection Pooling
@ -75,7 +75,7 @@ Set `DBX_AGENT_JDBC_POOL_ENABLED=false` for a runtime-level compatibility fallba
For new agents, prefer a **native (Go or Rust) driver** over a Java/JDBC agent whenever a mature, license-compatible native driver is available. Native agents ship as a single self-contained executable with no JRE, which significantly reduces memory footprint and startup time — the JVM baseline that every Java agent pays even when idle is avoided entirely.
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/duckdb`, `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/xugu`, and `drivers/rabbitmq` (amqp091-go) as reference implementations. No JRE download or management is needed.
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/cassandra-go` (Apache cassandra-gocql-driver), `drivers/duckdb`, `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/vastbase-go` (openGauss connector), `drivers/tdengine` (taos-connector-rust), `drivers/xugu`, and `drivers/rabbitmq` (amqp091-go) as reference implementations. No JRE download or management is needed.
- **Java/JDBC** — the default fallback when only a JDBC driver exists for the database, or when the native driver is immature or unmaintained. Most agents still fall in this category.
Native agents implement the same JSON-RPC contract and `versions.json` registration as Java agents; they ship an `agent` executable instead of `agent.jar`. If both native and Java source implementations exist for the same database, publish only the native artifact unless the Java variant has a separately registered compatibility profile, such as `oracle-legacy` / `oracle-10g`.
@ -87,12 +87,15 @@ Requires JDK 21 (Gradle toolchain auto-downloads if needed).
```bash
./gradlew shadowJar
(cd drivers/oracle-go && go build -o agent .)
(cd drivers/cassandra-go && go build -o agent .)
(cd drivers/kingbase-go && go build -o agent .)
(cd drivers/vastbase-go && go build -o agent .)
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
(cd drivers/xugu && go build -o agent .)
(cd drivers/rabbitmq && go build -o agent .)
```
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/xugu`, and `drivers/rabbitmq`.
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/cassandra-go`, `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/vastbase-go`, `drivers/tdengine`, `drivers/xugu`, and `drivers/rabbitmq`.
### Local DBX Runtime Test
@ -106,14 +109,14 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
Restart DBX or disconnect and reconnect the database so the new agent process loads the replacement JAR.
Native agents such as `oracle`, `kingbase`, `xugu`, and `rabbitmq` use the `agent` executable in the driver directory instead of `agent.jar`.
Native agents such as `cassandra`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`.
## Versioning
Agent module versions are tracked in [`versions.json`](versions.json).
- **Changing an existing driver** — do not edit `versions.json` manually. The release CI diffs each `drivers/<module>/` directory against the previous tag and auto-bumps the patch version for every changed module (see [`bump-agent-versions.mjs`](../.github/scripts/bump-agent-versions.mjs)). A change to the shared `agents/common` runtime bumps every module that packages it.
- **Adding a new driver** — add an entry to `versions.json`, e.g. `"rabbitmq": "0.1.0"`. The CI only bumps keys already present in the file, so a new module is invisible to versioning until it is registered here. In the same change, also add the module to `settings.gradle` and the support table above — `versions.json` keys must match the agent modules declared in `settings.gradle`, excluding the infrastructure modules `common` and `test-support`.
- **Adding a new driver** — add an entry to `versions.json`, e.g. `"rabbitmq": "0.1.0"`. The CI only bumps keys already present in the file, so a new module is invisible to versioning until it is registered here. Java modules must also be added to `settings.gradle`; native modules must be registered in the release version script and workflow. Update the support table in the same change.
## Development

View File

@ -13,7 +13,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
| access | Microsoft Access | UCanAccess |
| dameng | 达梦 DM8 | DM JDBC |
| kingbase | 人大金仓 KingbaseES | gokb Go 原生 agent |
| vastbase | Vastbase | Vastbase JDBC |
| vastbase | Vastbase | openGauss Go 原生 agent |
| uxdb | 优炫 UXDB | UXDB JDBC |
| goldendb | GoldenDB | MySQL Connector/J |
| databend | Databend | Databend JDBC |
@ -33,12 +33,12 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
| hive | Apache Hive | Hive JDBC |
| db2 | IBM DB2 | DB2 JDBC |
| informix | IBM Informix | Informix JDBC |
| neo4j | Neo4j | Neo4j JDBC |
| cassandra | Apache Cassandra | Cassandra JDBC |
| neo4j | Neo4j | 官方 Neo4j Go Driver 原生 Agent |
| cassandra | Apache Cassandra 2.1+ | Apache cassandra-gocql-driver 原生 Agent |
| bigquery | Google BigQuery | BigQuery JDBC |
| kylin | Apache Kylin | Kylin JDBC |
| sundb | SunDB | SunDB JDBC |
| tdengine | TDengine | taos-jdbcdriverWebSocketREST 兜底) |
| tdengine | TDengine 2.4+ | taos-connector-rust 原生 WebSocket agent |
| yashandb | 崖山 YashanDB | YashanDB JDBC |
| xugu | 虚谷 XuguDB | XuguDB Go 原生 agent |
| iotdb | Apache IoTDB | IoTDB JDBC |
@ -49,7 +49,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
## 多 JRE 支持
多数 Java agent 以 JRE 21 为目标。原生 agent`oracle`、`kingbase`、`xugu` 和 `rabbitmq`)不需要 JRE。对 Java agentDBX 会自动下载并管理 JRE 21 安装。
多数 Java agent 以 JRE 21 为目标。原生 agent`cassandra`、`duckdb`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq`)不需要 JRE。对 Java agentDBX 会自动下载并管理 JRE 21 安装。
## JDBC 连接池
@ -75,7 +75,7 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
对于新 agent只要存在成熟、许可证兼容的原生驱动优先选择**原生Go 或 Rust驱动**而非 Java/JDBC agent。原生 agent 以单一自包含可执行文件发布,无需 JRE可显著降低内存占用和启动时间 —— 完全避开 Java agent 即便空闲也要付出的 JVM 基线开销。
- **原生Go/Rust** —— 存在可用原生驱动时首选。参考 `drivers/oracle-go`go-ora、`drivers/kingbase-go`gokb、`drivers/xugu` 和 `drivers/rabbitmq`amqp091-go。无需 JRE 下载与管理。
- **原生Go/Rust** —— 存在可用原生驱动时首选。参考 `drivers/cassandra-go`Apache cassandra-gocql-driver、`drivers/duckdb`、`drivers/oracle-go`go-ora、`drivers/kingbase-go`gokb)、`drivers/vastbase-go`openGauss connector、`drivers/tdengine`taos-connector-rust)、`drivers/xugu` 和 `drivers/rabbitmq`amqp091-go。无需 JRE 下载与管理。
- **Java/JDBC** —— 当某数据库只有 JDBC 驱动,或原生驱动不成熟、缺乏维护时的默认兜底方案。多数 agent 仍属此类。
原生 agent 实现与 Java agent 相同的 JSON-RPC 契约和 `versions.json` 登记;它发布的是 `agent` 可执行文件而非 `agent.jar`。若同一数据库同时保留原生和 Java 源码实现,默认只发布原生产物;只有 Java 变体以独立兼容配置登记时才同时发布,例如 `oracle-legacy` / `oracle-10g`
@ -87,12 +87,15 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
```bash
./gradlew shadowJar
(cd drivers/oracle-go && go build -o agent .)
(cd drivers/cassandra-go && go build -o agent .)
(cd drivers/kingbase-go && go build -o agent .)
(cd drivers/vastbase-go && go build -o agent .)
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
(cd drivers/xugu && go build -o agent .)
(cd drivers/rabbitmq && go build -o agent .)
```
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/oracle-go`、`drivers/kingbase-go`、`drivers/xugu` 和 `drivers/rabbitmq` 构建。
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/cassandra-go`、`drivers/duckdb`、`drivers/oracle-go`、`drivers/kingbase-go`、`drivers/vastbase-go`、`drivers/tdengine`、`drivers/xugu` 和 `drivers/rabbitmq` 构建。
### 本地 DBX 运行时测试
@ -106,14 +109,14 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
重启 DBX 或断开重连数据库,使新 agent 进程加载替换后的 JAR。
`oracle`、`kingbase`、`xugu` 和 `rabbitmq` 等原生 agent 使用驱动目录下的 `agent` 可执行文件而非 `agent.jar`。
`cassandra`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
## 版本管理
Agent 模块的版本记录在 [`versions.json`](versions.json) 中,遵循以下规则:
- **修改现有驱动**:无需手动编辑 `versions.json`。发版 CI 会把每个 `drivers/<module>/` 目录与上一个 tag 做对比,对有变更的模块自动 bump patch 版本号(见 [`bump-agent-versions.mjs`](../.github/scripts/bump-agent-versions.mjs))。若改动的是共享运行时 `agents/common`,所有依赖它的模块会一并 bump。
- **新增驱动**:在 `versions.json` 中新增一行,例如 `"rabbitmq": "0.1.0"`。CI 只 bump 文件里已存在的 key所以新模块在登记到这里之前对版本管理完全不可见。同一次改动中,还要把模块加进 `settings.gradle` 并更新上方的支持表 —— `versions.json` 的 key 必须与 `settings.gradle` 声明的 agent 模块一致(不含 `common`、`test-support` 这类基础设施模块)
- **新增驱动**:在 `versions.json` 中新增一行,例如 `"rabbitmq": "0.1.0"`。CI 只 bump 文件里已存在的 key所以新模块在登记到这里之前对版本管理完全不可见。Java 模块还要加入 `settings.gradle`;原生模块要在发版版本脚本与 workflow 中登记,并同步更新上方支持表
## 开发

View File

@ -5,11 +5,11 @@ plugins {
def infrastructureProjects = ['common', 'test-support'] as Set
def legacyStandaloneProjects = ['mongodb', 'kafka', 'rocketmq'] as Set
def pooledJdbcProjects = [
'access', 'bigquery', 'cassandra', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo', 'hive',
'informix', 'iotdb', 'iris', 'kylin', 'neo4j', 'oceanbase-oracle', 'oscar', 'saphana',
'snowflake', 'spark', 'sqlserver-legacy', 'sundb', 'tdengine', 'teradata', 'trino', 'uxdb',
'vastbase', 'vertica', 'yashandb'
'informix', 'iotdb', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
'snowflake', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
'vertica', 'yashandb'
] as Set
def agentProjects = subprojects.findAll { !infrastructureProjects.contains(it.name) }
def jdbcAgentProjects = agentProjects.findAll { !legacyStandaloneProjects.contains(it.name) }

View File

@ -270,10 +270,18 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
poolRegistry = registry;
}
public boolean supportsConnectionPooling() {
return true;
}
final synchronized boolean usesConnectionPool() {
return poolRegistry != null;
}
final synchronized boolean hasActivePooledLeases() {
return poolRegistry != null && poolIdentity != null && poolRegistry.hasActiveLeases(poolIdentity);
}
final synchronized boolean quarantinePooledConnection() {
pooledConnectionPoisoned = true;
return requestActive && pooledLease != null && pooledLease.quarantine();
@ -531,7 +539,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
return this::resultValue;
}
private Connection openInitializedConnection(ConnectParams params) throws Exception {
protected final Connection openInitializedConnection(ConnectParams params) throws Exception {
Connection opened = openConnection(params);
try {
afterPhysicalConnect(params, opened);

View File

@ -48,6 +48,9 @@ public final class AgentProtocol {
public static final String MONGO_METHOD_LIST_DATABASES = "list_databases";
public static final String MONGO_METHOD_LIST_COLLECTIONS = "list_collections";
public static final String MONGO_METHOD_FIND_DOCUMENTS = "find_documents";
public static final String MONGO_METHOD_FIND_ONE = "find_one";
public static final String MONGO_METHOD_EXPLAIN_FIND = "explain_find";
public static final String MONGO_METHOD_AGGREGATE_DOCUMENTS = "aggregate_documents";
/**
* MongoDB read path that returns documents as relaxed Extended JSON for transfer.
*/
@ -55,8 +58,10 @@ public final class AgentProtocol {
public static final String MONGO_METHOD_COUNT_DOCUMENTS = "count_documents";
public static final String MONGO_METHOD_SERVER_VERSION = "server_version";
public static final String MONGO_METHOD_CREATE_INDEX = "create_index";
public static final String MONGO_METHOD_CREATE_USER = "create_user";
public static final String MONGO_METHOD_DROP_INDEXES = "drop_indexes";
public static final String MONGO_METHOD_DROP_COLLECTION = "drop_collection";
public static final String MONGO_METHOD_DROP_DATABASE = "drop_database";
public static final String MONGO_METHOD_INSERT_DOCUMENT = "insert_document";
public static final String MONGO_METHOD_UPDATE_DOCUMENT = "update_document";
public static final String MONGO_METHOD_UPDATE_DOCUMENTS = "update_documents";
@ -112,7 +117,9 @@ public final class AgentProtocol {
public static final String CAPABILITY_ETCD_WATCH = "etcd_watch";
public static final String CAPABILITY_ETCD_LEASE = "etcd_lease";
public static final String CAPABILITY_ETCD_AUTH = "etcd_auth";
public static final String CAPABILITY_MONGO_DROP_DATABASE = "mongo_drop_database";
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
CAPABILITY_CONNECT,
@ -142,11 +149,16 @@ public final class AgentProtocol {
CAPABILITY_ETCD_DEFRAG,
CAPABILITY_ETCD_WATCH,
CAPABILITY_ETCD_LEASE,
CAPABILITY_ETCD_AUTH
CAPABILITY_ETCD_AUTH,
CAPABILITY_MONGO_DROP_DATABASE
));
public static final List<String> MULTI_SESSION_CAPABILITIES;
public static final List<String> MULTI_SESSION_ALL_CAPABILITIES;
public static final List<String> MONGO_LEGACY_CAPABILITIES;
public static final List<String> MONGO_LEGACY_MULTI_SESSION_CAPABILITIES;
public static final List<String> MULTI_SESSION_JDBC_CAPABILITIES;
public static final List<String> MULTI_SESSION_JDBC_ALL_CAPABILITIES;
public static final List<String> COMMON_METHODS = Collections.unmodifiableList(Arrays.asList(
METHOD_HANDSHAKE,
@ -194,6 +206,22 @@ public final class AgentProtocol {
allCapabilities.add(CAPABILITY_MULTI_SESSION);
MULTI_SESSION_ALL_CAPABILITIES = Collections.unmodifiableList(allCapabilities);
List<String> mongoCapabilities = new java.util.ArrayList<>(CAPABILITIES);
mongoCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE);
MONGO_LEGACY_CAPABILITIES = Collections.unmodifiableList(mongoCapabilities);
List<String> mongoMultiSessionCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE);
MONGO_LEGACY_MULTI_SESSION_CAPABILITIES = Collections.unmodifiableList(mongoMultiSessionCapabilities);
List<String> jdbcCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
jdbcCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
MULTI_SESSION_JDBC_CAPABILITIES = Collections.unmodifiableList(jdbcCapabilities);
List<String> jdbcAllCapabilities = new java.util.ArrayList<>(MULTI_SESSION_ALL_CAPABILITIES);
jdbcAllCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
MULTI_SESSION_JDBC_ALL_CAPABILITIES = Collections.unmodifiableList(jdbcAllCapabilities);
List<String> methods = new java.util.ArrayList<>(COMMON_METHODS);
int insertAt = methods.indexOf(METHOD_CONNECT) + 1;
methods.addAll(insertAt, Arrays.asList(
@ -209,12 +237,17 @@ public final class AgentProtocol {
MONGO_METHOD_LIST_DATABASES,
MONGO_METHOD_LIST_COLLECTIONS,
MONGO_METHOD_FIND_DOCUMENTS,
MONGO_METHOD_FIND_ONE,
MONGO_METHOD_EXPLAIN_FIND,
MONGO_METHOD_AGGREGATE_DOCUMENTS,
MONGO_METHOD_FIND_DOCUMENTS_EXTENDED_JSON,
MONGO_METHOD_COUNT_DOCUMENTS,
MONGO_METHOD_SERVER_VERSION,
MONGO_METHOD_CREATE_INDEX,
MONGO_METHOD_CREATE_USER,
MONGO_METHOD_DROP_INDEXES,
MONGO_METHOD_DROP_COLLECTION,
MONGO_METHOD_DROP_DATABASE,
MONGO_METHOD_INSERT_DOCUMENT,
MONGO_METHOD_UPDATE_DOCUMENT,
MONGO_METHOD_UPDATE_DOCUMENTS,
@ -270,6 +303,30 @@ public final class AgentProtocol {
);
}
public static HandshakeResult mongoLegacyHandshakeResult() {
return new HandshakeResult(PROTOCOL_VERSION, PROTOCOL_VERSION, MONGO_LEGACY_CAPABILITIES);
}
public static HandshakeResult mongoLegacyMultiSessionHandshakeResult() {
return new HandshakeResult(
MULTI_SESSION_PROTOCOL_VERSION,
MULTI_SESSION_PROTOCOL_VERSION,
MONGO_LEGACY_MULTI_SESSION_CAPABILITIES
);
}
/**
* Handshake for pooled JDBC Agents that emit the v1 structured error contract.
* Generic/custom v2 handlers must continue using multiSessionHandshakeResult().
*/
public static HandshakeResult multiSessionJdbcHandshakeResult() {
return new HandshakeResult(
MULTI_SESSION_PROTOCOL_VERSION,
MULTI_SESSION_PROTOCOL_VERSION,
MULTI_SESSION_JDBC_CAPABILITIES
);
}
public static final class HandshakeResult {
private final int protocolVersion;
private final int agentProtocolVersion;

View File

@ -3,15 +3,21 @@ package com.dbx.agent;
import com.google.gson.JsonObject;
import java.sql.SQLException;
import java.sql.SQLTimeoutException;
import java.sql.SQLRecoverableException;
import java.sql.SQLTransientConnectionException;
import java.util.Locale;
import java.util.concurrent.CancellationException;
final class AgentRpcError extends RuntimeException {
private final String category;
private final boolean retryable;
private final String disposition;
private final String stage;
private final String operationOutcome;
private final String sqlState;
private final Integer vendorCode;
private final String exceptionClass;
private AgentRpcError(
String message,
@ -19,6 +25,10 @@ final class AgentRpcError extends RuntimeException {
boolean retryable,
String disposition,
String stage,
String operationOutcome,
String sqlState,
Integer vendorCode,
String exceptionClass,
Throwable cause
) {
super(message, cause);
@ -26,6 +36,10 @@ final class AgentRpcError extends RuntimeException {
this.retryable = retryable;
this.disposition = disposition;
this.stage = stage;
this.operationOutcome = operationOutcome;
this.sqlState = sqlState;
this.vendorCode = vendorCode;
this.exceptionClass = exceptionClass;
}
static AgentRpcError resource(String stage, Throwable cause) {
@ -35,6 +49,10 @@ final class AgentRpcError extends RuntimeException {
false,
"replace_runtime",
stage,
operationOutcome(stage),
null,
null,
cause == null ? null : cause.getClass().getName(),
cause
);
}
@ -46,6 +64,10 @@ final class AgentRpcError extends RuntimeException {
true,
"keep",
stage,
operationOutcome(stage),
null,
null,
cause == null ? null : cause.getClass().getName(),
cause
);
}
@ -60,6 +82,13 @@ final class AgentRpcError extends RuntimeException {
data.addProperty("retryable", classified.retryable);
data.addProperty("sessionDisposition", classified.disposition);
data.addProperty("stage", classified.stage);
data.addProperty("contractVersion", 1);
data.addProperty("operationOutcome", classified.operationOutcome);
addDiagnostic(data, "sqlState", classified.sqlState);
if (classified.vendorCode != null) {
data.addProperty("vendorCode", classified.vendorCode);
}
addDiagnostic(data, "exceptionClass", classified.exceptionClass);
if (agentSessionId != null && !agentSessionId.trim().isEmpty()) {
data.addProperty("agentSessionId", agentSessionId);
}
@ -72,34 +101,69 @@ final class AgentRpcError extends RuntimeException {
if (explicit != null) {
return explicit;
}
String stage = stage(method);
if (find(error, CancellationException.class) != null || find(error, InterruptedException.class) != null) {
return new AgentRpcError(
message(error),
"canceled",
false,
"quarantine",
stage,
operationOutcome(stage),
null,
null,
safeClassName(error),
error
);
}
SQLException sqlError = find(error, SQLException.class);
if (sqlError != null) {
String sqlState = sqlError.getSQLState();
String stage = stage(method);
String category = sqlError instanceof SQLTimeoutException ? "timeout" : null;
boolean connectionError = "connect".equals(stage)
|| "validate".equals(stage)
|| sqlError instanceof SQLRecoverableException
|| sqlError instanceof SQLTransientConnectionException
|| (sqlState != null && sqlState.toUpperCase(Locale.ROOT).startsWith("08"));
boolean operationRetryable = connectionError && ("connect".equals(stage) || "validate".equals(stage));
String disposition = connectionError && !"connect".equals(stage) ? "quarantine" : "keep";
String disposition = category != null || (connectionError && !"connect".equals(stage)) ? "quarantine" : "keep";
return new AgentRpcError(
message(error),
connectionError ? "connection" : "sql",
category == null ? (connectionError ? "connection" : "sql") : category,
operationRetryable,
disposition,
stage,
operationOutcome(stage),
safeSqlState(sqlState),
sqlError.getErrorCode(),
safeClassName(sqlError),
error
);
}
return new AgentRpcError(message(error), "protocol", false, "keep", stage(method), error);
return new AgentRpcError(
message(error),
"protocol",
false,
"keep",
stage,
operationOutcome(stage),
null,
null,
safeClassName(error),
error
);
}
private static String stage(String method) {
if (method == null) {
return "request";
}
if (AgentProtocol.METHOD_CONNECT.equals(method) || AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
return "request";
}
if (AgentProtocol.METHOD_CONNECT.equals(method)
|| AgentProtocol.METHOD_OPEN_SESSION.equals(method)
|| AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) {
return "connect";
}
if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method) || AgentProtocol.METHOD_VALIDATE_SESSION.equals(method)) {
@ -108,7 +172,11 @@ final class AgentRpcError extends RuntimeException {
if (AgentProtocol.METHOD_CANCEL_SESSION.equals(method)) {
return "cancel";
}
if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method) || AgentProtocol.METHOD_DISCONNECT.equals(method)) {
if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)
|| AgentProtocol.METHOD_DISCONNECT.equals(method)
|| AgentProtocol.METHOD_CLOSE_QUERY_SESSION.equals(method)
|| AgentProtocol.METHOD_CLOSE_TABLE_READ_SESSION.equals(method)
|| AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
return "close";
}
if (AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method)
@ -122,6 +190,41 @@ final class AgentRpcError extends RuntimeException {
return error.getMessage() == null ? error.toString() : error.getMessage();
}
private static String operationOutcome(String stage) {
return switch (stage) {
case "request", "checkout", "connect", "validate" -> "not_started";
default -> "unknown";
};
}
private static String safeSqlState(String sqlState) {
return safeDiagnostic(sqlState, 16);
}
private static String safeClassName(Throwable error) {
return error == null ? null : safeDiagnostic(error.getClass().getName(), 160);
}
private static String safeDiagnostic(String value, int maxLength) {
if (value == null) {
return null;
}
StringBuilder safe = new StringBuilder(Math.min(value.length(), maxLength));
for (int index = 0; index < value.length() && safe.length() < maxLength; index++) {
char character = value.charAt(index);
if (character >= 0x21 && character <= 0x7e) {
safe.append(character);
}
}
return safe.isEmpty() ? null : safe.toString();
}
private static void addDiagnostic(JsonObject data, String name, String value) {
if (value != null && !value.isBlank()) {
data.addProperty(name, value);
}
}
private static <T extends Throwable> T find(Throwable error, Class<T> type) {
Throwable current = error;
while (current != null) {

View File

@ -32,25 +32,8 @@ public final class BatchExecutor {
return unchecked(() -> {
long start = System.currentTimeMillis();
applySchema(conn, schema, setSchemaSql, resetSchemaSql);
long totalAffected = 0;
int statementCount = 0;
try (Statement stmt = conn.createStatement()) {
for (String statement : statements) {
String trimmed = JdbcExecutor.trimSql(statement);
if (trimmed.isEmpty()) {
continue;
}
stmt.addBatch(trimmed);
statementCount++;
}
if (statementCount > 0) {
totalAffected = affectedRows(executeBatch(stmt));
}
} catch (BatchUpdateException e) {
long[] counts = e.getLargeUpdateCounts();
int failedIndex = counts == null ? 1 : counts.length + 1;
throw new RuntimeException("Statement " + failedIndex + " failed: " + e.getMessage(), e);
}
Long batchAffected = tryExecuteBatch(conn, statements);
long totalAffected = batchAffected == null ? executeIndividually(conn, statements) : batchAffected;
return new QueryResult(
Collections.emptyList(),
Collections.emptyList(),
@ -61,6 +44,54 @@ public final class BatchExecutor {
});
}
private static Long tryExecuteBatch(Connection conn, List<String> statements) throws Exception {
try (Statement stmt = conn.createStatement()) {
try {
int statementCount = 0;
for (String statement : statements) {
String trimmed = JdbcExecutor.trimSql(statement);
if (trimmed.isEmpty()) {
continue;
}
stmt.addBatch(trimmed);
statementCount++;
}
return statementCount == 0 ? 0L : affectedRows(executeBatch(stmt));
} catch (BatchUpdateException e) {
long[] counts = e.getLargeUpdateCounts();
int failedIndex = counts == null ? 1 : counts.length + 1;
throw new RuntimeException("Statement " + failedIndex + " failed: " + e.getMessage(), e);
} catch (SQLFeatureNotSupportedException | UnsupportedOperationException | AbstractMethodError e) {
return null;
}
}
}
private static long executeIndividually(Connection conn, List<String> statements) throws Exception {
long totalAffected = 0;
int statementIndex = 0;
try (Statement stmt = conn.createStatement()) {
for (String statement : statements) {
String trimmed = JdbcExecutor.trimSql(statement);
if (trimmed.isEmpty()) {
continue;
}
statementIndex++;
try {
if (!stmt.execute(trimmed)) {
long updateCount = updateCount(stmt);
if (updateCount >= 0) {
totalAffected += updateCount;
}
}
} catch (Exception e) {
throw new RuntimeException("Statement " + statementIndex + " failed: " + e.getMessage(), e);
}
}
}
return totalAffected;
}
private static long affectedRows(long[] updateCounts) {
long total = 0;
if (updateCounts == null) {
@ -89,6 +120,14 @@ public final class BatchExecutor {
}
}
private static long updateCount(Statement stmt) throws Exception {
try {
return stmt.getLargeUpdateCount();
} catch (SQLFeatureNotSupportedException | UnsupportedOperationException | AbstractMethodError e) {
return stmt.getUpdateCount();
}
}
private static void applySchema(
Connection conn,
String schema,

View File

@ -125,6 +125,11 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
return physicalConnectionBudget.activeCount();
}
boolean hasActiveLeases(String identity) {
PoolEntry entry = pools.get(digest(identity));
return entry != null && entry.hasActiveLeases();
}
private PoolEntry createPoolEntry(String key, ConnectionFactory connectionFactory) {
try {
ConnectionFactoryDataSource factoryDataSource = new ConnectionFactoryDataSource(
@ -695,6 +700,10 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
return retired;
}
private synchronized boolean hasActiveLeases() {
return activeLeases > 0;
}
private void retireAfterCheckoutFailure(OperationDeadline deadline) {
synchronized (this) {
retired = true;
@ -1475,7 +1484,7 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
call("physical_set_network_timeout", () -> {
connection.setNetworkTimeout(networkTimeoutExecutor, networkTimeoutMillis);
return null;
}, factoryDataSource, timeoutMillis);
}, factoryDataSource, timeoutMillis, true);
}
private <T> T call(
@ -1483,6 +1492,16 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
PhysicalConnectionCall<T> call,
ConnectionFactoryDataSource factoryDataSource,
long timeoutMillis
) throws SQLException {
return call(operation, call, factoryDataSource, timeoutMillis, false);
}
private <T> T call(
String operation,
PhysicalConnectionCall<T> call,
ConnectionFactoryDataSource factoryDataSource,
long timeoutMillis,
boolean preserveCompletedFailure
) throws SQLException {
CompletableFuture<T> outcome = new CompletableFuture<>();
try {
@ -1511,6 +1530,13 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
factoryDataSource.poison(failure);
throw failure;
} catch (ExecutionException error) {
if (preserveCompletedFailure) {
Throwable cause = error.getCause();
if (cause instanceof SQLException sqlError) {
throw sqlError;
}
throw new SQLException("JDBC physical operation failed: " + operation, cause);
}
SQLException failure = new PhysicalConnectionStateUnknownException(error.getCause());
factoryDataSource.poison(failure);
throw failure;

View File

@ -87,6 +87,21 @@ public final class JdbcExecutor {
Integer fetchSize,
int timeoutSecs,
ResultValueReader valueReader
) {
return execute(conn, sql, schema, setSchemaSql, resetSchemaSql, maxRows, fetchSize, timeoutSecs, valueReader, StatementMessageReader.NONE);
}
public QueryResult execute(
Connection conn,
String sql,
String schema,
Function<String, String> setSchemaSql,
Supplier<String> resetSchemaSql,
int maxRows,
Integer fetchSize,
int timeoutSecs,
ResultValueReader valueReader,
StatementMessageReader statementMessageReader
) {
return unchecked(() -> {
String trimmedSql = trimSql(sql);
@ -122,7 +137,7 @@ public final class JdbcExecutor {
false
);
}
return withStatementWarnings(result, stmt);
return withStatementMessages(result, stmt, effectiveMaxRows, statementMessageReader);
} finally {
activeStatements.remove(stmt);
}
@ -198,7 +213,19 @@ public final class JdbcExecutor {
QueryPageOptions options,
ResultValueReader valueReader
) {
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, sessions);
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, StatementMessageReader.NONE, sessions);
}
public QueryPageResult executePage(
Connection conn,
String sql,
String schema,
Function<String, String> setSchemaSql,
QueryPageOptions options,
ResultValueReader valueReader,
StatementMessageReader statementMessageReader
) {
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, statementMessageReader, sessions);
}
public QueryPageResult executePage(
@ -210,7 +237,7 @@ public final class JdbcExecutor {
QueryPageOptions options,
ResultValueReader valueReader
) {
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader, sessions);
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader, StatementMessageReader.NONE, sessions);
}
public QueryPageResult startTableRead(
@ -221,7 +248,7 @@ public final class JdbcExecutor {
QueryPageOptions options,
ResultValueReader valueReader
) {
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, tableReadSessions);
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, StatementMessageReader.NONE, tableReadSessions);
}
public QueryPageResult startTableRead(
@ -233,7 +260,7 @@ public final class JdbcExecutor {
QueryPageOptions options,
ResultValueReader valueReader
) {
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader, tableReadSessions);
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader, StatementMessageReader.NONE, tableReadSessions);
}
private QueryPageResult executePage(
@ -244,6 +271,7 @@ public final class JdbcExecutor {
Supplier<String> resetSchemaSql,
QueryPageOptions options,
ResultValueReader valueReader,
StatementMessageReader statementMessageReader,
ConcurrentHashMap<String, QuerySession> targetSessions
) {
return unchecked(() -> {
@ -267,13 +295,32 @@ public final class JdbcExecutor {
long elapsed = System.currentTimeMillis() - start;
if (!hasResultSet) {
int updateCount = stmt.getUpdateCount();
activeStatements.remove(stmt);
stmt.close();
return new QueryPageResult(
QueryResult result = new QueryResult(
Collections.emptyList(),
Collections.emptyList(),
updateCount >= 0 ? updateCount : 0,
elapsed
elapsed,
false
);
if (statementMessageReader != StatementMessageReader.NONE) {
result = withStatementMessages(
result,
stmt,
Math.max(options.getMaxRows(), 1),
statementMessageReader
);
}
activeStatements.remove(stmt);
stmt.close();
return new QueryPageResult(
result.getColumns(),
result.getColumn_types(),
result.getRows(),
result.getAffected_rows(),
result.getExecution_time_ms(),
result.getTruncated(),
null,
false
);
}
@ -700,17 +747,28 @@ public final class JdbcExecutor {
}
}
private static QueryResult withStatementWarnings(QueryResult result, Statement stmt) {
private static QueryResult withStatementMessages(
QueryResult result,
Statement stmt,
int maxRows,
StatementMessageReader statementMessageReader
) {
if (!result.getColumns().isEmpty() || !result.getRows().isEmpty()) {
return result;
}
List<List<Object>> rows = new ArrayList<>();
int effectiveMaxRows = Math.max(maxRows, 1);
boolean truncated = result.getTruncated();
try {
Set<SQLWarning> seen = Collections.newSetFromMap(new IdentityHashMap<>());
for (SQLWarning warning = stmt.getWarnings(); warning != null && seen.add(warning); warning = warning.getNextWarning()) {
String message = warning.getMessage();
if (message != null && !message.trim().isEmpty()) {
if (rows.size() >= effectiveMaxRows) {
truncated = true;
break;
}
rows.add(Collections.singletonList(message));
}
}
@ -720,6 +778,24 @@ public final class JdbcExecutor {
// successfully executed statement into a query failure.
}
try {
List<String> messages = statementMessageReader.read(stmt);
if (messages != null) {
for (String message : messages) {
if (message == null) {
continue;
}
if (rows.size() >= effectiveMaxRows) {
truncated = true;
break;
}
rows.add(Collections.singletonList(message));
}
}
} catch (Exception ignored) {
// Driver-specific informational output is advisory, like SQLWarning.
}
if (rows.isEmpty()) {
return result;
}
@ -729,7 +805,7 @@ public final class JdbcExecutor {
rows,
result.getAffected_rows(),
result.getExecution_time_ms(),
result.getTruncated()
truncated
);
}
@ -800,6 +876,14 @@ public final class JdbcExecutor {
Object read(ResultSet rs, int index, int sqlType) throws SQLException;
}
/** Reads driver-specific informational output that is not exposed as {@link SQLWarning}. */
@FunctionalInterface
public interface StatementMessageReader {
StatementMessageReader NONE = statement -> Collections.emptyList();
List<String> read(Statement statement) throws SQLException;
}
/**
* Optional extension of {@link ResultValueReader} that exposes the JDBC
* {@code getColumnTypeName} alongside the SQL type code, allowing per-driver

View File

@ -91,6 +91,11 @@ public final class JsonRpcServer {
Object dispatchForRuntime(String method, JsonObject params) throws Exception {
return AgentExecutionContext.withJdbcExecutor(jdbcExecutor, () -> {
AbstractJdbcAgent jdbcAgent = pooledJdbcAgent();
if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method)
&& jdbcAgent != null
&& jdbcAgent.hasActivePooledLeases()) {
return Collections.singletonMap("ok", true);
}
boolean manageConnection = jdbcAgent != null && requiresConnectedConnection(method);
if (manageConnection) {
jdbcAgent.beginPooledRequest();

View File

@ -27,7 +27,7 @@ import java.util.function.Supplier;
public final class MultiSessionJsonRpcServer implements AutoCloseable {
private static final String LEGACY_SESSION_ID = "__legacy__";
private static final int MAX_SESSIONS = 256;
static final int MAX_SESSIONS = 256;
private static final int MAX_REQUEST_THREADS = 64;
private static final int MAX_CLEANUP_THREADS = 16;
private static final long MAINTENANCE_INTERVAL_MILLIS = 60_000L;
@ -137,7 +137,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
try {
Object result;
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
result = sessionHandlerFactory == null ? AgentProtocol.multiSessionHandshakeResult() : customHandshake();
result = sessionHandlerFactory == null ? AgentProtocol.multiSessionJdbcHandshakeResult() : customHandshake();
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
result = openSession(requiredSessionId(params), params);
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
@ -171,14 +171,19 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
private Object openSession(String sessionId, JsonObject params) throws Exception {
if (sessions.size() >= MAX_SESSIONS && !sessions.containsKey(sessionId)) {
throw new IllegalStateException("Agent session limit reached: " + MAX_SESSIONS);
throw AgentRpcError.backpressure(
"connect",
new IllegalStateException("Agent session limit reached: " + MAX_SESSIONS)
);
}
Session session;
if (sessionHandlerFactory != null) {
session = new Session(sessionHandlerFactory.get());
} else {
DatabaseAgent agent = agentFactory.get();
if (poolRegistry.isEnabled() && agent instanceof AbstractJdbcAgent jdbcAgent) {
if (poolRegistry.isEnabled()
&& agent instanceof AbstractJdbcAgent jdbcAgent
&& jdbcAgent.supportsConnectionPooling()) {
jdbcAgent.attachConnectionPoolRegistry(poolRegistry);
ensureMaintenanceStarted();
}

View File

@ -2,7 +2,7 @@
"protocolVersion": 1,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth"],
"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"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"commonMethods": [
@ -39,6 +39,6 @@
"disconnect",
"shutdown"
],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "drop_database", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"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"]
}

View File

@ -2,9 +2,9 @@
"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", "multi_session"],
"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", "multi_session", "structured_error_v1"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session", "structured_error_v1"],
"commonMethods": [
"handshake",
"connect",
@ -43,7 +43,7 @@
"disconnect",
"shutdown"
],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "drop_database", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"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"

View File

@ -0,0 +1,97 @@
package com.dbx.agent;
import com.google.gson.JsonObject;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import java.sql.SQLTimeoutException;
import java.util.concurrent.CancellationException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class AgentRpcErrorTest {
@Test
void serializesVersionedSqlDiagnosticsAndUnknownExecuteOutcome() {
SQLException cause = new SQLException("statement failed", "42000", 1064);
JsonObject data = errorData(cause, AgentProtocol.METHOD_EXECUTE_QUERY, "session-1");
assertEquals(1, data.get("contractVersion").getAsInt());
assertEquals("sql", data.get("category").getAsString());
assertEquals("execute", data.get("stage").getAsString());
assertEquals("unknown", data.get("operationOutcome").getAsString());
assertEquals("keep", data.get("sessionDisposition").getAsString());
assertEquals("42000", data.get("sqlState").getAsString());
assertEquals(1064, data.get("vendorCode").getAsInt());
assertEquals(SQLException.class.getName(), data.get("exceptionClass").getAsString());
assertEquals("session-1", data.get("agentSessionId").getAsString());
}
@Test
void classifiesTimeoutAndCanceledErrorsWithoutClaimingSafeReplay() {
JsonObject timeout = errorData(
new SQLTimeoutException("timed out", "HYT00", 0),
AgentProtocol.METHOD_EXECUTE_QUERY,
"session-1"
);
JsonObject canceled = errorData(
new CancellationException("canceled"),
AgentProtocol.METHOD_CANCEL_SESSION,
"session-1"
);
assertEquals("timeout", timeout.get("category").getAsString());
assertEquals("unknown", timeout.get("operationOutcome").getAsString());
assertFalse(timeout.get("retryable").getAsBoolean());
assertEquals("quarantine", timeout.get("sessionDisposition").getAsString());
assertEquals("canceled", canceled.get("category").getAsString());
assertEquals("cancel", canceled.get("stage").getAsString());
assertEquals("unknown", canceled.get("operationOutcome").getAsString());
}
@Test
void marksConnectionSetupFailureAsNotStartedAndBoundsSqlState() {
SQLException cause = new SQLException("connect failed", "12345678901234567890", -7);
JsonObject data = errorData(cause, AgentProtocol.METHOD_CONNECT, null);
assertEquals("connection", data.get("category").getAsString());
assertEquals("connect", data.get("stage").getAsString());
assertEquals("not_started", data.get("operationOutcome").getAsString());
assertEquals(16, data.get("sqlState").getAsString().length());
assertFalse(data.has("agentSessionId"));
}
@Test
void removesNonGraphicCharactersFromStrictDiagnostics() {
SQLException cause = new SQLException("connect failed", "08\n006\u00e9", -7);
JsonObject data = errorData(cause, AgentProtocol.METHOD_CONNECT, null);
assertEquals("08006", data.get("sqlState").getAsString());
}
@Test
void mapsConnectionAndCloseMethodsToTheSameStagesAsTheRustDecoder() {
JsonObject testConnection = errorData(
new SQLException("connect failed", "08001", 0),
AgentProtocol.METHOD_TEST_CONNECTION,
null
);
JsonObject closeQuery = errorData(
new SQLException("close failed", "42000", 0),
AgentProtocol.METHOD_CLOSE_QUERY_SESSION,
"session-1"
);
assertEquals("connect", testConnection.get("stage").getAsString());
assertEquals("not_started", testConnection.get("operationOutcome").getAsString());
assertEquals("close", closeQuery.get("stage").getAsString());
assertEquals("unknown", closeQuery.get("operationOutcome").getAsString());
}
private static JsonObject errorData(Throwable error, String method, String agentSessionId) {
return AgentRpcError.toJson(error, method, agentSessionId).getAsJsonObject("data");
}
}

View File

@ -6,6 +6,7 @@ import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
@ -37,6 +38,31 @@ class BatchExecutorTest {
assertEquals(2L, result.getAffected_rows());
}
@Test
void executeBatchStatementsFallsBackWhenStatementBatchIsUnsupported() {
List<String> executedSql = new ArrayList<>();
AtomicInteger addBatchCalls = new AtomicInteger();
AtomicInteger executeLargeBatchCalls = new AtomicInteger();
Statement statement = unsupportedBatchStatementProxy(executedSql, addBatchCalls, executeLargeBatchCalls);
Connection connection = connectionProxy(statement);
QueryResult result = BatchExecutor.executeBatchStatements(
connection,
Arrays.asList(" UPDATE items SET name = 'Ada' WHERE id = 1; ", " DELETE FROM items WHERE id = 2; "),
null,
schema -> null
);
assertEquals(1, addBatchCalls.get());
assertEquals(0, executeLargeBatchCalls.get());
assertEquals(
Arrays.asList("UPDATE items SET name = 'Ada' WHERE id = 1", "DELETE FROM items WHERE id = 2"),
executedSql
);
assertEquals(0L, result.getAffected_rows());
}
private static Statement statementProxy(
List<String> batchedSql,
AtomicInteger executeLargeBatchCalls,
@ -60,6 +86,31 @@ class BatchExecutorTest {
return (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class<?>[]{Statement.class}, handler);
}
private static Statement unsupportedBatchStatementProxy(
List<String> executedSql,
AtomicInteger addBatchCalls,
AtomicInteger executeLargeBatchCalls
) {
InvocationHandler handler = (Object unused, Method method, Object[] args) -> {
switch (method.getName()) {
case "addBatch":
addBatchCalls.incrementAndGet();
throw new SQLFeatureNotSupportedException("Batches not supported");
case "executeLargeBatch":
executeLargeBatchCalls.incrementAndGet();
throw new AssertionError("Unsupported JDBC batches must not be executed");
case "execute":
executedSql.add((String) args[0]);
return false;
case "getLargeUpdateCount":
return -1L;
default:
return defaultValue(method.getReturnType());
}
};
return (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class<?>[]{Statement.class}, handler);
}
private static Connection connectionProxy(Statement statement) {
InvocationHandler handler = (Object unused, Method method, Object[] args) -> {
if ("createStatement".equals(method.getName())) {

View File

@ -72,7 +72,7 @@ class CommonJavaCompatibilityTest {
strings(contract.getAsJsonArray("handshakeResponseFields"))
);
assertEquals(
AgentProtocol.MULTI_SESSION_ALL_CAPABILITIES,
AgentProtocol.MULTI_SESSION_JDBC_ALL_CAPABILITIES,
strings(contract.getAsJsonArray("allCapabilities"))
);
assertEquals(
@ -80,7 +80,7 @@ class CommonJavaCompatibilityTest {
strings(contract.getAsJsonArray("capabilities"))
);
assertEquals(
AgentProtocol.MULTI_SESSION_CAPABILITIES,
AgentProtocol.MULTI_SESSION_JDBC_CAPABILITIES,
strings(contract.getAsJsonArray("defaultSqlCapabilities"))
);
assertEquals(AgentProtocol.MULTI_SESSION_METHODS, strings(contract.getAsJsonArray("commonMethods")));
@ -164,6 +164,7 @@ class CommonJavaCompatibilityTest {
)).getAsJsonObject().getAsJsonObject("result");
assertEquals(2, handshake.get("protocolVersion").getAsInt());
assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "multi_session"));
assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "structured_error_v1"));
server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"a\"}}");
server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"b\"}}");
@ -177,6 +178,68 @@ class CommonJavaCompatibilityTest {
assertEquals(0, created.get(1).disconnectCount);
}
@Test
void customSessionHandlersDoNotImplicitlyAdvertiseStructuredErrors() {
MultiSessionJsonRpcServer server = MultiSessionJsonRpcServer.forSessionHandlers(() -> new SessionRpcHandler() {
@Override
public Object connect(JsonObject params) {
return Collections.singletonMap("ok", true);
}
@Override
public Object handle(String method, JsonObject params) {
return Collections.singletonMap("ok", true);
}
@Override
public void close() {
}
});
JsonObject handshake = JsonParser.parseString(server.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"handshake\",\"params\":{}}"
)).getAsJsonObject().getAsJsonObject("result");
assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "multi_session"));
assertFalse(containsCapability(handshake.getAsJsonArray("capabilities"), "structured_error_v1"));
}
@Test
void multiSessionLimitReturnsStructuredBackpressureBeforeConnectStarts() {
try (MultiSessionJsonRpcServer server = MultiSessionJsonRpcServer.forSessionHandlers(() -> new SessionRpcHandler() {
@Override
public Object connect(JsonObject params) {
return Collections.singletonMap("ok", true);
}
@Override
public Object handle(String method, JsonObject params) {
return Collections.singletonMap("ok", true);
}
@Override
public void close() {
}
})) {
for (int index = 0; index < MultiSessionJsonRpcServer.MAX_SESSIONS; index++) {
JsonObject response = JsonParser.parseString(server.handleRequest(openSessionRequest(index, "session-" + index)))
.getAsJsonObject();
assertTrue(response.has("result"), response::toString);
}
JsonObject response = JsonParser.parseString(server.handleRequest(
openSessionRequest(MultiSessionJsonRpcServer.MAX_SESSIONS, "overflow")
)).getAsJsonObject();
JsonObject data = response.getAsJsonObject("error").getAsJsonObject("data");
assertEquals("resource", data.get("category").getAsString());
assertTrue(data.get("retryable").getAsBoolean());
assertEquals("keep", data.get("sessionDisposition").getAsString());
assertEquals("connect", data.get("stage").getAsString());
assertEquals("not_started", data.get("operationOutcome").getAsString());
}
}
@Test
void multiSessionServerKeepsProtocolOutputWhenGlobalStdoutChanges() {
synchronized (System.class) {
@ -1113,6 +1176,17 @@ class CommonJavaCompatibilityTest {
return false;
}
private static String openSessionRequest(int requestId, String sessionId) {
JsonObject params = new JsonObject();
params.addProperty("agentSessionId", sessionId);
JsonObject request = new JsonObject();
request.addProperty("jsonrpc", "2.0");
request.addProperty("id", requestId);
request.addProperty("method", AgentProtocol.METHOD_OPEN_SESSION);
request.add("params", params);
return request.toString();
}
private static void awaitCondition(java.util.function.BooleanSupplier condition) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
while (!condition.getAsBoolean() && System.nanoTime() < deadline) {

View File

@ -850,6 +850,27 @@ class JdbcConnectionPoolingTest {
}
}
@RepeatedTest(5)
void unsupportedNetworkTimeoutDoesNotPoisonIdentity() throws Exception {
AtomicInteger physicalOpens = new AtomicInteger();
String url = h2Url("unsupported_network_timeout");
try (Connection ignored = openH2(url, physicalOpens)) {
// Keep H2 bootstrap outside the setup classification watchdog.
}
physicalOpens.set(0);
try (JdbcConnectionPoolRegistry registry = new JdbcConnectionPoolRegistry(shortTimeoutPoolSettings(1, 32))) {
for (int attempt = 0; attempt < 2; attempt++) {
try (JdbcConnectionPoolRegistry.Lease lease = registry.borrow(
"unsupported-network-timeout",
() -> unsupportedNetworkTimeoutConnection(openH2(url, physicalOpens))
)) {
assertTrue(lease.connection().isValid(1));
}
}
assertEquals(1, physicalOpens.get());
}
}
@Test
void blockedSetupAfterKnownFailurePoisonsCurrentAttemptGeneration() throws Exception {
AtomicInteger connectionAttempts = new AtomicInteger();
@ -1120,6 +1141,55 @@ class JdbcConnectionPoolingTest {
}
}
@Test
void validationSkipsBusySharedPoolWithoutWaiting() throws Exception {
AtomicInteger physicalOpens = new AtomicInteger();
AtomicInteger requestIds = new AtomicInteger();
String url = h2Url("busy_validation");
try (MultiSessionJsonRpcServer server = server(url, physicalOpens, 1)) {
openSession(server, requestIds, "cursor-owner");
openSession(server, requestIds, "validation-session");
JsonObject pageParams = sessionParams("cursor-owner");
pageParams.addProperty("sql", "SELECT X FROM SYSTEM_RANGE(1, 3)");
pageParams.addProperty("pageSize", 1);
JsonObject firstPage = result(request(
server,
requestIds,
AgentProtocol.METHOD_EXECUTE_QUERY_PAGE,
pageParams
));
assertTrue(firstPage.get("has_more").getAsBoolean());
String querySessionId = firstPage.get("session_id").getAsString();
long startedAtNanos = System.nanoTime();
JsonObject validation = result(request(
server,
requestIds,
AgentProtocol.METHOD_VALIDATE_SESSION,
sessionParams("validation-session")
));
long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos);
assertTrue(elapsedMillis < 200L, () -> "busy validation took " + elapsedMillis + "ms");
assertTrue(validation.get("ok").getAsBoolean());
assertEquals(1, physicalOpens.get());
JsonObject closeParams = sessionParams("cursor-owner");
closeParams.addProperty("sessionId", querySessionId);
assertTrue(request(
server,
requestIds,
AgentProtocol.METHOD_CLOSE_QUERY_SESSION,
closeParams
).get("result").getAsBoolean());
assertEquals(
2,
query(server, requestIds, "validation-session", "SELECT 2", null)
.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsInt()
);
}
}
@Test
void maintenanceExpiresAbandonedCursorAndReturnsItsConnection() throws Exception {
AtomicInteger physicalOpens = new AtomicInteger();
@ -2136,6 +2206,23 @@ class JdbcConnectionPoolingTest {
);
}
private static Connection unsupportedNetworkTimeoutConnection(Connection delegate) {
return (Connection) Proxy.newProxyInstance(
Connection.class.getClassLoader(),
new Class<?>[] {Connection.class},
(proxy, method, args) -> {
if ("setNetworkTimeout".equals(method.getName())) {
throw new SQLException("Does not support setNetworkTimeout");
}
try {
return method.invoke(delegate, args);
} catch (InvocationTargetException error) {
throw error.getCause();
}
}
);
}
private static Connection asynchronousAbortConnection(
Connection delegate,
CountDownLatch abortScheduled,

View File

@ -20,8 +20,10 @@ import java.util.concurrent.atomic.AtomicInteger;
import javax.sql.rowset.serial.SerialBlob;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JdbcExecutorTest {
@Test
@ -145,6 +147,87 @@ class JdbcExecutorTest {
assertEquals(3L, result.getAffected_rows());
}
@Test
void executeReturnsDriverMessagesForNoResultStatementsAndHonorsMaxRows() {
QueryResult result = JdbcExecutor.INSTANCE.execute(
executionConnection(false, -1, null, new AtomicInteger(), null, null),
"CALL LOG_ONLY_PROCEDURE()",
"",
schema -> "",
() -> "",
2,
null,
0,
JdbcExecutor.INSTANCE::defaultResultValue,
statement -> Arrays.asList("first", "second", "third")
);
assertEquals(Arrays.asList("Message"), result.getColumns());
assertEquals(Arrays.asList(Arrays.asList("first"), Arrays.asList("second")), result.getRows());
assertTrue(result.getTruncated());
}
@Test
void executeLimitsCombinedWarningsAndDriverMessages() {
SQLWarning first = new SQLWarning("first warning");
first.setNextWarning(new SQLWarning("second warning"));
QueryResult result = JdbcExecutor.INSTANCE.execute(
executionConnection(false, -1, first, new AtomicInteger(), null, null),
"CALL LOG_ONLY_PROCEDURE()",
"",
schema -> "",
() -> "",
1,
null,
0,
JdbcExecutor.INSTANCE::defaultResultValue,
statement -> Arrays.asList("driver message")
);
assertEquals(Arrays.asList("Message"), result.getColumns());
assertEquals(Arrays.asList(Arrays.asList("first warning")), result.getRows());
assertTrue(result.getTruncated());
}
@Test
void executePageReturnsDriverMessagesForNoResultStatements() {
QueryPageResult result = JdbcExecutor.INSTANCE.executePage(
executionConnection(false, -1, null, new AtomicInteger(), null, null),
"CALL LOG_ONLY_PROCEDURE()",
"",
schema -> "",
new QueryPageOptions(100, null, 100),
JdbcExecutor.INSTANCE::defaultResultValue,
statement -> Arrays.asList("first", "second")
);
assertEquals(Arrays.asList("Message"), result.getColumns());
assertEquals(Arrays.asList(Arrays.asList("first"), Arrays.asList("second")), result.getRows());
assertFalse(result.getHas_more());
}
@Test
void executePageKeepsWarningsHiddenWithoutADriverMessageReader() {
QueryPageResult result = JdbcExecutor.INSTANCE.executePage(
executionConnection(
false,
-1,
new SQLWarning("existing paged warning"),
new AtomicInteger(),
null,
null
),
"CALL EXISTING_PROCEDURE()",
"",
schema -> "",
new QueryPageOptions()
);
assertEquals(Collections.emptyList(), result.getColumns());
assertEquals(Collections.emptyList(), result.getRows());
}
@Test
void executeDoesNotReplaceOrdinaryResultSetsWithWarnings() {
CountingResultSetFixture fixture = countingResultSet(new Object[][]{{1, "Ada"}});

View File

@ -62,6 +62,8 @@ class MetadataConstraintCoverageTest {
result.add(driversRoot.relativize(file).getName(0).toString());
} else if ("main.go".equals(fileName) && hasGoMetadataDispatcher(file)) {
result.add(driversRoot.relativize(file).getName(0).toString());
} else if (fileName.endsWith(".rs") && hasRustMetadataDispatcher(file)) {
result.add(driversRoot.relativize(file).getName(0).toString());
}
});
}
@ -85,6 +87,15 @@ class MetadataConstraintCoverageTest {
}
}
private static boolean hasRustMetadataDispatcher(Path file) {
try {
String source = readUtf8(file);
return source.contains("\"list_tables\"") || source.contains("\"list_objects\"");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private static Map<String, String> readCoverageMatrix(Path matrix) throws IOException {
Map<String, String> result = new HashMap<>();
for (String line : Files.readAllLines(matrix, StandardCharsets.UTF_8)) {

View File

@ -1,6 +1,6 @@
# Agent Protocol v2: Multi-session runtimes
Protocol v2 allows one Agent process to serve multiple isolated database sessions. The handshake advertises `protocolVersion: 2` and the `multi_session` capability. DBX falls back to the v1 one-process-per-pool lifecycle when that capability is absent.
Protocol v2 allows one Agent process to serve multiple isolated database sessions. Pooled JDBC Agents that use the common structured error producer advertise `protocolVersion: 2`, `multi_session`, and `structured_error_v1`. Generic/custom v2 handlers may advertise only `multi_session`. DBX falls back to the v1 one-process-per-pool lifecycle when `multi_session` is absent.
## Session lifecycle
@ -37,14 +37,21 @@ JSON-RPC failures may include structured recovery data:
```json
{
"contractVersion": 1,
"category": "timeout|canceled|connection|protocol|resource|sql",
"retryable": false,
"sessionDisposition": "keep|quarantine|replace_runtime",
"agentSessionId": "optional-session-id",
"stage": "checkout|connect|validate|execute|fetch|cancel|close"
"stage": "request|checkout|connect|validate|execute|fetch|cancel|close",
"operationOutcome": "not_started|unknown",
"sqlState": "optional-jdbc-sql-state",
"vendorCode": 0,
"exceptionClass": "optional-java-exception-class"
}
```
`contractVersion: 1` is guaranteed only when the handshake advertises `structured_error_v1`. Unknown extra fields are allowed, but unknown enum values, missing required fields, invalid types, or an `agentSessionId` that does not match the current request are contract violations. `operationOutcome` describes whether the user operation may have reached the database; `retryable` is an internal hint and never authorizes automatic SQL replay.
`keep` preserves the logical session, `quarantine` removes only that session from routing, and `replace_runtime` requires DBX to atomically remove every pool sharing the runtime before terminating it. Agent code reports the disposition but must not independently terminate a shared runtime because it does not own DBX routing state. Temporary workload checkout backpressure uses `category=resource`, `retryable=true`, and `sessionDisposition=keep`; only unrecoverable runtime or cleanup saturation requests `replace_runtime`.
The complete JDBC pool checkout runs under a bounded runtime executor, including HikariCP idle-connection validation, physical connection creation, and driver setup. Workload admission, the runtime-wide physical connection budget, physical creation, and checkout consume one absolute deadline rather than restarting the timeout at each stage. Connection return, eviction, and physical close use separate bounded executors so they cannot deadlock checkout or creation. If a driver call outlives its boundary, or cleanup cannot confirm the physical connection state, the connection identity is poisoned and returns `category=resource` with `sessionDisposition=replace_runtime` on the current or next checkout. A late connection must be evicted and closed instead of published, and DBX must not replay the timed-out user operation automatically.

View File

@ -166,9 +166,9 @@ git push origin "$RELEASE_TAG"
The release workflow will:
- Bump changed module versions in `versions.json`.
- Build all agent shadow jars.
- Build/download JRE `.tar.zst` artifacts.
- Resolve the effective previous module versions from the post-release version-sync commit after the previous `agents-v*` tag.
- Bump and build only changed Java or native agent modules.
- Download unchanged single-driver packages and JRE archives from the previous immutable release, then verify filenames, versions, platform coverage, sizes, and SHA-256 digests before reuse.
- Generate `agent-registry.json`.
- Create full offline platform ZIPs from raw staging files.
- Create one `.tar.zst` package per Java or native driver.

View File

@ -0,0 +1,139 @@
# Cassandra native Agent
The Cassandra Agent uses Apache `cassandra-gocql-driver` and implements the DBX
multi-session JSON-RPC protocol without a JVM.
## Compatibility
- Native protocol versions: v3-v5
- Declared server range: Apache Cassandra 2.1+
- Live validation: 2.2.19, 3.11.19, 4.1.10, and 5.0.6
- Kerberos live validation: Cassandra 4.1.10 with password, keytab, FILE ccache,
JAAS discovery, and HOCON `configfile`
- Astra validation: secure-connect bundle parsing and transport configuration;
live Astra credentials were not available
- Authentication: username/password and Kerberos/GSSAPI
- TLS: CA verification, optional client certificate/key, hostname verification
- Cloud: DataStax Astra secure connect bundles
- Configuration: Java Driver 4 HOCON `configfile` mapping plus native extensions
- Metadata: keyspaces, tables, columns, indexes, CQL table DDL, completion search
- Queries: legacy string result values, paging, cancellation, logged and unlogged batches
The Agent accepts both normal DBX connection fields and Cassandra JDBC-style
connection strings, including the wrapper's `host1--host2:9042` contact-point
syntax.
## JDBC URL parameter mapping
| JDBC parameter | Native behavior |
| --- | --- |
| `consistency` | GoCQL consistency |
| `fetchsize` | default page size |
| `retries` | retry/reconnection attempt count |
| `loadbalancing` | default, round-robin, DC-aware, or token-aware built-in policy |
| `localdatacenter` | DC-aware host selection |
| `retry` | default/simple, fallthrough, downgrading, or exponential built-in policy |
| `reconnection` | constant or exponential reconnection policy |
| `debug` | GoCQL debug logging to stderr |
| `enablessl` | TLS enablement |
| `sslenginefactory` | the standard `DefaultSslEngineFactory` maps to native TLS |
| `hostnameverification` | TLS hostname verification; enabled by default |
| `user`, `password` | password authentication |
| `configfile` | Java Driver 4 HOCON configuration; overrides URL options except contact points and keyspace |
| `usekrb5` | Kerberos/GSSAPI authentication using password, keytab, or FILE credential cache |
| `secureconnectbundle` | DataStax Astra secure connect bundle; contact points and manual TLS options are ignored |
| `requesttimeout`, `connecttimeout` | request and connection deadlines |
| `tcpnodelay`, `keepalive` | native TCP socket options |
| `compliancemode` | accepted; JDBC-only `java.sql` behavior is not applicable to JSON-RPC |
The Agent rejects custom Java implementation classes because they cannot be
loaded by a native binary. This includes custom authentication, SSL, retry,
reconnection, and load-balancing classes. Java JKS/PKCS12 truststores and
keystores are not read directly; use the native PEM paths described below.
## Java Driver HOCON configuration
`configfile` reads Java Driver 4 HOCON files and preserves the JDBC wrapper's
precedence: the file overrides URL options except contact points and keyspace.
A missing file is ignored for compatibility with the JDBC wrapper.
Mapped Java Driver paths include:
- `basic.request.timeout`, `consistency`, `serial-consistency`, and `page-size`
- `basic.load-balancing-policy.class` and `local-datacenter`
- `basic.cloud.secure-connect-bundle`
- `advanced.connection.connect-timeout` and `pool.local.size`
- `advanced.socket.tcp-no-delay` and `keep-alive`
- `advanced.protocol.version`, retry policy, and reconnection policy
- `advanced.auth-provider` plaintext and Instaclustr Kerberos options
- `advanced.ssl-engine-factory` default TLS and hostname validation
Native-only settings can be placed under `dbx.cassandra`:
```hocon
dbx.cassandra {
tls {
enabled = true
ca-cert-path = "/path/to/ca.pem"
client-cert-path = "/path/to/client.pem"
client-key-path = "/path/to/client-key.pem"
hostname-verification = true
}
kerberos {
enabled = true
config = "/etc/krb5.conf"
jaas-config = "/path/to/jaas.conf"
principal = "alice@EXAMPLE.COM"
keytab = "/path/to/alice.keytab"
service-name = "cassandra"
server-name = "node1.example.com"
authorization-id = "assumed_role"
qop = "auth"
}
}
```
## Kerberos
`usekrb5=true` implements the same GSSAPI flow used by the former Instaclustr
Java auth provider. The service principal defaults to
`cassandra/<canonical-node-hostname>`. Set `kerberosservername` when reverse DNS
does not resolve to the service-principal hostname.
Credential discovery order is:
1. Explicit JAAS `CassandraJavaClient` cache/keytab selection
2. Explicit `kerberosccache` or `kerberoskeytab`
3. Explicit principal and password
4. `KRB5CCNAME`, then `KRB5_CLIENT_KTNAME`/`KRB5_KTNAME`
The Agent also reads `java.security.auth.login.config` and
`java.security.krb5.conf` from `JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, or
`JDK_JAVA_OPTIONS`. Only FILE credential caches are supported. SASL QOP `auth`
is supported; `auth-int` and `auth-conf` are rejected because they require
wrapping Cassandra traffic after authentication.
## Astra secure connect bundles
Set `secureconnectbundle` to a local Astra bundle ZIP and provide its database
credentials with `user` and `password`. A normal Cassandra host is not required.
Kerberos cannot be combined with a secure connect bundle. Manual TLS settings
are ignored because the bundle supplies its own CA, client certificate, key,
SNI endpoint, and metadata service.
## Integration test
```bash
CASSANDRA_TEST_HOST=127.0.0.1 \
CASSANDRA_TEST_PORT=9042 \
CASSANDRA_TEST_USERNAME=cassandra \
CASSANDRA_TEST_PASSWORD=cassandra \
go test -run TestCassandraIntegration -v
```
Optional variables include `CASSANDRA_TEST_URL_PARAMS`, `CASSANDRA_TEST_SSL`,
`CASSANDRA_TEST_CA_CERT_PATH`, `CASSANDRA_TEST_CLIENT_CERT_PATH`, and
`CASSANDRA_TEST_CLIENT_KEY_PATH`.
See `bench/README.md` for the archived JDBC comparison workflow and measured
Cassandra 4.1.10 results.

View File

@ -0,0 +1,166 @@
package main
import (
"archive/zip"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"math/big"
"net/url"
"os"
"path/filepath"
"reflect"
"testing"
"time"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
func TestSecureConnectBundleBuildsAstraClusterWithoutHost(t *testing.T) {
bundlePath := writeTestSecureConnectBundle(t)
config, err := parseCassandraConfig(connectParams{
Username: "token",
Password: "astra-token",
URLParams: url.Values{
"secureconnectbundle": []string{bundlePath},
"requesttimeout": []string{"9s"},
"connecttimeout": []string{"7s"},
}.Encode(),
})
if err != nil {
t.Fatal(err)
}
if len(config.hosts) != 0 || config.secureConnectBundle != bundlePath {
t.Fatalf("unexpected Astra config: %#v", config)
}
cluster, err := config.clusterConfig("app")
if err != nil {
t.Fatal(err)
}
if cluster.HostDialer == nil {
t.Fatal("Astra cluster must use the secure-connect HostDialer")
}
if reflect.DeepEqual(cluster.Hosts, config.hosts) || len(cluster.Hosts) != 3 {
t.Fatalf("Astra cluster must use dialer placeholder hosts: %#v", cluster.Hosts)
}
credentials, ok := cluster.Authenticator.(*gocql.PasswordAuthenticator)
if !ok || credentials.Username != "token" || credentials.Password != "astra-token" {
t.Fatalf("unexpected Astra authenticator: %#v", cluster.Authenticator)
}
if cluster.Keyspace != "app" || cluster.Timeout != 9*time.Second || cluster.ConnectTimeout != 7*time.Second {
t.Fatalf("unexpected Astra cluster options: %#v", cluster)
}
}
func TestSecureConnectBundleSupportsHOCONConfiguration(t *testing.T) {
bundlePath := writeTestSecureConnectBundle(t)
configPath := writeTestFile(t, "astra.conf", `
datastax-java-driver {
basic.cloud.secure-connect-bundle = "`+bundlePath+`"
advanced.auth-provider {
class = PlainTextAuthProvider
username = token
password = astra-token
}
}
`)
config, err := parseCassandraConfig(connectParams{
URLParams: url.Values{"configfile": []string{configPath}}.Encode(),
})
if err != nil {
t.Fatal(err)
}
if config.secureConnectBundle != bundlePath || config.username != "token" || config.password != "astra-token" {
t.Fatalf("unexpected HOCON Astra config: %#v", config)
}
if _, err := config.clusterConfig(""); err != nil {
t.Fatal(err)
}
}
func TestSecureConnectBundleValidatesCredentialsAndAuthMode(t *testing.T) {
bundlePath := writeTestSecureConnectBundle(t)
if _, err := parseCassandraConfig(connectParams{
URLParams: url.Values{"secureconnectbundle": []string{bundlePath}}.Encode(),
}); err == nil {
t.Fatal("expected Astra credential validation error")
}
if _, err := parseCassandraConfig(connectParams{
Username: "token",
Password: "astra-token",
URLParams: url.Values{
"secureconnectbundle": []string{bundlePath},
"usekrb5": []string{"true"},
}.Encode(),
}); err == nil {
t.Fatal("expected Astra and Kerberos conflict")
}
}
func writeTestSecureConnectBundle(t *testing.T) string {
t.Helper()
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "dbx-astra-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
}
certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
t.Fatal(err)
}
privateKeyDER, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
t.Fatal(err)
}
certificatePEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificateDER})
privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKeyDER})
configJSON, err := json.Marshal(map[string]any{"host": "astra.example.com", "port": 29042})
if err != nil {
t.Fatal(err)
}
bundlePath := filepath.Join(t.TempDir(), "secure-connect.zip")
bundle, err := os.Create(bundlePath)
if err != nil {
t.Fatal(err)
}
archive := zip.NewWriter(bundle)
for name, contents := range map[string][]byte{
"config.json": configJSON,
"ca.crt": certificatePEM,
"cert": certificatePEM,
"key": privateKeyPEM,
} {
entry, createErr := archive.Create(name)
if createErr != nil {
t.Fatal(createErr)
}
if _, writeErr := entry.Write(contents); writeErr != nil {
t.Fatal(writeErr)
}
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
if err := bundle.Close(); err != nil {
t.Fatal(err)
}
return bundlePath
}

View File

@ -0,0 +1,61 @@
# Cassandra Agent benchmark
This benchmark compares the same DBX JSON-RPC operations through the native
Apache `cassandra-gocql-driver` Agent and the archived Cassandra JDBC Agent.
It measures process startup, connection creation, RSS, latency, throughput,
artifact size, and shutdown behavior.
Each connection sample uses a fresh Agent process so JDBC runtime pooling cannot
turn later samples into warm reconnects. Query workloads use one persistent,
already-connected process per candidate.
## Prepare the fixture
The default workload expects `dbx_native_test.all_types` with at least 100 rows
and an integer primary key named `id`. Override the SQL variables below when
using another schema.
## Build the native Agent
From `agents/`:
```bash
go build -o /tmp/dbx-cassandra-bench/cassandra-go ./drivers/cassandra-go
```
Keep an archived JDBC Agent JAR as the baseline. The production Cassandra
module publishes only the native executable.
## Run
```bash
GO_AGENT=/tmp/dbx-cassandra-bench/cassandra-go \
JDBC_AGENT_JAR=/tmp/dbx-cassandra-bench/dbx-agent-cassandra.jar \
CASSANDRA_HOST=127.0.0.1 \
CASSANDRA_PORT=9042 \
CASSANDRA_KEYSPACE=dbx_native_test \
python3 drivers/cassandra-go/bench/agent_compare.py \
> /tmp/dbx-cassandra-bench/result.json
```
If Java is only available in a container, provide the full interactive command:
```bash
JDBC_AGENT_COMMAND='docker run --rm -i --name dbx-cassandra-jdbc-bench -v /tmp/dbx-cassandra-bench:/bench:ro eclipse-temurin:21-jre java -jar /bench/dbx-agent-cassandra.jar'
JDBC_RSS_COMMAND="docker inspect --format '{{.State.Pid}}' dbx-cassandra-jdbc-bench | xargs -I{} awk '/VmRSS/ {print \$2}' /proc/{}/status"
```
## Configuration
- `BENCH_CANDIDATES`: `go,jdbc` by default
- `BENCH_STARTUPS`: startup samples, default `10`
- `BENCH_CONNECTS`: connection samples, default `10`
- `BENCH_WARMUPS`: warmups before each workload, default `20`
- `CASSANDRA_USERNAME`, `CASSANDRA_PASSWORD`, `CASSANDRA_URL_PARAMS`
- `CASSANDRA_SSL`, `CASSANDRA_CA_CERT_PATH`, `CASSANDRA_CLIENT_CERT_PATH`, `CASSANDRA_CLIENT_KEY_PATH`
- `BENCH_SELECT_ONE_SQL`, `BENCH_DECODE_SQL`, `BENCH_PAGE_SQL`
- `BENCH_SELECT_ONE_COUNT`, `BENCH_DECODE_COUNT`, `BENCH_LIST_TABLES_COUNT`, `BENCH_PAGE_COUNT`
Run both candidates on the same host against the same Cassandra instance. Do
not compare a local native Agent with a remote JDBC Agent or change the query
shape between candidates.

View File

@ -0,0 +1,328 @@
#!/usr/bin/env python3
import json
import os
import shlex
import statistics
import subprocess
import sys
import threading
import time
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Candidate:
name: str
command: list[str]
artifact: Path
rss_command: str = ""
class AgentProcess:
def __init__(self, candidate: Candidate):
self.candidate = candidate
self.process = subprocess.Popen(
candidate.command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
self.request_id = 0
self.stderr_lines: list[str] = []
threading.Thread(target=self._drain_stderr, daemon=True).start()
self._wait_ready()
def _drain_stderr(self) -> None:
assert self.process.stderr is not None
for line in self.process.stderr:
self.stderr_lines.append(line.rstrip())
def _wait_ready(self) -> None:
assert self.process.stdout is not None
deadline = time.monotonic() + env_float("BENCH_READY_TIMEOUT", 30.0)
while time.monotonic() < deadline:
line = self.process.stdout.readline()
if line == "" and self.process.poll() is not None:
raise RuntimeError(self._failure("agent exited before ready"))
try:
if json.loads(line).get("ready") is True:
return
except (json.JSONDecodeError, AttributeError):
continue
raise TimeoutError(self._failure("timed out waiting for agent readiness"))
def call(self, method: str, params: dict | None = None) -> dict:
self.request_id += 1
request = {
"jsonrpc": "2.0",
"id": self.request_id,
"method": method,
"params": params or {},
}
assert self.process.stdin is not None
assert self.process.stdout is not None
self.process.stdin.write(json.dumps(request, separators=(",", ":")) + "\n")
self.process.stdin.flush()
while True:
line = self.process.stdout.readline()
if line == "" and self.process.poll() is not None:
raise RuntimeError(self._failure(f"agent exited during {method}"))
try:
response = json.loads(line)
except json.JSONDecodeError:
continue
if response.get("id") != self.request_id:
continue
if response.get("error") is not None:
raise RuntimeError(f"{self.candidate.name} {method}: {json.dumps(response['error'], ensure_ascii=False)}")
return response.get("result")
def rss_kib(self) -> int:
if self.candidate.rss_command:
output = subprocess.check_output(self.candidate.rss_command, shell=True, text=True).strip()
return int(output)
output = subprocess.check_output(
["ps", "-o", "rss=", "-p", str(self.process.pid)],
text=True,
).strip()
return int(output or "0")
def close(self) -> bool:
if self.process.poll() is not None:
return True
try:
self.call("shutdown")
except Exception:
pass
try:
self.process.wait(timeout=3)
return True
except subprocess.TimeoutExpired:
self.process.terminate()
try:
self.process.wait(timeout=2)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait(timeout=5)
return False
def _failure(self, message: str) -> str:
stderr = "\n".join(self.stderr_lines[-20:])
return f"{self.candidate.name}: {message}\n{stderr}".rstrip()
def main() -> None:
candidates = configured_candidates()
connection = connection_params()
startup_iterations = env_int("BENCH_STARTUPS", 10)
connect_iterations = env_int("BENCH_CONNECTS", 10)
warmups = env_int("BENCH_WARMUPS", 20)
workloads = configured_workloads(connection["database"])
results = []
for candidate in candidates:
startup_samples = benchmark_startup(candidate, startup_iterations)
connect_samples = benchmark_connect(candidate, connection, connect_iterations)
process = AgentProcess(candidate)
shutdown_clean = False
try:
process.call("connect", connection)
rss_kib = process.rss_kib()
workload_results = [benchmark_workload(process, workload, warmups) for workload in workloads]
process.call("disconnect")
finally:
shutdown_clean = process.close()
results.append(
{
"candidate": candidate.name,
"command": candidate.command,
"artifact_bytes": candidate.artifact.stat().st_size,
"startup_ms": statistics.median(startup_samples),
"startup_samples_ms": startup_samples,
"connect_ms": statistics.median(connect_samples),
"connect_samples_ms": connect_samples,
"rss_kib": rss_kib,
"shutdown_exited_within_3s": shutdown_clean,
"workloads": workload_results,
}
)
output = {
"host": os.uname().nodename,
"server": env_default("CASSANDRA_SERVER", f"{connection['host']}:{connection['port']}"),
"keyspace": connection["database"],
"startup_iterations": startup_iterations,
"connect_iterations": connect_iterations,
"warmups": warmups,
"results": results,
}
json.dump(output, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
def configured_candidates() -> list[Candidate]:
selected = {item.strip() for item in env_default("BENCH_CANDIDATES", "go,jdbc").split(",") if item.strip()}
candidates = []
if "go" in selected:
artifact = required_path("GO_AGENT")
candidates.append(Candidate("go-native", [str(artifact)], artifact, os.getenv("GO_RSS_COMMAND", "")))
if "jdbc" in selected:
artifact = required_path("JDBC_AGENT_JAR")
raw_command = os.getenv("JDBC_AGENT_COMMAND", "")
command = shlex.split(raw_command) if raw_command else [env_default("JAVA_BIN", "java"), "-jar", str(artifact)]
candidates.append(Candidate("jdbc-java", command, artifact, os.getenv("JDBC_RSS_COMMAND", "")))
if not candidates:
raise ValueError("BENCH_CANDIDATES selected no candidates")
return candidates
def connection_params() -> dict:
return {
"host": env_default("CASSANDRA_HOST", "127.0.0.1"),
"port": env_int("CASSANDRA_PORT", 9042),
"database": env_default("CASSANDRA_KEYSPACE", "dbx_native_test"),
"username": os.getenv("CASSANDRA_USERNAME", ""),
"password": os.getenv("CASSANDRA_PASSWORD", ""),
"url_params": os.getenv("CASSANDRA_URL_PARAMS", ""),
"connection_string": os.getenv("CASSANDRA_CONNECTION_STRING", ""),
"ssl": env_bool("CASSANDRA_SSL", False),
"ca_cert_path": os.getenv("CASSANDRA_CA_CERT_PATH", ""),
"client_cert_path": os.getenv("CASSANDRA_CLIENT_CERT_PATH", ""),
"client_key_path": os.getenv("CASSANDRA_CLIENT_KEY_PATH", ""),
}
def configured_workloads(keyspace: str) -> list[dict]:
table = env_default("CASSANDRA_BENCH_TABLE", "all_types")
qualified = f'"{keyspace}"."{table}"'
return [
{
"name": "select_one",
"method": "execute_query",
"params": {"sql": env_default("BENCH_SELECT_ONE_SQL", f"SELECT id, txt FROM {qualified} WHERE id = 1"), "schema": keyspace, "maxRows": 1},
"count": env_int("BENCH_SELECT_ONE_COUNT", 1000),
},
{
"name": "decode_all_types",
"method": "execute_query",
"params": {"sql": env_default("BENCH_DECODE_SQL", f"SELECT * FROM {qualified} WHERE id = 1"), "schema": keyspace, "maxRows": 1},
"count": env_int("BENCH_DECODE_COUNT", 500),
},
{
"name": "list_tables",
"method": "list_tables",
"params": {"schema": keyspace},
"count": env_int("BENCH_LIST_TABLES_COUNT", 500),
},
{
"name": "page_100",
"method": "execute_query_page",
"params": {
"sql": env_default("BENCH_PAGE_SQL", f"SELECT id, txt FROM {qualified}"),
"schema": keyspace,
"maxRows": 100,
"pageSize": 100,
},
"count": env_int("BENCH_PAGE_COUNT", 200),
},
]
def benchmark_startup(candidate: Candidate, iterations: int) -> list[float]:
samples = []
for _ in range(iterations):
start = time.perf_counter()
process = AgentProcess(candidate)
samples.append((time.perf_counter() - start) * 1000)
process.close()
return samples
def benchmark_connect(candidate: Candidate, connection: dict, iterations: int) -> list[float]:
samples = []
for _ in range(iterations):
process = AgentProcess(candidate)
try:
start = time.perf_counter()
process.call("connect", connection)
samples.append((time.perf_counter() - start) * 1000)
finally:
process.close()
return samples
def benchmark_workload(process: AgentProcess, workload: dict, warmups: int) -> dict:
for _ in range(warmups):
process.call(workload["method"], workload["params"])
samples = []
start = time.perf_counter()
for _ in range(workload["count"]):
operation_start = time.perf_counter()
process.call(workload["method"], workload["params"])
samples.append((time.perf_counter() - operation_start) * 1000)
elapsed = time.perf_counter() - start
ordered = sorted(samples)
return {
"name": workload["name"],
"count": workload["count"],
"elapsed_ms": elapsed * 1000,
"ops_per_sec": workload["count"] / elapsed,
"mean_ms": statistics.mean(samples),
"p50_ms": percentile(ordered, 0.50),
"p95_ms": percentile(ordered, 0.95),
"p99_ms": percentile(ordered, 0.99),
}
def percentile(values: list[float], fraction: float) -> float:
if not values:
return 0.0
index = min(len(values) - 1, max(0, round((len(values) - 1) * fraction)))
return values[index]
def required_path(name: str) -> Path:
value = os.getenv(name, "")
if not value:
raise ValueError(f"{name} is required")
path = Path(value).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(path)
return path
def env_default(name: str, fallback: str) -> str:
return os.getenv(name, "") or fallback
def env_int(name: str, fallback: int) -> int:
value = int(env_default(name, str(fallback)))
if value < 1:
raise ValueError(f"{name} must be positive")
return value
def env_float(name: str, fallback: float) -> float:
value = float(env_default(name, str(fallback)))
if value <= 0:
raise ValueError(f"{name} must be positive")
return value
def env_bool(name: str, fallback: bool) -> bool:
raw = os.getenv(name)
if raw is None or raw == "":
return fallback
normalized = raw.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ValueError(f"{name} must be a boolean")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,36 @@
{
"date": "2026-08-03",
"host": "oss-rainyun-01",
"cassandra": "4.1.10",
"method": "five cold-process startup and connection samples; persistent process for query workloads",
"results": [
{
"candidate": "go-native",
"startup_ms": 8.994690957479179,
"connect_ms": 33.21323194541037,
"rss_kib": 10352,
"artifact_bytes": 6750370,
"shutdown_exited_within_3s": true,
"workloads": [
{"name": "select_one", "count": 1000, "elapsed_ms": 1940.8286979887635, "ops_per_sec": 515.2438239584345, "mean_ms": 1.9388069859705865, "p50_ms": 1.9103229278698564, "p95_ms": 2.221024944446981, "p99_ms": 2.3676720447838306},
{"name": "decode_all_types", "count": 500, "elapsed_ms": 1079.476205050014, "ops_per_sec": 463.18760678641746, "mean_ms": 2.156704908935353, "p50_ms": 2.141958102583885, "p95_ms": 2.4329390143975616, "p99_ms": 2.7680869679898024},
{"name": "list_tables", "count": 500, "elapsed_ms": 57.42691201157868, "ops_per_sec": 8706.71924513698, "mean_ms": 0.11419291398487985, "p50_ms": 0.10622991248965263, "p95_ms": 0.1487070694565773, "p99_ms": 0.19879091996699572},
{"name": "page_100", "count": 200, "elapsed_ms": 984.7569830017164, "ops_per_sec": 203.09579261917395, "mean_ms": 4.921203925041482, "p50_ms": 4.922428051941097, "p95_ms": 6.079918937757611, "p99_ms": 7.303814985789359}
]
},
{
"candidate": "jdbc-java",
"startup_ms": 657.9867920372635,
"connect_ms": 1778.3896300243214,
"rss_kib": 166232,
"artifact_bytes": 22984056,
"shutdown_exited_within_3s": false,
"workloads": [
{"name": "select_one", "count": 1000, "elapsed_ms": 3868.2536740088835, "ops_per_sec": 258.51458675502147, "mean_ms": 3.865854301955551, "p50_ms": 3.6270120181143284, "p95_ms": 5.733568919822574, "p99_ms": 7.344924029894173},
{"name": "decode_all_types", "count": 500, "elapsed_ms": 2014.9188039358705, "ops_per_sec": 248.14895718046694, "mean_ms": 4.0274618696421385, "p50_ms": 3.9998559514060616, "p95_ms": 4.94410190731287, "p99_ms": 5.3489640122279525},
{"name": "list_tables", "count": 500, "elapsed_ms": 763.6687039630488, "ops_per_sec": 654.7341764894339, "mean_ms": 1.5251681823283434, "p50_ms": 1.494601950980723, "p95_ms": 1.9315499812364578, "p99_ms": 2.339883940294385},
{"name": "page_100", "count": 200, "elapsed_ms": 8309.039836982265, "ops_per_sec": 24.070169829952025, "mean_ms": 41.53980694070924, "p50_ms": 42.0777719700709, "p95_ms": 51.87095201108605, "p99_ms": 57.71494994405657}
]
}
]
}

View File

@ -0,0 +1,653 @@
package main
import (
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
gocql "github.com/apache/cassandra-gocql-driver/v2"
gocqlastra "github.com/datastax/gocql-astra/v2"
)
type cassandraConfig struct {
hosts []string
port int
keyspace string
username string
password string
localDatacenter string
requestTimeout time.Duration
connectTimeout time.Duration
protocolVersion int
consistency string
serialConsistency string
numConnections int
pageSize int
cqlVersion string
ssl bool
caCertPath string
clientCertPath string
clientKeyPath string
hostVerification bool
tcpNoDelay bool
keepAlive bool
debug bool
retryPolicy string
retryCount int
reconnectionPolicy string
reconnectionBaseDelay time.Duration
reconnectionMaxDelay time.Duration
loadBalancingPolicy string
disableInitialHostLookup bool
configFile string
secureConnectBundle string
kerberos kerberosConfig
}
func parseCassandraConfig(cp connectParams) (cassandraConfig, error) {
config := cassandraConfig{
port: 9042,
keyspace: strings.TrimSpace(cp.Database),
username: cp.Username,
password: cp.Password,
requestTimeout: 11 * time.Second,
connectTimeout: defaultConnectTimeout,
numConnections: 2,
pageSize: 5000,
ssl: cp.SSL,
caCertPath: cp.CACertPath,
clientCertPath: cp.ClientCertPath,
clientKeyPath: cp.ClientKeyPath,
hostVerification: true,
tcpNoDelay: true,
retryCount: 3,
reconnectionBaseDelay: time.Second,
reconnectionMaxDelay: 60 * time.Second,
kerberos: defaultKerberosConfig(),
}
if cp.Port > 0 {
config.port = cp.Port
}
params := url.Values{}
if strings.TrimSpace(cp.ConnectionString) != "" {
if err := applyConnectionString(&config, params, cp.ConnectionString); err != nil {
return cassandraConfig{}, err
}
}
if len(config.hosts) == 0 {
config.hosts = splitHosts(cp.Host)
}
urlParams, err := parseURLParams(cp.URLParams)
if err != nil {
return cassandraConfig{}, err
}
for key, values := range urlParams {
params[key] = values
}
if err := applyCassandraURLParams(&config, params); err != nil {
return cassandraConfig{}, err
}
if config.configFile != "" {
if err := applyCassandraConfigFile(&config, config.configFile); err != nil {
return cassandraConfig{}, err
}
}
if err := config.finalize(); err != nil {
return cassandraConfig{}, err
}
if len(config.hosts) == 0 && config.secureConnectBundle == "" {
return cassandraConfig{}, fmt.Errorf("Cassandra host is required")
}
if len(config.hosts) > 0 && !config.disableInitialHostLookup && allLoopbackHosts(config.hosts) {
config.disableInitialHostLookup = true
}
return config, nil
}
func applyConnectionString(config *cassandraConfig, params url.Values, raw string) error {
value := strings.TrimSpace(raw)
value = strings.TrimPrefix(value, "jdbc:")
if !strings.Contains(value, "://") {
return fmt.Errorf("unsupported Cassandra connection string: %s", raw)
}
parsed, err := url.Parse(value)
if err != nil {
return fmt.Errorf("invalid Cassandra connection string: %w", err)
}
if parsed.Scheme != "cassandra" {
return fmt.Errorf("unsupported Cassandra connection scheme: %s", parsed.Scheme)
}
if parsed.User != nil {
config.username = parsed.User.Username()
if password, ok := parsed.User.Password(); ok {
config.password = password
}
}
config.hosts = splitHosts(parsed.Host)
if port := parsed.Port(); port != "" {
parsedPort, parseErr := strconv.Atoi(port)
if parseErr != nil || parsedPort < 1 || parsedPort > 65535 {
return fmt.Errorf("invalid Cassandra port: %s", port)
}
config.port = parsedPort
}
if keyspace := strings.Trim(strings.TrimSpace(parsed.Path), "/"); keyspace != "" {
config.keyspace = keyspace
}
for key, values := range parsed.Query() {
params[key] = values
}
return nil
}
func parseURLParams(raw string) (url.Values, error) {
raw = strings.TrimPrefix(strings.TrimSpace(raw), "?")
if raw == "" {
return url.Values{}, nil
}
values, err := url.ParseQuery(raw)
if err != nil {
return nil, fmt.Errorf("invalid Cassandra URL parameters: %w", err)
}
return values, nil
}
func applyCassandraURLParams(config *cassandraConfig, params url.Values) error {
for rawKey, values := range params {
if len(values) == 0 {
continue
}
key := normalizeOptionName(rawKey)
value := strings.TrimSpace(values[len(values)-1])
switch key {
case "localdatacenter", "datacenter", "dc":
config.localDatacenter = value
case "requesttimeout", "timeout":
duration, err := parseDurationOption(value)
if err != nil {
return fmt.Errorf("invalid requesttimeout: %w", err)
}
config.requestTimeout = duration
case "connecttimeout", "logintimeout":
duration, err := parseDurationOption(value)
if err != nil {
return fmt.Errorf("invalid connecttimeout: %w", err)
}
config.connectTimeout = duration
case "protocolversion", "protoversion":
version, err := strconv.Atoi(value)
if err != nil || version < 3 || version > 5 {
return fmt.Errorf("protocolversion must be between 3 and 5")
}
config.protocolVersion = version
case "consistency":
if _, err := gocql.ParseConsistencyWrapper(value); err != nil {
return err
}
config.consistency = value
case "serialconsistency":
consistency, err := gocql.ParseConsistencyWrapper(value)
if err != nil {
return err
}
if consistency != gocql.Serial && consistency != gocql.LocalSerial {
return fmt.Errorf("serialconsistency must be SERIAL or LOCAL_SERIAL")
}
config.serialConsistency = value
case "numconns", "connectionsperhost":
count, err := strconv.Atoi(value)
if err != nil || count < 1 || count > 32 {
return fmt.Errorf("numconns must be between 1 and 32")
}
config.numConnections = count
case "pagesize", "fetchsize":
size, err := strconv.Atoi(value)
if err != nil || size < 1 {
return fmt.Errorf("pagesize must be positive")
}
config.pageSize = size
case "cqlversion":
config.cqlVersion = value
case "ssl", "enablessl":
enabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid ssl option: %w", err)
}
config.ssl = enabled
case "hostverification", "verifyhostname", "sslhostnameverification", "hostnameverification":
enabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid host verification option: %w", err)
}
config.hostVerification = enabled
case "tcpnodelay":
enabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid tcpnodelay option: %w", err)
}
config.tcpNoDelay = enabled
case "keepalive":
enabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid keepalive option: %w", err)
}
config.keepAlive = enabled
case "user":
config.username = value
case "password":
config.password = value
case "debug":
enabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid debug option: %w", err)
}
config.debug = enabled
case "retries":
count, err := strconv.Atoi(value)
if err != nil || count < 0 || count > 1000 {
return fmt.Errorf("retries must be between 0 and 1000")
}
config.retryCount = count
case "retry":
policy, err := normalizeRetryPolicy(value)
if err != nil {
return err
}
config.retryPolicy = policy
case "reconnection":
policy, baseDelay, maxDelay, err := parseReconnectionPolicy(value)
if err != nil {
return err
}
config.reconnectionPolicy = policy
config.reconnectionBaseDelay = baseDelay
config.reconnectionMaxDelay = maxDelay
case "disableinitialhostlookup":
disabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid disableinitialhostlookup option: %w", err)
}
config.disableInitialHostLookup = disabled
case "loadbalancing":
policy, err := normalizeLoadBalancingPolicy(value)
if err != nil {
return err
}
config.loadBalancingPolicy = policy
case "sslenginefactory":
if value != "" && !strings.EqualFold(simpleClassName(value), "DefaultSslEngineFactory") {
return fmt.Errorf("custom Cassandra sslenginefactory is not supported by the native agent: %s", value)
}
config.ssl = true
case "usekrb5":
enabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid usekrb5 option: %w", err)
}
config.kerberos.enabled = enabled
case "secureconnectbundle":
config.secureConnectBundle = value
case "configfile":
config.configFile = value
case "kerberosconfig", "kerberosconfigpath", "krb5config", "krb5conf":
config.kerberos.configPath = value
case "jaasconfig", "jaasconfigpath":
config.kerberos.jaasConfigPath = value
case "kerberosprincipal", "krb5principal":
config.kerberos.principal = value
case "kerberosrealm", "krb5realm":
config.kerberos.realm = value
case "kerberoskeytab", "keytab":
config.kerberos.keytabPath = value
case "kerberosccache", "kerberosticketcache", "ccache", "ticketcache":
config.kerberos.ccachePath = value
case "kerberospassword":
config.kerberos.password = value
case "kerberosservice", "kerberosservicename", "saslprotocol":
config.kerberos.serviceName = value
case "kerberosservername", "saslservername":
config.kerberos.serverName = value
case "kerberosauthorizationid", "authorizationid":
config.kerberos.authorizationID = value
case "kerberosqop", "saslqop":
config.kerberos.qop = value
case "kerberosdisablepafxfast", "disablepafxfast":
disabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid disablepafxfast option: %w", err)
}
config.kerberos.disablePAFXFAST = disabled
case "kerberosusekeytab", "usekeytab":
enabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid usekeytab option: %w", err)
}
config.kerberos.useKeytab = enabled
config.kerberos.useKeytabSet = true
case "kerberosuseticketcache", "useticketcache":
enabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid useticketcache option: %w", err)
}
config.kerberos.useTicketCache = enabled
config.kerberos.useTicketCacheSet = true
case "compliancemode":
// JDBC compliance modes only alter java.sql behavior. The native DBX
// JSON-RPC contract already defines statement and transaction behavior.
default:
return fmt.Errorf("unsupported Cassandra URL parameter: %s", rawKey)
}
}
return nil
}
func (config cassandraConfig) clusterConfig(keyspace string) (*gocql.ClusterConfig, error) {
var cluster *gocql.ClusterConfig
var err error
if config.secureConnectBundle != "" {
cluster, err = gocqlastra.NewClusterFromBundle(
config.secureConnectBundle,
config.username,
config.password,
config.connectTimeout,
)
if err != nil {
return nil, fmt.Errorf("load Cassandra secure connect bundle: %w", err)
}
} else {
cluster = gocql.NewCluster(config.hosts...)
cluster.Port = config.port
cluster.Dialer = cassandraDialer{
timeout: config.connectTimeout,
keepAlive: config.keepAlive,
tcpNoDelay: config.tcpNoDelay,
}
cluster.DisableInitialHostLookup = config.disableInitialHostLookup
cluster.IgnorePeerAddr = config.disableInitialHostLookup
}
cluster.Keyspace = strings.TrimSpace(keyspace)
cluster.Timeout = config.requestTimeout
cluster.ConnectTimeout = config.connectTimeout
cluster.WriteTimeout = config.requestTimeout
cluster.NumConns = config.numConnections
cluster.PageSize = config.pageSize
if config.protocolVersion != 0 {
cluster.ProtoVersion = config.protocolVersion
}
if config.cqlVersion != "" {
cluster.CQLVersion = config.cqlVersion
}
if config.consistency != "" {
consistency, err := gocql.ParseConsistencyWrapper(config.consistency)
if err != nil {
return nil, err
}
cluster.Consistency = consistency
}
if config.serialConsistency != "" {
consistency, err := gocql.ParseConsistencyWrapper(config.serialConsistency)
if err != nil {
return nil, err
}
cluster.SerialConsistency = consistency
}
if config.kerberos.enabled {
authProvider, err := newKerberosAuthProvider(config.kerberos, config.username, config.password)
if err != nil {
return nil, err
}
cluster.Authenticator = nil
cluster.AuthProvider = authProvider
} else if config.secureConnectBundle == "" && config.username != "" {
cluster.Authenticator = gocql.PasswordAuthenticator{Username: config.username, Password: config.password}
}
if config.secureConnectBundle == "" && config.ssl {
cluster.SslOpts = &gocql.SslOptions{
CaPath: config.caCertPath,
CertPath: config.clientCertPath,
KeyPath: config.clientKeyPath,
EnableHostVerification: config.hostVerification,
}
}
if config.debug {
cluster.Logger = gocql.NewLogger(gocql.LogLevelDebug)
}
if err := applyRetryPolicies(cluster, config); err != nil {
return nil, err
}
if err := applyLoadBalancingPolicy(cluster, config); err != nil {
return nil, err
}
return cluster, nil
}
func (config *cassandraConfig) finalize() error {
var err error
config.configFile, err = normalizeLocalFilePath(config.configFile)
if err != nil {
return fmt.Errorf("invalid Cassandra configfile: %w", err)
}
config.secureConnectBundle, err = normalizeLocalFilePath(config.secureConnectBundle)
if err != nil {
return fmt.Errorf("invalid Cassandra secureconnectbundle: %w", err)
}
if config.secureConnectBundle != "" && config.kerberos.enabled {
return fmt.Errorf("Cassandra secure connect bundles cannot be combined with Kerberos authentication")
}
if config.secureConnectBundle != "" && (config.username == "" || config.password == "") {
return fmt.Errorf("Cassandra secure connect bundles require username and password credentials")
}
if config.kerberos.enabled {
if err := config.kerberos.finalize(config.username, config.password); err != nil {
return err
}
}
return nil
}
func splitHosts(raw string) []string {
raw = strings.ReplaceAll(raw, "--", ",")
parts := strings.FieldsFunc(raw, func(char rune) bool { return char == ',' || char == ';' })
hosts := make([]string, 0, len(parts))
for _, part := range parts {
host := strings.TrimSpace(part)
if host == "" {
continue
}
hosts = append(hosts, host)
}
return hosts
}
func allLoopbackHosts(hosts []string) bool {
for _, host := range hosts {
host = hostNameOnly(host)
if strings.EqualFold(host, "localhost") {
continue
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return false
}
}
return len(hosts) > 0
}
func hostNameOnly(host string) string {
host = strings.TrimSpace(host)
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
return parsedHost
}
return strings.Trim(host, "[]")
}
func parseDurationOption(value string) (time.Duration, error) {
if duration, err := time.ParseDuration(value); err == nil {
return duration, nil
}
milliseconds, err := strconv.Atoi(value)
if err != nil || milliseconds < 1 {
return 0, fmt.Errorf("expected duration or positive milliseconds")
}
return time.Duration(milliseconds) * time.Millisecond, nil
}
func normalizeRetryPolicy(value string) (string, error) {
name := strings.ToLower(simpleClassName(value))
switch name {
case "", "defaultretrypolicy", "simpleretrypolicy":
return "simple", nil
case "fallthroughretrypolicy":
return "fallthrough", nil
case "downgradingconsistencyretrypolicy":
return "downgrading", nil
case "exponentialbackoffretrypolicy":
return "exponential", nil
default:
return "", fmt.Errorf("unsupported Cassandra retry policy: %s", value)
}
}
func normalizeLoadBalancingPolicy(value string) (string, error) {
name := strings.ToLower(simpleClassName(value))
switch name {
case "", "basicloadbalancingpolicy", "dcinferringloadbalancingpolicy", "defaultloadbalancingpolicy":
return "default", nil
case "roundrobinpolicy":
return "round_robin", nil
case "dcawareroundrobinpolicy":
return "dc_aware", nil
case "tokenawarepolicy":
return "token_aware", nil
default:
return "", fmt.Errorf("unsupported Cassandra loadbalancing policy: %s", value)
}
}
func parseReconnectionPolicy(value string) (string, time.Duration, time.Duration, error) {
trimmed := strings.TrimSpace(value)
name := simpleClassName(trimmed)
parameters := ""
if open := strings.IndexByte(name, '('); open >= 0 {
parameters = strings.TrimSuffix(name[open+1:], ")")
name = name[:open]
}
policy := strings.ToLower(strings.TrimSpace(name))
baseDelay := time.Second
maxDelay := 60 * time.Second
if parameters != "" {
parts := strings.Split(parameters, ",")
for index, part := range parts {
part = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(part), "(long)", ""))
seconds, err := strconv.Atoi(part)
if err != nil || seconds < 0 {
return "", 0, 0, fmt.Errorf("invalid Cassandra reconnection policy delay: %s", part)
}
if index == 0 {
baseDelay = time.Duration(seconds) * time.Second
} else if index == 1 {
maxDelay = time.Duration(seconds) * time.Second
} else {
return "", 0, 0, fmt.Errorf("too many Cassandra reconnection policy parameters")
}
}
}
switch policy {
case "", "constantreconnectionpolicy":
return "constant", baseDelay, baseDelay, nil
case "exponentialreconnectionpolicy":
return "exponential", baseDelay, maxDelay, nil
default:
return "", 0, 0, fmt.Errorf("unsupported Cassandra reconnection policy: %s", value)
}
}
func simpleClassName(value string) string {
value = strings.TrimSpace(value)
prefix := value
if open := strings.IndexByte(prefix, '('); open >= 0 {
prefix = prefix[:open]
}
if dot := strings.LastIndexByte(prefix, '.'); dot >= 0 {
return value[dot+1:]
}
return value
}
func applyRetryPolicies(cluster *gocql.ClusterConfig, config cassandraConfig) error {
switch config.retryPolicy {
case "":
case "simple":
cluster.RetryPolicy = &gocql.SimpleRetryPolicy{NumRetries: config.retryCount}
case "fallthrough":
cluster.RetryPolicy = &gocql.SimpleRetryPolicy{NumRetries: 0}
case "downgrading":
cluster.RetryPolicy = &gocql.DowngradingConsistencyRetryPolicy{}
case "exponential":
cluster.RetryPolicy = &gocql.ExponentialBackoffRetryPolicy{
NumRetries: config.retryCount,
Min: config.reconnectionBaseDelay,
Max: config.reconnectionMaxDelay,
}
default:
return fmt.Errorf("unsupported Cassandra retry policy: %s", config.retryPolicy)
}
if config.reconnectionPolicy != "" || config.retryCount != 3 {
switch config.reconnectionPolicy {
case "", "constant":
cluster.ReconnectionPolicy = &gocql.ConstantReconnectionPolicy{
MaxRetries: config.retryCount,
Interval: config.reconnectionBaseDelay,
}
case "exponential":
cluster.ReconnectionPolicy = &gocql.ExponentialReconnectionPolicy{
MaxRetries: config.retryCount,
InitialInterval: config.reconnectionBaseDelay,
MaxInterval: config.reconnectionMaxDelay,
}
default:
return fmt.Errorf("unsupported Cassandra reconnection policy: %s", config.reconnectionPolicy)
}
}
return nil
}
func applyLoadBalancingPolicy(cluster *gocql.ClusterConfig, config cassandraConfig) error {
policy := config.loadBalancingPolicy
if policy == "" {
policy = "default"
}
switch policy {
case "default":
if config.localDatacenter == "" {
return nil
}
cluster.PoolConfig.HostSelectionPolicy = gocql.TokenAwareHostPolicy(
gocql.DCAwareRoundRobinPolicy(config.localDatacenter),
)
case "round_robin":
cluster.PoolConfig.HostSelectionPolicy = gocql.RoundRobinHostPolicy()
case "dc_aware":
if config.localDatacenter == "" {
return fmt.Errorf("DCAwareRoundRobinPolicy requires localdatacenter")
}
cluster.PoolConfig.HostSelectionPolicy = gocql.DCAwareRoundRobinPolicy(config.localDatacenter)
case "token_aware":
fallback := gocql.RoundRobinHostPolicy()
if config.localDatacenter != "" {
fallback = gocql.DCAwareRoundRobinPolicy(config.localDatacenter)
}
cluster.PoolConfig.HostSelectionPolicy = gocql.TokenAwareHostPolicy(fallback)
default:
return fmt.Errorf("unsupported Cassandra loadbalancing policy: %s", policy)
}
return nil
}
func normalizeOptionName(value string) string {
return strings.NewReplacer("_", "", "-", "", ".", "").Replace(strings.ToLower(strings.TrimSpace(value)))
}

View File

@ -0,0 +1,469 @@
package main
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
gocql "github.com/apache/cassandra-gocql-driver/v2"
"github.com/gurkankaymak/hocon"
)
const javaDriverConfigPrefix = "datastax-java-driver."
func applyCassandraConfigFile(config *cassandraConfig, rawPath string) error {
path, err := normalizeLocalFilePath(rawPath)
if err != nil {
return fmt.Errorf("invalid Cassandra configfile: %w", err)
}
if path == "" {
return nil
}
info, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("read Cassandra configfile %s: %w", path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("Cassandra configfile is not a regular file: %s", path)
}
parsed, err := hocon.ParseResource(path)
if err != nil {
return fmt.Errorf("parse Cassandra configfile %s: %w", path, err)
}
config.configFile = path
if err := applyJavaDriverHOCON(config, parsed); err != nil {
return fmt.Errorf("apply Cassandra configfile %s: %w", path, err)
}
return nil
}
func applyJavaDriverHOCON(config *cassandraConfig, parsed *hocon.Config) error {
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"basic.request.timeout"); err != nil {
return err
} else if ok {
config.requestTimeout = value
}
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.request.consistency"); err != nil {
return err
} else if ok {
if _, err := gocql.ParseConsistencyWrapper(value); err != nil {
return err
}
config.consistency = value
}
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.request.serial-consistency"); err != nil {
return err
} else if ok {
consistency, err := gocql.ParseConsistencyWrapper(value)
if err != nil {
return err
}
if consistency != gocql.Serial && consistency != gocql.LocalSerial {
return fmt.Errorf("serial consistency must be SERIAL or LOCAL_SERIAL")
}
config.serialConsistency = value
}
if value, ok, err := hoconInt(parsed, javaDriverConfigPrefix+"basic.request.page-size"); err != nil {
return err
} else if ok {
if value < 1 {
return fmt.Errorf("page size must be positive")
}
config.pageSize = value
}
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.load-balancing-policy.local-datacenter"); err != nil {
return err
} else if ok {
config.localDatacenter = value
}
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.load-balancing-policy.class"); err != nil {
return err
} else if ok {
policy, err := normalizeLoadBalancingPolicy(value)
if err != nil {
return err
}
config.loadBalancingPolicy = policy
}
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"basic.cloud.secure-connect-bundle"); err != nil {
return err
} else if ok {
config.secureConnectBundle = value
}
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"advanced.connection.connect-timeout"); err != nil {
return err
} else if ok {
config.connectTimeout = value
}
if value, ok, err := hoconInt(parsed, javaDriverConfigPrefix+"advanced.connection.pool.local.size"); err != nil {
return err
} else if ok {
if value < 1 || value > 32 {
return fmt.Errorf("connection pool local size must be between 1 and 32")
}
config.numConnections = value
}
if value, ok, err := hoconBool(parsed, javaDriverConfigPrefix+"advanced.socket.tcp-no-delay"); err != nil {
return err
} else if ok {
config.tcpNoDelay = value
}
if value, ok, err := hoconBool(parsed, javaDriverConfigPrefix+"advanced.socket.keep-alive"); err != nil {
return err
} else if ok {
config.keepAlive = value
}
if value, ok, err := hoconProtocolVersion(parsed, javaDriverConfigPrefix+"advanced.protocol.version"); err != nil {
return err
} else if ok {
config.protocolVersion = value
}
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"advanced.retry-policy.class"); err != nil {
return err
} else if ok {
policy, err := normalizeRetryPolicy(value)
if err != nil {
return err
}
config.retryPolicy = policy
}
if value, ok, err := hoconString(parsed, javaDriverConfigPrefix+"advanced.reconnection-policy.class"); err != nil {
return err
} else if ok {
policy, baseDelay, maxDelay, err := parseReconnectionPolicy(value)
if err != nil {
return err
}
config.reconnectionPolicy = policy
config.reconnectionBaseDelay = baseDelay
config.reconnectionMaxDelay = maxDelay
}
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"advanced.reconnection-policy.base-delay"); err != nil {
return err
} else if ok {
config.reconnectionBaseDelay = value
}
if value, ok, err := hoconDuration(parsed, javaDriverConfigPrefix+"advanced.reconnection-policy.max-delay"); err != nil {
return err
} else if ok {
config.reconnectionMaxDelay = value
}
if err := applyHOCONAuthentication(config, parsed); err != nil {
return err
}
if err := applyHOCONSSL(config, parsed); err != nil {
return err
}
return applyNativeHOCON(config, parsed)
}
func applyHOCONAuthentication(config *cassandraConfig, parsed *hocon.Config) error {
prefix := javaDriverConfigPrefix + "advanced.auth-provider."
if value, ok, err := hoconString(parsed, prefix+"class"); err != nil {
return err
} else if ok {
switch strings.ToLower(simpleClassName(value)) {
case "plaintextauthprovider", "dseplaintextauthprovider":
config.kerberos.enabled = false
case "kerberosauthprovider", "programmatickerberosauthprovider", "dsegssapiauthprovider":
config.kerberos.enabled = true
default:
return fmt.Errorf("unsupported Cassandra auth provider class: %s", value)
}
}
if value, ok, err := hoconString(parsed, prefix+"username"); err != nil {
return err
} else if ok {
config.username = value
}
if value, ok, err := hoconString(parsed, prefix+"password"); err != nil {
return err
} else if ok {
config.password = value
}
if value, ok, err := hoconString(parsed, prefix+"authorization-id"); err != nil {
return err
} else if ok {
config.kerberos.authorizationID = value
}
for _, path := range []string{prefix + "sasl-protocol", prefix + "service"} {
if value, ok, err := hoconString(parsed, path); err != nil {
return err
} else if ok {
config.kerberos.serviceName = value
}
}
if value, ok, err := hoconStringMap(parsed, prefix+"sasl-properties"); err != nil {
return err
} else if ok {
for key, property := range value {
if strings.EqualFold(key, "javax.security.sasl.qop") {
config.kerberos.qop = property
}
}
}
if value, ok, err := hoconString(parsed, prefix+"server-name-resolver"); err != nil {
return err
} else if ok && value != "" {
return fmt.Errorf("custom Java Kerberos server-name-resolver is not supported; use dbx.cassandra.kerberos.server-name")
}
loginPrefix := prefix + "login-configuration."
if value, ok, err := hoconString(parsed, loginPrefix+"principal"); err != nil {
return err
} else if ok {
config.kerberos.principal = value
}
if value, ok, err := firstHOCONString(parsed, loginPrefix+"keyTab", loginPrefix+"keytab"); err != nil {
return err
} else if ok {
config.kerberos.keytabPath = value
}
if value, ok, err := firstHOCONString(parsed, loginPrefix+"ticketCache", loginPrefix+"ticket-cache"); err != nil {
return err
} else if ok {
config.kerberos.ccachePath = value
}
if value, ok, err := firstHOCONBool(parsed, loginPrefix+"useKeyTab", loginPrefix+"use-keytab"); err != nil {
return err
} else if ok {
config.kerberos.useKeytab = value
config.kerberos.useKeytabSet = true
}
if value, ok, err := firstHOCONBool(parsed, loginPrefix+"useTicketCache", loginPrefix+"use-ticket-cache"); err != nil {
return err
} else if ok {
config.kerberos.useTicketCache = value
config.kerberos.useTicketCacheSet = true
}
return nil
}
func applyHOCONSSL(config *cassandraConfig, parsed *hocon.Config) error {
prefix := javaDriverConfigPrefix + "advanced.ssl-engine-factory."
if value, ok, err := hoconString(parsed, prefix+"class"); err != nil {
return err
} else if ok {
if !strings.EqualFold(simpleClassName(value), "DefaultSslEngineFactory") {
return fmt.Errorf("unsupported Cassandra SSL engine factory class: %s", value)
}
config.ssl = true
}
if value, ok, err := hoconBool(parsed, prefix+"hostname-validation"); err != nil {
return err
} else if ok {
config.hostVerification = value
config.ssl = true
}
for _, path := range []string{prefix + "truststore-path", prefix + "keystore-path"} {
if value, ok, err := hoconString(parsed, path); err != nil {
return err
} else if ok && value != "" {
return fmt.Errorf("Java truststore and keystore files are not supported; use dbx.cassandra.tls PEM paths")
}
}
return nil
}
func applyNativeHOCON(config *cassandraConfig, parsed *hocon.Config) error {
prefix := "dbx.cassandra."
stringMappings := []struct {
path string
target *string
}{
{"tls.ca-cert-path", &config.caCertPath},
{"tls.client-cert-path", &config.clientCertPath},
{"tls.client-key-path", &config.clientKeyPath},
{"kerberos.config", &config.kerberos.configPath},
{"kerberos.jaas-config", &config.kerberos.jaasConfigPath},
{"kerberos.principal", &config.kerberos.principal},
{"kerberos.realm", &config.kerberos.realm},
{"kerberos.keytab", &config.kerberos.keytabPath},
{"kerberos.ccache", &config.kerberos.ccachePath},
{"kerberos.password", &config.kerberos.password},
{"kerberos.service-name", &config.kerberos.serviceName},
{"kerberos.server-name", &config.kerberos.serverName},
{"kerberos.authorization-id", &config.kerberos.authorizationID},
{"kerberos.qop", &config.kerberos.qop},
}
for _, mapping := range stringMappings {
if value, ok, err := hoconString(parsed, prefix+mapping.path); err != nil {
return err
} else if ok {
*mapping.target = value
}
}
if value, ok, err := hoconBool(parsed, prefix+"tls.enabled"); err != nil {
return err
} else if ok {
config.ssl = value
}
if value, ok, err := hoconBool(parsed, prefix+"tls.hostname-verification"); err != nil {
return err
} else if ok {
config.hostVerification = value
}
if value, ok, err := hoconBool(parsed, prefix+"kerberos.enabled"); err != nil {
return err
} else if ok {
config.kerberos.enabled = value
}
if value, ok, err := hoconBool(parsed, prefix+"kerberos.disable-pafxfast"); err != nil {
return err
} else if ok {
config.kerberos.disablePAFXFAST = value
}
if value, ok, err := hoconBool(parsed, prefix+"kerberos.use-keytab"); err != nil {
return err
} else if ok {
config.kerberos.useKeytab = value
config.kerberos.useKeytabSet = true
}
if value, ok, err := hoconBool(parsed, prefix+"kerberos.use-ticket-cache"); err != nil {
return err
} else if ok {
config.kerberos.useTicketCache = value
config.kerberos.useTicketCacheSet = true
}
return nil
}
func normalizeLocalFilePath(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", nil
}
if strings.Contains(value, "://") || strings.HasPrefix(strings.ToLower(value), "file:") {
parsed, err := url.Parse(value)
if err != nil {
return "", err
}
if parsed.Scheme != "file" {
return "", fmt.Errorf("unsupported file URI scheme: %s", parsed.Scheme)
}
if parsed.Host != "" && !strings.EqualFold(parsed.Host, "localhost") {
return "", fmt.Errorf("remote file URI hosts are not supported: %s", parsed.Host)
}
value, err = url.PathUnescape(parsed.Path)
if err != nil {
return "", err
}
if runtime.GOOS == "windows" && len(value) >= 3 && value[0] == '/' && value[2] == ':' {
value = value[1:]
}
}
return filepath.Clean(filepath.FromSlash(value)), nil
}
func hoconString(config *hocon.Config, path string) (string, bool, error) {
if config.Get(path) == nil {
return "", false, nil
}
value, err := config.GetStringE(path)
if err != nil {
return "", false, fmt.Errorf("invalid %s: %w", path, err)
}
return strings.TrimSpace(value), true, nil
}
func firstHOCONString(config *hocon.Config, paths ...string) (string, bool, error) {
for _, path := range paths {
value, ok, err := hoconString(config, path)
if err != nil || ok {
return value, ok, err
}
}
return "", false, nil
}
func hoconStringMap(config *hocon.Config, path string) (map[string]string, bool, error) {
if config.Get(path) == nil {
return nil, false, nil
}
value, err := config.GetStringMapStringE(path)
if err != nil {
return nil, false, fmt.Errorf("invalid %s: %w", path, err)
}
return value, true, nil
}
func hoconDuration(config *hocon.Config, path string) (time.Duration, bool, error) {
if config.Get(path) == nil {
return 0, false, nil
}
value, err := config.GetDurationE(path)
if err != nil {
return 0, false, fmt.Errorf("invalid %s: %w", path, err)
}
return value, true, nil
}
func hoconInt(config *hocon.Config, path string) (int, bool, error) {
if config.Get(path) == nil {
return 0, false, nil
}
value, err := config.GetIntE(path)
if err != nil {
return 0, false, fmt.Errorf("invalid %s: %w", path, err)
}
return value, true, nil
}
func hoconBool(config *hocon.Config, path string) (bool, bool, error) {
value := config.Get(path)
if value == nil {
return false, false, nil
}
switch typed := value.(type) {
case hocon.Boolean:
return bool(typed), true, nil
case hocon.String:
parsed, err := strconv.ParseBool(string(typed))
if err != nil {
return false, false, fmt.Errorf("invalid %s: %w", path, err)
}
return parsed, true, nil
default:
return false, false, fmt.Errorf("invalid %s: expected boolean", path)
}
}
func firstHOCONBool(config *hocon.Config, paths ...string) (bool, bool, error) {
for _, path := range paths {
value, ok, err := hoconBool(config, path)
if err != nil || ok {
return value, ok, err
}
}
return false, false, nil
}
func hoconProtocolVersion(config *hocon.Config, path string) (int, bool, error) {
value := config.Get(path)
if value == nil {
return 0, false, nil
}
var raw string
switch typed := value.(type) {
case hocon.Int:
raw = strconv.Itoa(int(typed))
case hocon.String:
raw = string(typed)
default:
return 0, false, fmt.Errorf("invalid %s: expected V3, V4, or V5", path)
}
raw = strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(raw)), "V")
version, err := strconv.Atoi(raw)
if err != nil || version < 3 || version > 5 {
return 0, false, fmt.Errorf("invalid %s: expected V3, V4, or V5", path)
}
return version, true, nil
}

View File

@ -0,0 +1,173 @@
package main
import (
"net/url"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/gurkankaymak/hocon"
)
func TestCassandraConfigFileOverridesURLExceptEndpoint(t *testing.T) {
configPath := writeTestFile(t, "application.conf", `
datastax-java-driver {
basic {
contact-points = ["ignored.example.com:9042"]
session-keyspace = ignored_keyspace
request {
timeout = 2 seconds
consistency = LOCAL_ONE
serial-consistency = LOCAL_SERIAL
page-size = 321
}
load-balancing-policy {
class = BasicLoadBalancingPolicy
local-datacenter = dc-config
}
}
advanced {
connection {
connect-timeout = 3 seconds
pool.local.size = 4
}
socket {
tcp-no-delay = false
keep-alive = true
}
protocol.version = V4
retry-policy.class = FallthroughRetryPolicy
reconnection-policy {
class = ExponentialReconnectionPolicy
base-delay = 4 seconds
max-delay = 20 seconds
}
auth-provider {
class = PlainTextAuthProvider
username = file-user
password = file-password
}
ssl-engine-factory {
class = DefaultSslEngineFactory
hostname-validation = false
}
}
}
`)
config, err := parseCassandraConfig(connectParams{
Host: "url.example.com",
Database: "url_keyspace",
Username: "url-user",
Password: "url-password",
URLParams: url.Values{
"configfile": []string{configPath},
"requesttimeout": []string{"30s"},
"connecttimeout": []string{"31s"},
"consistency": []string{"QUORUM"},
"serialconsistency": []string{"SERIAL"},
"fetchsize": []string{"999"},
"localdatacenter": []string{"dc-url"},
"protocolversion": []string{"5"},
"numconns": []string{"2"},
"tcpnodelay": []string{"true"},
"keepalive": []string{"false"},
"retry": []string{"DefaultRetryPolicy"},
"reconnection": []string{"ConstantReconnectionPolicy((long)1)"},
"user": []string{"query-user"},
"password": []string{"query-password"},
}.Encode(),
})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(config.hosts, []string{"url.example.com"}) || config.keyspace != "url_keyspace" {
t.Fatalf("configfile must not replace endpoint or keyspace: %#v", config)
}
if config.username != "file-user" || config.password != "file-password" {
t.Fatalf("configfile credentials did not override URL values: %#v", config)
}
if config.requestTimeout != 2*time.Second || config.connectTimeout != 3*time.Second {
t.Fatalf("unexpected configfile timeouts: request=%s connect=%s", config.requestTimeout, config.connectTimeout)
}
if config.consistency != "LOCAL_ONE" || config.serialConsistency != "LOCAL_SERIAL" || config.pageSize != 321 {
t.Fatalf("unexpected request config: %#v", config)
}
if config.localDatacenter != "dc-config" || config.loadBalancingPolicy != "default" || config.protocolVersion != 4 {
t.Fatalf("unexpected topology/protocol config: %#v", config)
}
if config.numConnections != 4 || config.tcpNoDelay || !config.keepAlive {
t.Fatalf("unexpected connection/socket config: %#v", config)
}
if config.retryPolicy != "fallthrough" || config.reconnectionPolicy != "exponential" {
t.Fatalf("unexpected policy config: %#v", config)
}
if config.reconnectionBaseDelay != 4*time.Second || config.reconnectionMaxDelay != 20*time.Second {
t.Fatalf("unexpected reconnection delays: %#v", config)
}
if !config.ssl || config.hostVerification {
t.Fatalf("unexpected TLS config: %#v", config)
}
}
func TestCassandraConfigFileSupportsKerberosLoginOptionCasing(t *testing.T) {
parsed, err := hocon.ParseString(`
datastax-java-driver.advanced.auth-provider {
class = com.instaclustr.cassandra.driver.auth.KerberosAuthProvider
authorization-id = assumed_role
sasl-protocol = cassandra-custom
sasl-properties."javax.security.sasl.qop" = auth
login-configuration {
principal = "alice@EXAMPLE.COM"
keyTab = "/tmp/alice.keytab"
ticketCache = "FILE:/tmp/alice.ccache"
useKeyTab = true
useTicketCache = false
}
}
`)
if err != nil {
t.Fatal(err)
}
config := cassandraConfig{kerberos: defaultKerberosConfig()}
if err := applyJavaDriverHOCON(&config, parsed); err != nil {
t.Fatal(err)
}
if !config.kerberos.enabled || config.kerberos.principal != "alice@EXAMPLE.COM" {
t.Fatalf("unexpected Kerberos provider config: %#v", config.kerberos)
}
if config.kerberos.keytabPath != "/tmp/alice.keytab" || config.kerberos.ccachePath != "FILE:/tmp/alice.ccache" {
t.Fatalf("unexpected Kerberos file options: %#v", config.kerberos)
}
if !config.kerberos.useKeytab || !config.kerberos.useKeytabSet || config.kerberos.useTicketCache || !config.kerberos.useTicketCacheSet {
t.Fatalf("unexpected Kerberos credential switches: %#v", config.kerberos)
}
if config.kerberos.authorizationID != "assumed_role" || config.kerberos.serviceName != "cassandra-custom" || config.kerberos.qop != "auth" {
t.Fatalf("unexpected Kerberos SASL options: %#v", config.kerberos)
}
}
func TestMissingCassandraConfigFileIsIgnoredForJDBCCompatibility(t *testing.T) {
missingPath := filepath.Join(t.TempDir(), "missing.conf")
config, err := parseCassandraConfig(connectParams{
Host: "localhost",
URLParams: url.Values{"configfile": []string{missingPath}, "requesttimeout": []string{"2s"}}.Encode(),
})
if err != nil {
t.Fatal(err)
}
if config.requestTimeout != 2*time.Second {
t.Fatalf("missing configfile must leave URL options intact: %#v", config)
}
}
func writeTestFile(t *testing.T, name, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return path
}

View File

@ -0,0 +1,184 @@
package main
import (
"reflect"
"testing"
"time"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
func TestParseCassandraConfigSupportsLegacyJDBCOptions(t *testing.T) {
config, err := parseCassandraConfig(connectParams{
Host: "127.0.0.1",
Database: "app",
Username: "cassandra",
Password: "secret",
URLParams: "?localdatacenter=dc1&requesttimeout=10000&connecttimeout=5s&protocolversion=4&consistency=local_quorum&numconns=4",
})
if err != nil {
t.Fatal(err)
}
if len(config.hosts) != 1 || config.hosts[0] != "127.0.0.1" {
t.Fatalf("unexpected hosts: %#v", config.hosts)
}
if config.port != 9042 || config.keyspace != "app" {
t.Fatalf("unexpected endpoint: port=%d keyspace=%q", config.port, config.keyspace)
}
if config.localDatacenter != "dc1" || config.protocolVersion != 4 {
t.Fatalf("unexpected topology config: %#v", config)
}
if config.requestTimeout != 10*time.Second || config.connectTimeout != 5*time.Second {
t.Fatalf("unexpected timeouts: request=%s connect=%s", config.requestTimeout, config.connectTimeout)
}
if config.numConnections != 4 || !config.disableInitialHostLookup {
t.Fatalf("unexpected pool/tunnel config: %#v", config)
}
}
func TestParseCassandraConfigAcceptsConnectionString(t *testing.T) {
config, err := parseCassandraConfig(connectParams{
ConnectionString: "jdbc:cassandra://alice:secret@db.example.com:9142/catalog?protocolversion=5",
})
if err != nil {
t.Fatal(err)
}
if len(config.hosts) != 1 || config.hosts[0] != "db.example.com:9142" || config.port != 9142 {
t.Fatalf("unexpected endpoint: %#v", config)
}
if config.keyspace != "catalog" || config.username != "alice" || config.password != "secret" {
t.Fatalf("unexpected credentials/keyspace: %#v", config)
}
if config.protocolVersion != 5 {
t.Fatalf("unexpected protocol version: %d", config.protocolVersion)
}
}
func TestParseCassandraConfigCoversMappableJDBCWrapperOptions(t *testing.T) {
config, err := parseCassandraConfig(connectParams{
ConnectionString: "jdbc:cassandra://host1--host2:9142/catalog?" +
"user=query-user&password=query-secret&enablessl=true&hostnameverification=false&" +
"tcpnodelay=false&keepalive=true&debug=true&retries=7&retry=DefaultRetryPolicy&" +
"reconnection=ExponentialReconnectionPolicy((long)2,(long)30)&" +
"loadbalancing=TokenAwarePolicy&compliancemode=Liquibase",
})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(config.hosts, []string{"host1", "host2:9142"}) || config.port != 9142 {
t.Fatalf("unexpected multi-host endpoint: hosts=%#v port=%d", config.hosts, config.port)
}
if config.username != "query-user" || config.password != "query-secret" {
t.Fatalf("unexpected query credentials: %#v", config)
}
if !config.ssl || config.hostVerification || config.tcpNoDelay || !config.keepAlive || !config.debug {
t.Fatalf("unexpected transport options: %#v", config)
}
if config.retryPolicy != "simple" || config.retryCount != 7 || config.reconnectionPolicy != "exponential" {
t.Fatalf("unexpected retry options: %#v", config)
}
if config.reconnectionBaseDelay != 2*time.Second || config.reconnectionMaxDelay != 30*time.Second {
t.Fatalf("unexpected reconnection delays: %#v", config)
}
if config.loadBalancingPolicy != "token_aware" {
t.Fatalf("unexpected load-balancing option: %#v", config)
}
cluster, err := config.clusterConfig(config.keyspace)
if err != nil {
t.Fatal(err)
}
dialer, ok := cluster.Dialer.(cassandraDialer)
if !ok || dialer.tcpNoDelay || !dialer.keepAlive {
t.Fatalf("unexpected socket dialer: %#v", cluster.Dialer)
}
retryPolicy, ok := cluster.RetryPolicy.(*gocql.SimpleRetryPolicy)
if !ok || retryPolicy.NumRetries != 7 {
t.Fatalf("unexpected query retry policy: %#v", cluster.RetryPolicy)
}
reconnectionPolicy, ok := cluster.ReconnectionPolicy.(*gocql.ExponentialReconnectionPolicy)
if !ok || reconnectionPolicy.MaxRetries != 7 || reconnectionPolicy.InitialInterval != 2*time.Second || reconnectionPolicy.MaxInterval != 30*time.Second {
t.Fatalf("unexpected reconnection policy: %#v", cluster.ReconnectionPolicy)
}
}
func TestParseCassandraConfigUsesSecureTransportDefaults(t *testing.T) {
config, err := parseCassandraConfig(connectParams{Host: "127.0.0.1:9042", SSL: true})
if err != nil {
t.Fatal(err)
}
if !config.hostVerification || !config.tcpNoDelay || config.keepAlive {
t.Fatalf("unexpected defaults: %#v", config)
}
if !config.disableInitialHostLookup {
t.Fatal("loopback host with explicit port must disable peer discovery")
}
}
func TestParseCassandraConfigAcceptsDefaultSSLEngineFactory(t *testing.T) {
config, err := parseCassandraConfig(connectParams{
Host: "localhost",
URLParams: "sslenginefactory=com.datastax.oss.driver.internal.core.ssl.DefaultSslEngineFactory&usekrb5=false",
})
if err != nil {
t.Fatal(err)
}
if !config.ssl {
t.Fatal("default SSL engine factory must enable TLS")
}
}
func TestParseCassandraConfigRejectsCustomJavaImplementationClasses(t *testing.T) {
tests := []string{
"sslenginefactory=example.CustomSslEngineFactory",
"loadbalancing=example.CustomPolicy",
"retry=example.CustomRetryPolicy",
}
for _, urlParams := range tests {
if _, err := parseCassandraConfig(connectParams{Host: "localhost", URLParams: urlParams}); err == nil {
t.Fatalf("expected custom Java implementation rejection for %q", urlParams)
}
}
}
func TestParseReconnectionPolicySupportsFullyQualifiedClass(t *testing.T) {
policy, baseDelay, maxDelay, err := parseReconnectionPolicy(
"com.datastax.oss.driver.internal.core.connection.ExponentialReconnectionPolicy((long)1,(long)8)",
)
if err != nil {
t.Fatal(err)
}
if policy != "exponential" || baseDelay != time.Second || maxDelay != 8*time.Second {
t.Fatalf("unexpected policy: %s %s %s", policy, baseDelay, maxDelay)
}
}
func TestParseCassandraConfigRejectsUnsupportedLoadBalancingClass(t *testing.T) {
_, err := parseCassandraConfig(connectParams{
Host: "localhost",
URLParams: "loadbalancing=example.CustomPolicy",
})
if err == nil {
t.Fatal("expected unsupported load-balancing policy error")
}
}
func TestParseCassandraConfigRejectsCassandra20Protocol(t *testing.T) {
_, err := parseCassandraConfig(connectParams{
Host: "localhost",
URLParams: "protocolversion=2",
})
if err == nil {
t.Fatal("expected native protocol v2 rejection")
}
}
func TestParseDurationOptionTreatsBareNumbersAsMilliseconds(t *testing.T) {
duration, err := parseDurationOption("1500")
if err != nil {
t.Fatal(err)
}
if duration != 1500*time.Millisecond {
t.Fatalf("unexpected duration: %s", duration)
}
}

View File

@ -0,0 +1,48 @@
package main
import (
"context"
"net"
"time"
)
const cassandraKeepAlivePeriod = 30 * time.Second
type cassandraDialer struct {
timeout time.Duration
keepAlive bool
tcpNoDelay bool
}
func (dialer cassandraDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
keepAlivePeriod := time.Duration(-1)
if dialer.keepAlive {
keepAlivePeriod = cassandraKeepAlivePeriod
}
connection, err := (&net.Dialer{
Timeout: dialer.timeout,
KeepAlive: keepAlivePeriod,
}).DialContext(ctx, network, address)
if err != nil {
return nil, err
}
tcpConnection, ok := connection.(*net.TCPConn)
if !ok {
return connection, nil
}
if err := tcpConnection.SetNoDelay(dialer.tcpNoDelay); err != nil {
connection.Close()
return nil, err
}
if err := tcpConnection.SetKeepAlive(dialer.keepAlive); err != nil {
connection.Close()
return nil, err
}
if dialer.keepAlive {
if err := tcpConnection.SetKeepAlivePeriod(cassandraKeepAlivePeriod); err != nil {
connection.Close()
return nil, err
}
}
return connection, nil
}

View File

@ -0,0 +1,29 @@
module github.com/t8y2/dbx/agents/drivers/cassandra-go
go 1.22
require (
github.com/apache/cassandra-gocql-driver/v2 v2.1.2
github.com/datastax/gocql-astra/v2 v2.0.1
github.com/gurkankaymak/hocon v1.3.0
github.com/jcmturner/gofork v1.7.6
github.com/jcmturner/gokrb5/v8 v8.4.4
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/datastax/astra-client-go/v2 v2.2.54 // indirect
github.com/datastax/cql-proxy v0.1.6 // indirect
github.com/datastax/go-cassandra-native-protocol v0.0.0-20220706104457-5e8aad05cf90 // indirect
github.com/deepmap/oapi-codegen v1.12.4 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.6.0 // indirect
golang.org/x/net v0.7.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
)

View File

@ -0,0 +1,114 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apache/cassandra-gocql-driver/v2 v2.1.2 h1:lu/p0Db2av18enHJvWJQoChLssI0P+AR06STq4VdvCc=
github.com/apache/cassandra-gocql-driver/v2 v2.1.2/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/datastax/astra-client-go/v2 v2.2.54 h1:R2k9ek9zaU15cLD96np5gsj12oZhK3Z5/tSytjQagO8=
github.com/datastax/astra-client-go/v2 v2.2.54/go.mod h1:zxXWuqDkYia7PzFIL3T7RmjChc9LN81UnfI2yB4kE7M=
github.com/datastax/cql-proxy v0.1.6 h1:IFJ/QV5Hk25CVaqVzPAz9o3ZsczZKKE3htpeNk3/e9o=
github.com/datastax/cql-proxy v0.1.6/go.mod h1:AgfO39+s+6oSwMETjysTnr5dhDArYfiDoMz0J9RG72I=
github.com/datastax/go-cassandra-native-protocol v0.0.0-20220706104457-5e8aad05cf90 h1:SiFe3gwoHPt95ly6HLjwyyItxROxCUJuxqqTnguR5ac=
github.com/datastax/go-cassandra-native-protocol v0.0.0-20220706104457-5e8aad05cf90/go.mod h1:6FzirJfdffakAVqmHjwVfFkpru/gNbIazUOK5rIhndc=
github.com/datastax/gocql-astra/v2 v2.0.1 h1:yZo7pN51mn/D8LlA7FXFrkYeEw2OrsbGcAZLfY9dx6M=
github.com/datastax/gocql-astra/v2 v2.0.1/go.mod h1:dnBksaFCHOIHWqVqDploY3/J8G+0PO/9193VBphW0cA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deepmap/oapi-codegen v1.12.4 h1:pPmn6qI9MuOtCz82WY2Xaw46EQjgvxednXXrP7g5Q2s=
github.com/deepmap/oapi-codegen v1.12.4/go.mod h1:3lgHGMu6myQ2vqbbTXH2H1o4eXFTGnFiDaOaKKl5yas=
github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gurkankaymak/hocon v1.3.0 h1:OPw9XP+c3o4JGBVIS5e4+84YNfFFFgKQ625LX5tjSyQ=
github.com/gurkankaymak/hocon v1.3.0/go.mod h1:CM7yeDDq8AUU7I+QiLA7aVSJVbP1BVgZqqCiuzgcNS0=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pierrec/lz4/v4 v4.1.8 h1:ieHkV+i2BRzngO4Wd/3HGowuZStgq6QkPsD1eolNAO4=
github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.8.0 h1:CUhrE4N1rqSE6FM9ecihEjRkLQu8cDfgDyoOs83mEY4=
go.uber.org/atomic v1.8.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,206 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
)
func TestCassandraIntegration(t *testing.T) {
host := strings.TrimSpace(os.Getenv("CASSANDRA_TEST_HOST"))
if host == "" {
t.Skip("Cassandra integration environment is not configured")
}
port := 9042
if rawPort := strings.TrimSpace(os.Getenv("CASSANDRA_TEST_PORT")); rawPort != "" {
parsedPort, err := strconv.Atoi(rawPort)
if err != nil {
t.Fatal(err)
}
port = parsedPort
}
ssl, err := strconv.ParseBool(envDefault("CASSANDRA_TEST_SSL", "false"))
if err != nil {
t.Fatal(err)
}
connection := connectParams{
Host: host,
Port: port,
Username: os.Getenv("CASSANDRA_TEST_USERNAME"),
Password: os.Getenv("CASSANDRA_TEST_PASSWORD"),
URLParams: os.Getenv("CASSANDRA_TEST_URL_PARAMS"),
SSL: ssl,
CACertPath: os.Getenv("CASSANDRA_TEST_CA_CERT_PATH"),
ClientCertPath: os.Getenv("CASSANDRA_TEST_CLIENT_CERT_PATH"),
ClientKeyPath: os.Getenv("CASSANDRA_TEST_CLIENT_KEY_PATH"),
}
runtime, err := newConnectionRuntime(connection)
if err != nil {
t.Fatal(err)
}
defer runtime.close()
server := newServer(runtime, connection)
if err := server.validateConnection(); err != nil {
t.Fatal(err)
}
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
keyspace := "dbx_native_it_" + suffix
table := "all_types"
pagedTable := "paged_rows"
mustCQL(t, server, "CREATE KEYSPACE "+quoteCQLIdentifier(keyspace)+" WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}", "")
t.Cleanup(func() {
_, _ = server.executeQuery(queryOptions{SQL: "DROP KEYSPACE IF EXISTS " + quoteCQLIdentifier(keyspace)})
})
mustCQL(t, server, "CREATE TABLE "+qualifiedCQLName(keyspace, table)+" ("+
"id int PRIMARY KEY, txt text, flag boolean, amount decimal, payload blob, created timestamp, address inet, "+
"tags set<text>, items list<int>, attrs map<text, int>, pair frozen<tuple<int, text>>)", keyspace)
mustCQL(t, server, "CREATE INDEX "+quoteCQLIdentifier(table+"_txt_idx")+" ON "+qualifiedCQLName(keyspace, table)+" (txt)", keyspace)
mustCQL(t, server, "INSERT INTO "+qualifiedCQLName(keyspace, table)+" "+
"(id, txt, flag, amount, payload, created, address, tags, items, attrs, pair) VALUES "+
"(1, 'hello', true, 12.34, 0x00ff, '2026-08-03T00:00:00Z', '127.0.0.1', {'a', 'b'}, [1, 2], {'a': 1}, (7, 'seven'))", keyspace)
mustCQL(t, server, "CREATE TABLE "+qualifiedCQLName(keyspace, pagedTable)+" (id int PRIMARY KEY, txt text)", keyspace)
for start := 0; start < 250; start += 50 {
statements := make([]string, 0, 50)
for id := start; id < start+50; id++ {
statements = append(statements, fmt.Sprintf("INSERT INTO %s (id, txt) VALUES (%d, 'row-%d')", qualifiedCQLName(keyspace, pagedTable), id, id))
}
mustStatements(t, server, keyspace, statements, false)
}
mustStatements(t, server, keyspace, []string{
"INSERT INTO " + qualifiedCQLName(keyspace, pagedTable) + " (id, txt) VALUES (1001, 'unlogged')",
}, false)
mustStatements(t, server, keyspace, []string{
"INSERT INTO " + qualifiedCQLName(keyspace, pagedTable) + " (id, txt) VALUES (1002, 'logged')",
}, true)
connectionInfo, err := server.connectionInfo()
if err != nil || strings.TrimSpace(fmt.Sprint(connectionInfo["database_version"])) == "" {
t.Fatalf("connection info failed: info=%v err=%v", connectionInfo, err)
}
databases, err := server.listDatabases()
if err != nil || !containsDatabase(databases, keyspace) {
t.Fatalf("keyspace metadata missing: databases=%v err=%v", databases, err)
}
tables, err := server.listTables(keyspace, metadataListConstraints{})
if err != nil || !containsTable(tables, table) || !containsTable(tables, pagedTable) {
t.Fatalf("table metadata missing: tables=%v err=%v", tables, err)
}
columns, err := server.getColumns(keyspace, table)
if err != nil || len(columns) != 11 || !containsPrimaryKeyColumn(columns, "id") {
t.Fatalf("column metadata mismatch: columns=%v err=%v", columns, err)
}
indexes, err := server.listIndexes(keyspace, table)
if err != nil || !containsIndex(indexes, table+"_txt_idx") {
t.Fatalf("index metadata missing: indexes=%v err=%v", indexes, err)
}
ddl, err := server.getTableDDL(keyspace, table)
if err != nil || !strings.Contains(ddl, "tuple<int, text>") || !strings.Contains(ddl, "PRIMARY KEY") {
t.Fatalf("table DDL mismatch: ddl=%q err=%v", ddl, err)
}
result, err := server.executeQuery(queryOptions{
SQL: "SELECT * FROM " + qualifiedCQLName(keyspace, table) + " WHERE id = 1",
Schema: keyspace,
})
if err != nil || len(result.Rows) != 1 || len(result.Rows[0]) != len(result.Columns) {
t.Fatalf("all-types query failed: result=%v err=%v", result, err)
}
for _, value := range result.Rows[0] {
if value != nil {
if _, ok := value.(string); !ok {
t.Fatalf("legacy result contract requires strings, got %T (%v)", value, value)
}
}
}
page, err := server.executeQueryPage(queryOptions{
SQL: "SELECT id, txt FROM " + qualifiedCQLName(keyspace, pagedTable),
Schema: keyspace,
MaxRows: 250,
}, 100)
if err != nil || len(page.Rows) != 100 || !page.HasMore || page.SessionID == nil {
t.Fatalf("first page mismatch: page=%v err=%v", page, err)
}
totalRows := len(page.Rows)
for page.HasMore {
page, err = server.fetchQueryPage(*page.SessionID, 100)
if err != nil {
t.Fatal(err)
}
totalRows += len(page.Rows)
}
if totalRows != 250 {
t.Fatalf("unexpected paged row count: %d", totalRows)
}
}
func envDefault(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func qualifiedCQLName(keyspace, object string) string {
return quoteCQLIdentifier(keyspace) + "." + quoteCQLIdentifier(object)
}
func mustCQL(t *testing.T, server *server, sql, keyspace string) {
t.Helper()
if _, err := server.executeQuery(queryOptions{SQL: sql, Schema: keyspace}); err != nil {
t.Fatalf("execute %q: %v", sql, err)
}
}
func mustStatements(t *testing.T, server *server, keyspace string, statements []string, transactional bool) {
t.Helper()
rawStatements, _ := json.Marshal(statements)
rawSchema, _ := json.Marshal(keyspace)
if _, err := server.executeStatements(map[string]json.RawMessage{
"schema": rawSchema,
"statements": rawStatements,
}, transactional); err != nil {
t.Fatal(err)
}
}
func containsDatabase(databases []databaseInfo, name string) bool {
for _, database := range databases {
if database.Name == name {
return true
}
}
return false
}
func containsTable(tables []tableInfo, name string) bool {
for _, table := range tables {
if table.Name == name {
return true
}
}
return false
}
func containsIndex(indexes []indexInfo, name string) bool {
for _, index := range indexes {
if index.Name == name {
return true
}
}
return false
}
func containsPrimaryKeyColumn(columns []columnInfo, name string) bool {
for _, column := range columns {
if column.Name == name && column.IsPrimaryKey {
return true
}
}
return false
}

View File

@ -0,0 +1,634 @@
package main
import (
"encoding/binary"
"fmt"
"net"
"os"
"os/user"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
gocql "github.com/apache/cassandra-gocql-driver/v2"
"github.com/jcmturner/gofork/encoding/asn1"
"github.com/jcmturner/gokrb5/v8/asn1tools"
krb5client "github.com/jcmturner/gokrb5/v8/client"
krb5config "github.com/jcmturner/gokrb5/v8/config"
"github.com/jcmturner/gokrb5/v8/credentials"
"github.com/jcmturner/gokrb5/v8/gssapi"
"github.com/jcmturner/gokrb5/v8/iana/chksumtype"
"github.com/jcmturner/gokrb5/v8/iana/keyusage"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/jcmturner/gokrb5/v8/messages"
"github.com/jcmturner/gokrb5/v8/types"
)
const (
kerberosAPRequestTokenID = 0x0100
kerberosGSSAPITag = 0x60
kerberosSecurityNone = 0x01
)
type kerberosCredentialMode int
const (
kerberosCredentialNone kerberosCredentialMode = iota
kerberosCredentialPassword
kerberosCredentialKeytab
kerberosCredentialCCache
)
type kerberosConfig struct {
enabled bool
configPath string
jaasConfigPath string
principal string
realm string
keytabPath string
ccachePath string
password string
serviceName string
serverName string
authorizationID string
qop string
disablePAFXFAST bool
useKeytab bool
useKeytabSet bool
useTicketCache bool
useTicketCacheSet bool
credentialMode kerberosCredentialMode
credentialUser string
credentialRealm string
}
type kerberosAuthenticator struct {
domain string
clientName types.PrincipalName
ticket messages.Ticket
sessionKey types.EncryptionKey
authorizationID string
step int
}
var (
jaasBlockPattern = regexp.MustCompile(`(?is)\bCassandraJavaClient\s*\{(.*?)\}\s*;`)
jaasModulePattern = regexp.MustCompile(`(?is)\bcom\.sun\.security\.auth\.module\.Krb5LoginModule\b(.*?);`)
jaasOptionPattern = regexp.MustCompile(`(?is)([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s;]+))`)
)
func defaultKerberosConfig() kerberosConfig {
return kerberosConfig{
serviceName: "cassandra",
qop: "auth",
}
}
func (config *kerberosConfig) finalize(username, password string) error {
config.applyJavaSystemProperties()
if config.jaasConfigPath != "" {
path, err := normalizeLocalFilePath(config.jaasConfigPath)
if err != nil {
return fmt.Errorf("invalid Cassandra JAAS config path: %w", err)
}
config.jaasConfigPath = path
if err := config.applyJAASConfig(path); err != nil {
return err
}
}
config.applyKerberosConfigEnvironment()
if config.configPath == "" {
config.configPath = defaultKerberosConfigPath()
}
path, err := normalizeLocalFilePath(firstPathListEntry(config.configPath))
if err != nil {
return fmt.Errorf("invalid Kerberos config path: %w", err)
}
config.configPath = path
if err := requireRegularFile("Kerberos config", config.configPath); err != nil {
return err
}
krbConfig, err := krb5config.Load(config.configPath)
if err != nil {
return fmt.Errorf("load Kerberos config %s: %w", config.configPath, err)
}
if config.serviceName == "" {
config.serviceName = "cassandra"
}
if !kerberosQOPIncludesAuth(config.qop) {
return fmt.Errorf("Cassandra Kerberos currently supports SASL QOP auth only, got %s", config.qop)
}
config.qop = "auth"
if config.principal == "" {
config.principal = strings.TrimSpace(username)
}
if config.password == "" {
config.password = password
}
if config.useTicketCache {
return config.selectCCacheCredential()
}
if config.useKeytab {
return config.selectKeytabCredential(krbConfig)
}
if config.ccachePath != "" && !config.useTicketCacheSet {
return config.selectCCacheCredential()
}
if config.keytabPath != "" && !config.useKeytabSet {
return config.selectKeytabCredential(krbConfig)
}
if config.principal != "" && config.password != "" {
config.credentialUser, config.credentialRealm, err = splitKerberosPrincipal(
config.principal,
config.realm,
krbConfig.LibDefaults.DefaultRealm,
)
if err != nil {
return err
}
config.credentialMode = kerberosCredentialPassword
return nil
}
if !config.useTicketCacheSet {
config.ccachePath = os.Getenv("KRB5CCNAME")
if config.ccachePath == "" {
defaultCache := defaultKerberosCCachePath()
if path, normalizeErr := normalizeKerberosCachePath(defaultCache); normalizeErr == nil {
if info, statErr := os.Stat(path); statErr == nil && info.Mode().IsRegular() {
config.ccachePath = defaultCache
}
}
}
if config.ccachePath != "" {
return config.selectCCacheCredential()
}
}
if !config.useKeytabSet {
config.keytabPath = firstNonEmpty(os.Getenv("KRB5_CLIENT_KTNAME"), os.Getenv("KRB5_KTNAME"))
if config.keytabPath != "" {
return config.selectKeytabCredential(krbConfig)
}
}
return fmt.Errorf("Kerberos authentication requires a credential cache, keytab, or principal and password")
}
func (config *kerberosConfig) selectCCacheCredential() error {
var err error
if config.ccachePath == "" {
config.ccachePath = defaultKerberosCCachePath()
}
config.ccachePath, err = normalizeKerberosCachePath(config.ccachePath)
if err != nil {
return err
}
if err := requireRegularFile("Kerberos credential cache", config.ccachePath); err != nil {
return err
}
config.credentialMode = kerberosCredentialCCache
return nil
}
func (config *kerberosConfig) selectKeytabCredential(krbConfig *krb5config.Config) error {
var err error
if config.keytabPath == "" {
config.keytabPath = firstNonEmpty(os.Getenv("KRB5_CLIENT_KTNAME"), os.Getenv("KRB5_KTNAME"))
if config.keytabPath == "" {
return fmt.Errorf("Kerberos keytab authentication requires a keytab path")
}
}
config.keytabPath, err = normalizeKerberosFileReference(config.keytabPath)
if err != nil {
return err
}
if err := requireRegularFile("Kerberos keytab", config.keytabPath); err != nil {
return err
}
if config.principal == "" {
config.principal, err = principalFromKeytab(config.keytabPath)
if err != nil {
return err
}
}
config.credentialUser, config.credentialRealm, err = splitKerberosPrincipal(
config.principal,
config.realm,
krbConfig.LibDefaults.DefaultRealm,
)
if err != nil {
return err
}
config.credentialMode = kerberosCredentialKeytab
return nil
}
func newKerberosAuthProvider(
config kerberosConfig,
username string,
password string,
) (func(*gocql.HostInfo) (gocql.Authenticator, error), error) {
if !config.enabled {
return nil, fmt.Errorf("Kerberos authentication is not enabled")
}
if config.credentialMode == kerberosCredentialNone {
if err := config.finalize(username, password); err != nil {
return nil, err
}
}
krbConfig, err := krb5config.Load(config.configPath)
if err != nil {
return nil, fmt.Errorf("load Kerberos config %s: %w", config.configPath, err)
}
return func(host *gocql.HostInfo) (gocql.Authenticator, error) {
return newKerberosAuthenticator(config, krbConfig, host)
}, nil
}
func newKerberosAuthenticator(
config kerberosConfig,
krbConfig *krb5config.Config,
host *gocql.HostInfo,
) (gocql.Authenticator, error) {
client, err := newKerberosClient(config, krbConfig)
if err != nil {
return nil, err
}
if err := client.Login(); err != nil {
client.Destroy()
return nil, fmt.Errorf("Kerberos login failed: %w", err)
}
serverName, err := kerberosServerName(config, host)
if err != nil {
client.Destroy()
return nil, err
}
servicePrincipal := config.serviceName + "/" + serverName
ticket, sessionKey, err := client.GetServiceTicket(servicePrincipal)
if err != nil {
client.Destroy()
return nil, fmt.Errorf("get Kerberos service ticket for %s: %w", servicePrincipal, err)
}
clientName := client.Credentials.CName()
clientName.NameString = append([]string(nil), clientName.NameString...)
authenticator := &kerberosAuthenticator{
domain: strings.Clone(client.Credentials.Domain()),
clientName: clientName,
ticket: ticket,
sessionKey: sessionKey,
authorizationID: config.authorizationID,
}
client.Destroy()
return authenticator, nil
}
func newKerberosClient(config kerberosConfig, krbConfig *krb5config.Config) (*krb5client.Client, error) {
settings := []func(*krb5client.Settings){krb5client.DisablePAFXFAST(config.disablePAFXFAST)}
switch config.credentialMode {
case kerberosCredentialCCache:
cache, err := credentials.LoadCCache(config.ccachePath)
if err != nil {
return nil, fmt.Errorf("load Kerberos credential cache %s: %w", config.ccachePath, err)
}
client, err := krb5client.NewFromCCache(cache, krbConfig, settings...)
if err != nil {
return nil, fmt.Errorf("create Kerberos client from credential cache: %w", err)
}
return client, nil
case kerberosCredentialKeytab:
loadedKeytab, err := keytab.Load(config.keytabPath)
if err != nil {
return nil, fmt.Errorf("load Kerberos keytab %s: %w", config.keytabPath, err)
}
return krb5client.NewWithKeytab(
config.credentialUser,
config.credentialRealm,
loadedKeytab,
krbConfig,
settings...,
), nil
case kerberosCredentialPassword:
return krb5client.NewWithPassword(
config.credentialUser,
config.credentialRealm,
config.password,
krbConfig,
settings...,
), nil
default:
return nil, fmt.Errorf("Kerberos credentials are not configured")
}
}
func (authenticator *kerberosAuthenticator) Challenge(request []byte) ([]byte, gocql.Authenticator, error) {
switch authenticator.step {
case 0:
token, err := authenticator.initialToken()
if err != nil {
return nil, nil, err
}
authenticator.step = 1
return token, authenticator, nil
case 1:
token, err := authenticator.securityLayerResponse(request)
if err != nil {
return nil, nil, err
}
authenticator.step = 2
return token, authenticator, nil
default:
return nil, nil, fmt.Errorf("unexpected Cassandra Kerberos authentication challenge")
}
}
func (authenticator *kerberosAuthenticator) Success(_ []byte) error {
if authenticator.step != 2 {
return fmt.Errorf("Cassandra reported Kerberos success before SASL negotiation completed")
}
return nil
}
func (authenticator *kerberosAuthenticator) initialToken() ([]byte, error) {
value, err := types.NewAuthenticator(authenticator.domain, authenticator.clientName)
if err != nil {
return nil, err
}
value.Cksum = types.Checksum{
CksumType: chksumtype.GSSAPI,
Checksum: kerberosAuthenticatorChecksum(),
}
request, err := messages.NewAPReq(authenticator.ticket, authenticator.sessionKey, value)
if err != nil {
return nil, err
}
payload := make([]byte, 2)
binary.BigEndian.PutUint16(payload, kerberosAPRequestTokenID)
encodedRequest, err := request.Marshal()
if err != nil {
return nil, err
}
payload = append(payload, encodedRequest...)
encodedOID, err := asn1.Marshal(gssapi.OIDKRB5.OID())
if err != nil {
return nil, err
}
header := append([]byte{kerberosGSSAPITag}, asn1tools.MarshalLengthBytes(len(encodedOID)+len(payload))...)
header = append(header, encodedOID...)
return append(header, payload...), nil
}
func (authenticator *kerberosAuthenticator) securityLayerResponse(challenge []byte) ([]byte, error) {
var request gssapi.WrapToken
if err := request.Unmarshal(challenge, true); err != nil {
return nil, fmt.Errorf("decode Kerberos SASL security-layer challenge: %w", err)
}
valid, err := request.Verify(authenticator.sessionKey, keyusage.GSSAPI_ACCEPTOR_SEAL)
if err != nil {
return nil, fmt.Errorf("verify Kerberos SASL security-layer challenge: %w", err)
}
if !valid {
return nil, fmt.Errorf("invalid Kerberos SASL security-layer challenge")
}
if len(request.Payload) < 4 || request.Payload[0]&kerberosSecurityNone == 0 {
return nil, fmt.Errorf("Cassandra Kerberos server does not allow SASL QOP auth")
}
payload := []byte{kerberosSecurityNone, 0, 0, 0}
payload = append(payload, authenticator.authorizationID...)
response, err := gssapi.NewInitiatorWrapToken(payload, authenticator.sessionKey)
if err != nil {
return nil, err
}
return response.Marshal()
}
func kerberosAuthenticatorChecksum() []byte {
checksum := make([]byte, 24)
binary.LittleEndian.PutUint32(checksum[:4], 16)
flags := uint32(gssapi.ContextFlagInteg | gssapi.ContextFlagConf)
binary.LittleEndian.PutUint32(checksum[20:24], flags)
return checksum
}
func kerberosQOPIncludesAuth(value string) bool {
for _, qop := range strings.Split(value, ",") {
if strings.EqualFold(strings.TrimSpace(qop), "auth") {
return true
}
}
return false
}
func kerberosServerName(config kerberosConfig, host *gocql.HostInfo) (string, error) {
if config.serverName != "" {
return strings.TrimSuffix(strings.TrimSpace(config.serverName), "."), nil
}
if host == nil {
return "", fmt.Errorf("resolve Kerberos server name: Cassandra host is unavailable")
}
address := host.ConnectAddress()
if address != nil {
names, err := net.LookupAddr(address.String())
if err == nil && len(names) > 0 {
return strings.TrimSuffix(strings.TrimSpace(names[0]), "."), nil
}
}
hostname, _, err := net.SplitHostPort(host.HostnameAndPort())
if err == nil && hostname != "" && net.ParseIP(hostname) == nil {
return strings.TrimSuffix(hostname, "."), nil
}
return "", fmt.Errorf("resolve Kerberos server name for Cassandra host %s; configure kerberosservername explicitly", host.ConnectAddressAndPort())
}
func (config *kerberosConfig) applyJavaSystemProperties() {
if config.jaasConfigPath == "" {
config.jaasConfigPath = javaSystemProperty("java.security.auth.login.config")
}
if config.configPath == "" {
config.configPath = javaSystemProperty("java.security.krb5.conf")
}
}
func (config *kerberosConfig) applyKerberosConfigEnvironment() {
if config.configPath == "" {
config.configPath = os.Getenv("KRB5_CONFIG")
}
}
func (config *kerberosConfig) applyJAASConfig(path string) error {
contents, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read Cassandra JAAS config %s: %w", path, err)
}
block := jaasBlockPattern.FindSubmatch(contents)
if len(block) != 2 {
return fmt.Errorf("Cassandra JAAS config %s does not contain CassandraJavaClient", path)
}
module := jaasModulePattern.FindSubmatch(block[1])
if len(module) != 2 {
return fmt.Errorf("CassandraJavaClient in %s does not configure Krb5LoginModule", path)
}
options := map[string]string{}
for _, match := range jaasOptionPattern.FindAllSubmatch(module[1], -1) {
value := firstNonEmpty(string(match[2]), string(match[3]), string(match[4]))
options[strings.ToLower(string(match[1]))] = value
}
if config.principal == "" {
config.principal = options["principal"]
}
if config.keytabPath == "" {
config.keytabPath = options["keytab"]
}
if config.ccachePath == "" {
config.ccachePath = options["ticketcache"]
}
if value, ok := options["usekeytab"]; ok {
config.useKeytab, err = strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid useKeyTab in Cassandra JAAS config: %w", err)
}
config.useKeytabSet = true
}
if value, ok := options["useticketcache"]; ok {
config.useTicketCache, err = strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid useTicketCache in Cassandra JAAS config: %w", err)
}
config.useTicketCacheSet = true
}
return nil
}
func javaSystemProperty(name string) string {
pattern := regexp.MustCompile(`(?:^|\s)-D` + regexp.QuoteMeta(name) + `=(?:"([^"]*)"|'([^']*)'|(\S+))`)
for _, environmentName := range []string{"JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS"} {
match := pattern.FindStringSubmatch(os.Getenv(environmentName))
if len(match) == 4 {
return firstNonEmpty(match[1], match[2], match[3])
}
}
return ""
}
func normalizeKerberosCachePath(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", fmt.Errorf("Kerberos ticket cache path is empty")
}
if separator := strings.IndexByte(value, ':'); separator > 0 && !isWindowsDrivePath(value) {
cacheType := strings.ToUpper(value[:separator])
if cacheType != "FILE" {
return "", fmt.Errorf("Kerberos credential cache type %s is not supported; use a FILE cache or keytab", cacheType)
}
value = value[separator+1:]
}
path, err := normalizeLocalFilePath(value)
if err != nil {
return "", fmt.Errorf("invalid Kerberos credential cache path: %w", err)
}
return path, nil
}
func isWindowsDrivePath(value string) bool {
return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) &&
value[1] == ':' && (value[2] == '\\' || value[2] == '/')
}
func normalizeKerberosFileReference(raw string) (string, error) {
value := strings.TrimSpace(raw)
if strings.HasPrefix(strings.ToUpper(value), "FILE:") {
value = value[5:]
}
path, err := normalizeLocalFilePath(value)
if err != nil {
return "", fmt.Errorf("invalid Kerberos file path: %w", err)
}
return path, nil
}
func splitKerberosPrincipal(principal, configuredRealm, defaultRealm string) (string, string, error) {
value := strings.TrimSpace(principal)
realm := strings.TrimSpace(configuredRealm)
if separator := strings.LastIndexByte(value, '@'); separator >= 0 {
realm = value[separator+1:]
value = value[:separator]
}
if value == "" {
return "", "", fmt.Errorf("Kerberos principal is empty")
}
if realm == "" {
realm = strings.TrimSpace(defaultRealm)
}
if realm == "" {
return "", "", fmt.Errorf("Kerberos realm is required for principal %s", principal)
}
return value, realm, nil
}
func principalFromKeytab(path string) (string, error) {
loadedKeytab, err := keytab.Load(path)
if err != nil {
return "", fmt.Errorf("load Kerberos keytab %s: %w", path, err)
}
principals := map[string]struct{}{}
for _, entry := range loadedKeytab.Entries {
principals[entry.Principal.String()] = struct{}{}
}
if len(principals) != 1 {
return "", fmt.Errorf("Kerberos keytab %s contains %d principals; configure kerberosprincipal explicitly", path, len(principals))
}
for principal := range principals {
return principal, nil
}
return "", fmt.Errorf("Kerberos keytab %s contains no principals", path)
}
func defaultKerberosConfigPath() string {
if runtime.GOOS == "windows" {
if windowsDirectory := os.Getenv("WINDIR"); windowsDirectory != "" {
return filepath.Join(windowsDirectory, "krb5.ini")
}
}
return "/etc/krb5.conf"
}
func defaultKerberosCCachePath() string {
if value := os.Getenv("KRB5CCNAME"); value != "" {
return value
}
currentUser, err := user.Current()
if err == nil && currentUser.Uid != "" {
return filepath.Join(os.TempDir(), "krb5cc_"+currentUser.Uid)
}
return ""
}
func firstPathListEntry(value string) string {
entries := filepath.SplitList(value)
if len(entries) == 0 {
return value
}
return entries[0]
}
func requireRegularFile(label, path string) error {
if path == "" {
return fmt.Errorf("%s path is empty", label)
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("read %s %s: %w", label, path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s is not a regular file: %s", label, path)
}
return nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,307 @@
package main
import (
"bytes"
"encoding/binary"
"net"
"path/filepath"
"strings"
"testing"
gocql "github.com/apache/cassandra-gocql-driver/v2"
"github.com/jcmturner/gofork/encoding/asn1"
"github.com/jcmturner/gokrb5/v8/gssapi"
"github.com/jcmturner/gokrb5/v8/iana/keyusage"
"github.com/jcmturner/gokrb5/v8/messages"
"github.com/jcmturner/gokrb5/v8/types"
)
func TestKerberosPasswordCredentialsTakePrecedenceOverEnvironmentCache(t *testing.T) {
clearKerberosEnvironment(t)
t.Setenv("KRB5CCNAME", filepath.Join(t.TempDir(), "missing.ccache"))
config := defaultKerberosConfig()
config.enabled = true
config.configPath = writeKerberosConfig(t)
config.principal = "alice@EXAMPLE.COM"
config.password = "secret"
if err := config.finalize("", ""); err != nil {
t.Fatal(err)
}
if config.credentialMode != kerberosCredentialPassword || config.credentialUser != "alice" || config.credentialRealm != "EXAMPLE.COM" {
t.Fatalf("unexpected password credential selection: %#v", config)
}
}
func TestKerberosExplicitCredentialSourcesTakePrecedence(t *testing.T) {
clearKerberosEnvironment(t)
krb5Path := writeKerberosConfig(t)
cachePath := writeTestFile(t, "alice.ccache", "placeholder")
keytabPath := writeTestFile(t, "alice.keytab", "placeholder")
t.Run("ccache", func(t *testing.T) {
config := defaultKerberosConfig()
config.enabled = true
config.configPath = krb5Path
config.ccachePath = "FILE:" + cachePath
config.principal = "alice@EXAMPLE.COM"
config.password = "ignored"
if err := config.finalize("", ""); err != nil {
t.Fatal(err)
}
if config.credentialMode != kerberosCredentialCCache || config.ccachePath != cachePath {
t.Fatalf("unexpected ccache credential selection: %#v", config)
}
})
t.Run("keytab", func(t *testing.T) {
config := defaultKerberosConfig()
config.enabled = true
config.configPath = krb5Path
config.keytabPath = "FILE:" + keytabPath
config.principal = "alice@EXAMPLE.COM"
config.password = "ignored"
if err := config.finalize("", ""); err != nil {
t.Fatal(err)
}
if config.credentialMode != kerberosCredentialKeytab || config.keytabPath != keytabPath {
t.Fatalf("unexpected keytab credential selection: %#v", config)
}
})
}
func TestKerberosDiscoversJavaJAASAndKrb5Properties(t *testing.T) {
clearKerberosEnvironment(t)
krb5Path := writeKerberosConfig(t)
cachePath := writeTestFile(t, "alice.ccache", "placeholder")
jaasPath := writeTestFile(t, "jaas.conf", `
CassandraJavaClient {
com.sun.security.auth.module.Krb5LoginModule required
useTicketCache=true
ticketCache="FILE:`+cachePath+`"
principal="alice@EXAMPLE.COM";
};
`)
t.Setenv("JAVA_TOOL_OPTIONS", `-Djava.security.krb5.conf="`+krb5Path+`" -Djava.security.auth.login.config='`+jaasPath+`'`)
config := defaultKerberosConfig()
config.enabled = true
if err := config.finalize("", ""); err != nil {
t.Fatal(err)
}
if config.configPath != krb5Path || config.jaasConfigPath != jaasPath {
t.Fatalf("Java system properties were not applied: %#v", config)
}
if config.credentialMode != kerberosCredentialCCache || config.ccachePath != cachePath || config.principal != "alice@EXAMPLE.COM" {
t.Fatalf("JAAS credential cache was not applied: %#v", config)
}
}
func TestKerberosRejectsUnsupportedQOP(t *testing.T) {
clearKerberosEnvironment(t)
config := defaultKerberosConfig()
config.enabled = true
config.configPath = writeKerberosConfig(t)
config.principal = "alice@EXAMPLE.COM"
config.password = "secret"
config.qop = "auth-conf"
err := config.finalize("", "")
if err == nil || !strings.Contains(err.Error(), "supports SASL QOP auth only") {
t.Fatalf("expected QOP rejection, got %v", err)
}
}
func TestKerberosAcceptsQOPPreferenceListContainingAuth(t *testing.T) {
clearKerberosEnvironment(t)
config := defaultKerberosConfig()
config.enabled = true
config.configPath = writeKerberosConfig(t)
config.principal = "alice@EXAMPLE.COM"
config.password = "secret"
config.qop = "auth-conf, auth"
if err := config.finalize("", ""); err != nil {
t.Fatal(err)
}
if config.qop != "auth" {
t.Fatalf("unexpected negotiated QOP preference: %q", config.qop)
}
}
func TestNormalizeKerberosCachePathSupportsWindowsDrivePaths(t *testing.T) {
path, err := normalizeKerberosCachePath(`C:\Users\alice\krb5cc`)
if err != nil {
t.Fatal(err)
}
if path != `C:\Users\alice\krb5cc` {
t.Fatalf("unexpected Windows cache path: %q", path)
}
if _, err := normalizeKerberosCachePath("DIR:/tmp/krb5cc"); err == nil {
t.Fatal("expected non-FILE credential cache type rejection")
}
}
func TestKerberosServerNameSupportsExplicitOverride(t *testing.T) {
name, err := kerberosServerName(kerberosConfig{serverName: "node1.example.com."}, nil)
if err != nil {
t.Fatal(err)
}
if name != "node1.example.com" {
t.Fatalf("unexpected explicit server name: %q", name)
}
if _, err := kerberosServerName(kerberosConfig{}, nil); err == nil {
t.Fatal("expected missing host error")
}
host, err := gocql.NewHostInfoFromAddrPort(net.ParseIP("127.0.0.1"), 9042)
if err != nil {
t.Fatal(err)
}
if resolved, err := kerberosServerName(kerberosConfig{}, host); err != nil || strings.TrimSpace(resolved) == "" {
t.Fatalf("expected loopback canonical name, got %q, %v", resolved, err)
}
}
func TestKerberosInitialTokenContainsDecryptableAPRequest(t *testing.T) {
key := testKerberosEncryptionKey()
authenticator := kerberosAuthenticator{
domain: "EXAMPLE.COM",
clientName: types.NewPrincipalName(1, "alice"),
ticket: messages.Ticket{
TktVNO: 5,
Realm: "EXAMPLE.COM",
SName: types.NewPrincipalName(2, "cassandra/node1.example.com"),
EncPart: types.EncryptedData{
EType: key.KeyType,
KVNO: 1,
Cipher: []byte{1},
},
},
sessionKey: key,
}
token, err := authenticator.initialToken()
if err != nil {
t.Fatal(err)
}
if len(token) < 2 || token[0] != kerberosGSSAPITag {
t.Fatalf("unexpected GSSAPI token prefix: %x", token)
}
encodedOID, err := asn1.Marshal(gssapi.OIDKRB5.OID())
if err != nil {
t.Fatal(err)
}
oidOffset := bytes.Index(token, encodedOID)
if oidOffset < 0 {
t.Fatalf("Kerberos OID missing from token: %x", token)
}
payload := token[oidOffset+len(encodedOID):]
if len(payload) < 3 || binary.BigEndian.Uint16(payload[:2]) != kerberosAPRequestTokenID {
t.Fatalf("unexpected Kerberos mechanism token: %x", payload)
}
var request messages.APReq
if err := request.Unmarshal(payload[2:]); err != nil {
t.Fatal(err)
}
if err := request.DecryptAuthenticator(key); err != nil {
t.Fatal(err)
}
if request.Authenticator.CName.PrincipalNameString() != "alice" || request.Authenticator.CRealm != "EXAMPLE.COM" {
t.Fatalf("unexpected AP-REQ authenticator: %#v", request.Authenticator)
}
}
func TestKerberosSecurityLayerNegotiatesAuthAndAuthorizationID(t *testing.T) {
key := testKerberosEncryptionKey()
challenge := marshalKerberosAcceptorToken(t, key, []byte{0x07, 0x00, 0x10, 0x00})
authenticator := kerberosAuthenticator{sessionKey: key, authorizationID: "assumed_role"}
response, err := authenticator.securityLayerResponse(challenge)
if err != nil {
t.Fatal(err)
}
var decoded gssapi.WrapToken
if err := decoded.Unmarshal(response, false); err != nil {
t.Fatal(err)
}
valid, err := decoded.Verify(key, keyusage.GSSAPI_INITIATOR_SEAL)
if err != nil || !valid {
t.Fatalf("invalid security-layer response: valid=%t err=%v", valid, err)
}
want := append([]byte{kerberosSecurityNone, 0, 0, 0}, []byte("assumed_role")...)
if !bytes.Equal(decoded.Payload, want) {
t.Fatalf("unexpected security-layer payload: %x", decoded.Payload)
}
}
func TestKerberosSecurityLayerRejectsUnavailableAuthQOP(t *testing.T) {
key := testKerberosEncryptionKey()
challenge := marshalKerberosAcceptorToken(t, key, []byte{0x02, 0, 0, 0})
authenticator := kerberosAuthenticator{sessionKey: key}
if _, err := authenticator.securityLayerResponse(challenge); err == nil {
t.Fatal("expected server QOP rejection")
}
}
func TestKerberosAuthenticatorChecksumRequestsIntegrityAndConfidentiality(t *testing.T) {
checksum := kerberosAuthenticatorChecksum()
if len(checksum) != 24 || binary.LittleEndian.Uint32(checksum[:4]) != 16 {
t.Fatalf("unexpected channel-binding checksum: %x", checksum)
}
wantFlags := uint32(gssapi.ContextFlagInteg | gssapi.ContextFlagConf)
if flags := binary.LittleEndian.Uint32(checksum[20:24]); flags != wantFlags {
t.Fatalf("unexpected GSSAPI context flags: %x", flags)
}
}
func clearKerberosEnvironment(t *testing.T) {
t.Helper()
for _, name := range []string{
"JAVA_TOOL_OPTIONS",
"_JAVA_OPTIONS",
"JDK_JAVA_OPTIONS",
"KRB5_CONFIG",
"KRB5CCNAME",
"KRB5_CLIENT_KTNAME",
"KRB5_KTNAME",
} {
t.Setenv(name, "")
}
}
func writeKerberosConfig(t *testing.T) string {
t.Helper()
return writeTestFile(t, "krb5.conf", `
[libdefaults]
default_realm = EXAMPLE.COM
dns_lookup_realm = false
dns_lookup_kdc = false
[realms]
EXAMPLE.COM = {
kdc = 127.0.0.1:88
}
`)
}
func testKerberosEncryptionKey() types.EncryptionKey {
return types.EncryptionKey{KeyType: 18, KeyValue: bytes.Repeat([]byte{0x42}, 32)}
}
func marshalKerberosAcceptorToken(t *testing.T, key types.EncryptionKey, payload []byte) []byte {
t.Helper()
token := gssapi.WrapToken{
Flags: 0x01,
EC: 12,
Payload: payload,
}
if err := token.SetCheckSum(key, keyusage.GSSAPI_ACCEPTOR_SEAL); err != nil {
t.Fatal(err)
}
encoded, err := token.Marshal()
if err != nil {
t.Fatal(err)
}
return encoded
}

View File

@ -0,0 +1,553 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
)
const (
protocolVersion = 2
defaultMaxRows = 10000
defaultPageSize = 500
legacyAgentSessionID = "__legacy__"
maxAgentSessions = 256
defaultConnectTimeout = 15 * time.Second
)
type request struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params map[string]json.RawMessage `json:"params"`
}
type response struct {
JSONRPC string `json:"jsonrpc,omitempty"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type connectParams struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
URLParams string `json:"url_params"`
ConnectionString string `json:"connection_string"`
SSL bool `json:"ssl"`
CACertPath string `json:"ca_cert_path"`
ClientCertPath string `json:"client_cert_path"`
ClientKeyPath string `json:"client_key_path"`
SessionRole string `json:"sessionRole"`
}
type queryOptions struct {
SQL string `json:"sql"`
Database string `json:"database"`
Schema string `json:"schema"`
MaxRows int `json:"maxRows"`
FetchSize int `json:"fetchSize"`
TimeoutSecs int `json:"timeoutSecs"`
}
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"`
}
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"`
}
type querySession struct {
sql string
keyspace string
pageState []byte
remaining int
}
type server struct {
runtime *connectionRuntime
params connectParams
querySessions map[string]*querySession
nextSessionID uint64
activeMu sync.Mutex
activeCancel context.CancelFunc
}
type agentSession struct {
server *server
runtimeKey string
mu sync.Mutex
}
type runtimeServer struct {
mu sync.RWMutex
sessions map[string]*agentSession
runtimesMu sync.Mutex
runtimes map[string]*connectionRuntime
}
func main() {
runtime := newRuntimeServer()
encoder := json.NewEncoder(os.Stdout)
var encoderMu sync.Mutex
var requests sync.WaitGroup
fmt.Fprintln(os.Stdout, `{"ready":true}`)
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 512*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var envelope request
if json.Unmarshal([]byte(line), &envelope) == nil && envelope.Method == "shutdown" {
requests.Wait()
resp, _ := runtime.handleLine(line)
encoderMu.Lock()
_ = encoder.Encode(resp)
encoderMu.Unlock()
return
}
requests.Add(1)
go func(line string) {
defer requests.Done()
resp, _ := runtime.handleLine(line)
encoderMu.Lock()
defer encoderMu.Unlock()
if err := encoder.Encode(resp); err != nil {
fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err)
}
}(line)
}
requests.Wait()
}
func newRuntimeServer() *runtimeServer {
return &runtimeServer{
sessions: map[string]*agentSession{},
runtimes: map[string]*connectionRuntime{},
}
}
func (r *runtimeServer) handleLine(line string) (response, bool) {
var req request
if err := json.Unmarshal([]byte(line), &req); err != nil {
return errorResponse(nil, "", "", err), false
}
if len(req.ID) == 0 {
req.ID = json.RawMessage("1")
}
result, shutdown, err := r.dispatch(req.Method, req.Params)
if err != nil {
return errorResponse(req.ID, req.Method, stringParam(req.Params, "agentSessionId"), err), false
}
return response{JSONRPC: "2.0", ID: req.ID, Result: result}, shutdown
}
func (r *runtimeServer) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) {
switch method {
case "handshake":
return handshakeResult(true), false, nil
case "open_session":
id := stringParam(params, "agentSessionId")
if id == "" {
return nil, false, errors.New("agentSessionId is required")
}
var cp connectParams
if err := decodeParams(params, &cp); err != nil {
return nil, false, err
}
return map[string]bool{"ok": true}, false, r.openSession(id, cp)
case "close_session":
return map[string]bool{"ok": true}, false, r.closeSession(stringParam(params, "agentSessionId"))
case "validate_session":
session, err := r.session(stringParam(params, "agentSessionId"))
if err != nil {
return nil, false, err
}
session.mu.Lock()
defer session.mu.Unlock()
return map[string]bool{"ok": true}, false, session.server.validateConnection()
case "cancel_session":
session, err := r.session(stringParam(params, "agentSessionId"))
if err != nil {
return nil, false, err
}
session.server.cancelActiveQuery()
return map[string]bool{"ok": true}, false, nil
case "test_connection":
var cp connectParams
if err := decodeParams(params, &cp); err != nil {
return nil, false, err
}
result, err := testConnection(cp)
return result, false, err
case "connect":
var cp connectParams
if err := decodeParams(params, &cp); err != nil {
return nil, false, err
}
_ = r.closeSession(legacyAgentSessionID)
return map[string]bool{"ok": true}, false, r.openSession(legacyAgentSessionID, cp)
case "disconnect":
return map[string]bool{"ok": true}, false, r.closeSession(legacyAgentSessionID)
case "shutdown":
return map[string]bool{"ok": true}, true, r.closeAllSessions()
default:
id := stringParam(params, "agentSessionId")
if id == "" {
id = legacyAgentSessionID
}
session, err := r.session(id)
if err != nil {
return nil, false, err
}
session.mu.Lock()
defer session.mu.Unlock()
release, err := session.server.runtime.acquire(isMetadataOperation(method))
if err != nil {
return nil, false, err
}
defer release()
return session.server.dispatch(method, params)
}
}
func (r *runtimeServer) openSession(id string, cp connectParams) error {
r.mu.Lock()
if _, exists := r.sessions[id]; exists {
r.mu.Unlock()
return fmt.Errorf("agent session already exists: %s", id)
}
if len(r.sessions) >= maxAgentSessions {
r.mu.Unlock()
return fmt.Errorf("agent session limit reached: %d", maxAgentSessions)
}
r.mu.Unlock()
runtime, key, err := r.acquireRuntime(cp)
if err != nil {
return err
}
s := newServer(runtime, cp)
if err := s.validateConnection(); err != nil {
r.releaseRuntime(key)
return err
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.sessions[id]; exists {
r.releaseRuntime(key)
return fmt.Errorf("agent session already exists: %s", id)
}
r.sessions[id] = &agentSession{server: s, runtimeKey: key}
return nil
}
func (r *runtimeServer) session(id string) (*agentSession, error) {
r.mu.RLock()
session := r.sessions[id]
r.mu.RUnlock()
if session == nil {
return nil, fmt.Errorf("agent session not found: %s", id)
}
return session, nil
}
func (r *runtimeServer) closeSession(id string) error {
r.mu.Lock()
session := r.sessions[id]
delete(r.sessions, id)
r.mu.Unlock()
if session == nil {
return nil
}
session.server.cancelActiveQuery()
session.mu.Lock()
session.server.disconnect()
session.mu.Unlock()
r.releaseRuntime(session.runtimeKey)
return nil
}
func (r *runtimeServer) closeAllSessions() error {
r.mu.RLock()
ids := make([]string, 0, len(r.sessions))
for id := range r.sessions {
ids = append(ids, id)
}
r.mu.RUnlock()
for _, id := range ids {
_ = r.closeSession(id)
}
r.runtimesMu.Lock()
runtimes := r.runtimes
r.runtimes = map[string]*connectionRuntime{}
r.runtimesMu.Unlock()
for _, runtime := range runtimes {
runtime.close()
}
return nil
}
func newServer(runtime *connectionRuntime, cp connectParams) *server {
return &server{runtime: runtime, params: cp, querySessions: map[string]*querySession{}}
}
func (s *server) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) {
switch method {
case "handshake":
return handshakeResult(false), false, nil
case "validate_connection":
return map[string]bool{"ok": true}, false, s.validateConnection()
case "connection_info":
result, err := s.connectionInfo()
return result, false, err
case "list_databases":
result, err := s.listDatabases()
return result, false, err
case "list_schemas":
result, err := s.listSchemas()
return result, false, err
case "list_tables":
result, err := s.listTables(stringParam(params, "schema"), metadataListConstraintsFromParams(params))
return result, false, err
case "get_table_comment":
return nil, false, nil
case "list_objects":
result, err := s.listObjects(stringParam(params, "schema"), metadataListConstraintsFromParams(params))
return result, false, err
case "list_data_types":
return cassandraDataTypes(), false, nil
case "completion_assistant_search_v1":
var input completionAssistantRequest
if err := decodeParams(params, &input); err != nil {
return nil, false, err
}
result, err := s.completionAssistantSearch(input)
return result, false, err
case "get_columns":
result, err := s.getColumns(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "list_indexes":
result, err := s.listIndexes(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "list_foreign_keys":
return []foreignKeyInfo{}, false, nil
case "list_triggers":
return []triggerInfo{}, false, nil
case "get_object_source":
return nil, false, errors.New("object source is not supported by Cassandra")
case "get_table_ddl":
result, err := s.getTableDDL(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "get_explain_info":
return nil, false, errors.New("execution plans are not supported by Cassandra")
case "execute_query":
result, err := s.executeQuery(queryOptionsFromParams(params))
return result, false, err
case "execute_query_page", "start_table_read":
result, err := s.executeQueryPage(queryOptionsFromParams(params), intParam(params, "pageSize"))
return result, false, err
case "fetch_query_page", "fetch_table_read_page":
result, err := s.fetchQueryPage(stringParam(params, "sessionId"), intParam(params, "pageSize"))
return result, false, err
case "close_query_session", "close_table_read_session":
return s.closeQuerySession(stringParam(params, "sessionId")), false, nil
case "execute_transaction":
result, err := s.executeStatements(params, true)
return result, false, err
case "execute_batch":
result, err := s.executeStatements(params, false)
return result, false, err
case "disconnect":
s.disconnect()
return map[string]bool{"ok": true}, false, nil
case "shutdown":
s.disconnect()
return map[string]bool{"ok": true}, true, nil
default:
return nil, false, fmt.Errorf("unknown method: %s", method)
}
}
func handshakeResult(multiSession bool) map[string]any {
capabilities := []string{
"connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "structured_error_v1",
}
if multiSession {
capabilities = append(capabilities, "multi_session")
}
return map[string]any{
"protocolVersion": protocolVersion,
"agentProtocolVersion": protocolVersion,
"capabilities": capabilities,
}
}
func (s *server) validateConnection() error {
ctx, cancel := context.WithTimeout(context.Background(), defaultConnectTimeout)
defer cancel()
session, err := s.runtime.sessionFor(s.defaultKeyspace())
if err != nil {
return err
}
var releaseVersion string
return session.Query("SELECT release_version FROM system.local").WithContext(ctx).Scan(&releaseVersion)
}
func testConnection(cp connectParams) (map[string]any, error) {
runtime, err := newConnectionRuntime(cp)
if err != nil {
return nil, err
}
defer runtime.close()
s := newServer(runtime, cp)
if err := s.validateConnection(); err != nil {
return nil, err
}
info, err := s.connectionInfo()
if err != nil {
return nil, err
}
return map[string]any{"ok": true, "info": info}, nil
}
func (s *server) disconnect() {
s.cancelActiveQuery()
s.querySessions = map[string]*querySession{}
}
func (s *server) defaultKeyspace() string {
if keyspace := strings.TrimSpace(s.params.Database); keyspace != "" {
return keyspace
}
return strings.TrimSpace(s.runtime.config.keyspace)
}
func (s *server) beginOperation(timeoutSecs int) (context.Context, context.CancelFunc) {
var ctx context.Context
var cancel context.CancelFunc
if timeoutSecs > 0 {
ctx, cancel = context.WithTimeout(context.Background(), time.Duration(timeoutSecs)*time.Second)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
s.activeMu.Lock()
s.activeCancel = cancel
s.activeMu.Unlock()
return ctx, cancel
}
func (s *server) endOperation(cancel context.CancelFunc) {
cancel()
s.activeMu.Lock()
s.activeCancel = nil
s.activeMu.Unlock()
}
func (s *server) cancelActiveQuery() {
s.activeMu.Lock()
cancel := s.activeCancel
s.activeMu.Unlock()
if cancel != nil {
cancel()
}
}
func queryOptionsFromParams(params map[string]json.RawMessage) queryOptions {
return queryOptions{
SQL: stringParam(params, "sql"),
Database: stringParam(params, "database"),
Schema: stringParam(params, "schema"),
MaxRows: intParam(params, "maxRows"),
FetchSize: intParam(params, "fetchSize"),
TimeoutSecs: intParam(params, "timeoutSecs"),
}
}
func decodeParams(params map[string]json.RawMessage, target any) error {
data, err := json.Marshal(params)
if err != nil {
return err
}
return json.Unmarshal(data, target)
}
func stringParam(params map[string]json.RawMessage, key string) string {
if raw, ok := params[key]; ok {
var value string
if json.Unmarshal(raw, &value) == nil {
return value
}
}
return ""
}
func intParam(params map[string]json.RawMessage, key string) int {
if raw, ok := params[key]; ok {
var value int
if json.Unmarshal(raw, &value) == nil {
return value
}
}
return 0
}
func boolParam(params map[string]json.RawMessage, key string) bool {
if raw, ok := params[key]; ok {
var value bool
if json.Unmarshal(raw, &value) == nil {
return value
}
}
return false
}
func stringSliceParam(params map[string]json.RawMessage, key string) []string {
if raw, ok := params[key]; ok {
var value []string
if json.Unmarshal(raw, &value) == nil {
return value
}
}
return []string{}
}
func errorResponse(id json.RawMessage, method, sessionID string, err error) response {
return response{JSONRPC: "2.0", ID: id, Error: classifyRPCError(method, sessionID, err)}
}
func isMetadataOperation(method string) bool {
switch method {
case "connection_info", "list_databases", "list_schemas", "list_tables", "get_table_comment", "list_objects",
"list_data_types", "completion_assistant_search_v1", "get_columns", "list_indexes", "list_foreign_keys",
"list_triggers", "get_object_source", "get_table_ddl", "get_explain_info":
return true
default:
return false
}
}

View File

@ -0,0 +1,586 @@
package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
var cassandraTypes = []string{
"ascii", "bigint", "blob", "boolean", "counter", "date", "decimal", "double", "duration",
"float", "inet", "int", "list", "map", "set", "smallint", "text", "time", "timestamp",
"timeuuid", "tinyint", "tuple", "uuid", "varchar", "varint", "vector", "frozen",
}
type databaseInfo struct {
Name string `json:"name"`
}
type tableInfo struct {
Name string `json:"name"`
TableType string `json:"table_type"`
Comment *string `json:"comment"`
}
type objectInfo struct {
Name string `json:"name"`
ObjectType string `json:"object_type"`
Schema string `json:"schema"`
Comment *string `json:"comment"`
Valid *bool `json:"valid,omitempty"`
}
type columnInfo struct {
Name string `json:"name"`
DataType string `json:"data_type"`
IsNullable bool `json:"is_nullable"`
ColumnDefault *string `json:"column_default"`
IsPrimaryKey bool `json:"is_primary_key"`
Extra *string `json:"extra"`
Comment *string `json:"comment"`
NumericPrecision *int `json:"numeric_precision"`
NumericScale *int `json:"numeric_scale"`
CharacterMaximumLength *int `json:"character_maximum_length"`
}
type indexInfo struct {
Name string `json:"name"`
Columns []string `json:"columns"`
IsUnique bool `json:"is_unique"`
IsPrimary bool `json:"is_primary"`
Filter *string `json:"filter"`
IndexType *string `json:"index_type"`
IncludedColumns []string `json:"included_columns"`
Comment *string `json:"comment"`
}
func (i indexInfo) MarshalJSON() ([]byte, error) {
type alias indexInfo
value := alias(i)
if value.Columns == nil {
value.Columns = []string{}
}
if value.IncludedColumns == nil {
value.IncludedColumns = []string{}
}
return json.Marshal(value)
}
type foreignKeyInfo struct {
Name string `json:"name"`
Column string `json:"column"`
RefTable string `json:"ref_table"`
RefColumn string `json:"ref_column"`
}
type triggerInfo struct {
Name string `json:"name"`
Event string `json:"event"`
Timing string `json:"timing"`
}
type metadataListConstraints struct {
Filter string
Limit int
Offset int
ObjectTypes []string
}
type completionAssistantRequest struct {
ConnectionID string `json:"connection_id"`
Database string `json:"database"`
Schema string `json:"schema"`
ObjectKinds []string `json:"object_kinds"`
Mask string `json:"mask"`
CaseSensitive bool `json:"case_sensitive"`
GlobalSearch bool `json:"global_search"`
MaxResults int `json:"max_results"`
ParentSchema string `json:"parent_schema"`
ParentName string `json:"parent_name"`
MatchMode string `json:"match_mode"`
}
type completionAssistantCandidate struct {
Name string `json:"name"`
Kind string `json:"kind"`
Database *string `json:"database"`
Schema *string `json:"schema"`
ParentSchema *string `json:"parent_schema"`
ParentName *string `json:"parent_name"`
Comment *string `json:"comment"`
DataType *string `json:"data_type"`
}
type completionAssistantResponse struct {
Candidates []completionAssistantCandidate `json:"candidates"`
Incomplete bool `json:"incomplete"`
FallbackUsed bool `json:"fallback_used"`
}
func cassandraDataTypes() []string {
return append([]string(nil), cassandraTypes...)
}
func (s *server) connectionInfo() (map[string]any, error) {
session, err := s.runtime.sessionFor("")
if err != nil {
return nil, err
}
var clusterName, version, cqlVersion, dataCenter string
err = session.Query("SELECT cluster_name, release_version, cql_version, data_center FROM system.local").Scan(
&clusterName, &version, &cqlVersion, &dataCenter,
)
if err != nil {
return nil, err
}
return map[string]any{
"database": s.defaultKeyspace(),
"schema": s.defaultKeyspace(),
"username": s.params.Username,
"version": version,
"clusterName": clusterName,
"cqlVersion": cqlVersion,
"localDatacenter": dataCenter,
"identifierQuote": `"`,
"compatibilityMode": "cql",
"databaseInfo": map[string]string{
"productName": "Apache Cassandra",
"productVersion": version,
"unquotedIdentifierCase": "lower",
"quotedIdentifierCase": "mixed",
"driverName": "Apache cassandra-gocql-driver",
"driverVersion": "2.1.2",
},
}, nil
}
func (s *server) allKeyspaceMetadata() (map[string]*gocql.KeyspaceMetadata, error) {
session, err := s.runtime.sessionFor("")
if err != nil {
return nil, err
}
return session.AllKeyspaceMetadata()
}
func (s *server) keyspaceMetadata(schema string) (*gocql.KeyspaceMetadata, error) {
session, err := s.runtime.sessionFor("")
if err != nil {
return nil, err
}
metadata, err := session.KeyspaceMetadata(schema)
if err != nil {
return nil, err
}
if metadata == nil {
return nil, fmt.Errorf("Cassandra keyspace not found: %s", schema)
}
return metadata, nil
}
func (s *server) tableMetadata(schema, table string) (*gocql.TableMetadata, error) {
keyspace, err := s.keyspaceMetadata(schema)
if err != nil {
return nil, err
}
metadata := keyspace.Tables[table]
if metadata == nil {
return nil, fmt.Errorf("Cassandra table not found: %s.%s", schema, table)
}
return metadata, nil
}
func (s *server) listDatabases() ([]databaseInfo, error) {
metadata, err := s.allKeyspaceMetadata()
if err != nil {
return nil, err
}
names := sortedMapKeys(metadata)
result := make([]databaseInfo, len(names))
for index, name := range names {
result[index] = databaseInfo{Name: name}
}
return result, nil
}
func (s *server) listSchemas() ([]string, error) {
databases, err := s.listDatabases()
if err != nil {
return nil, err
}
result := make([]string, len(databases))
for index, database := range databases {
result[index] = database.Name
}
return result, nil
}
func (s *server) listTables(schema string, constraints metadataListConstraints) ([]tableInfo, error) {
metadata, err := s.keyspaceMetadata(schema)
if err != nil {
return nil, err
}
names := sortedMapKeys(metadata.Tables)
result := make([]tableInfo, 0, len(names))
for _, name := range names {
if !metadataNameMatches(name, constraints.Filter) {
continue
}
result = append(result, tableInfo{Name: name, TableType: "TABLE"})
}
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
}
func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) {
tables, err := s.listTables(schema, metadataListConstraints{Filter: constraints.Filter})
if err != nil {
return nil, err
}
allowed := stringSet(constraints.ObjectTypes)
result := make([]objectInfo, 0, len(tables))
for _, table := range tables {
if len(allowed) > 0 && !allowed["table"] && !allowed["base_table"] {
continue
}
result = append(result, objectInfo{Name: table.Name, ObjectType: "TABLE", Schema: schema})
}
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
}
func (s *server) getColumns(schema, table string) ([]columnInfo, error) {
metadata, err := s.tableMetadata(schema, table)
if err != nil {
return nil, err
}
return columnsFromMetadata(metadata), nil
}
func columnsFromMetadata(metadata *gocql.TableMetadata) []columnInfo {
names := orderedColumnNames(metadata)
result := make([]columnInfo, 0, len(names))
for _, name := range names {
column := metadata.Columns[name]
if column == nil {
continue
}
primary := column.Kind == gocql.ColumnPartitionKey || column.Kind == gocql.ColumnClusteringKey
extra := column.Kind.String()
result = append(result, columnInfo{
Name: column.Name,
DataType: cqlTypeName(column.Type),
IsNullable: !primary,
IsPrimaryKey: primary,
Extra: &extra,
})
}
return result
}
func (s *server) listIndexes(schema, table string) ([]indexInfo, error) {
metadata, err := s.tableMetadata(schema, table)
if err != nil {
return nil, err
}
result := indexesFromMetadata(metadata)
queried, queryErr := s.querySystemIndexes(schema, table)
if queryErr == nil {
result = mergeIndexes(result, queried)
}
return result, nil
}
func (s *server) querySystemIndexes(schema, table string) ([]indexInfo, error) {
session, err := s.runtime.sessionFor("")
if err != nil {
return nil, err
}
iter := session.Query(
"SELECT index_name, kind, options FROM system_schema.indexes WHERE keyspace_name = ? AND table_name = ?",
schema,
table,
).Iter()
result := []indexInfo{}
var name, kind string
var options map[string]string
for iter.Scan(&name, &kind, &options) {
indexType := strings.TrimSpace(kind)
result = append(result, indexInfo{
Name: name,
Columns: targetColumns(options["target"]),
IndexType: optionalString(indexType),
IncludedColumns: []string{},
})
options = nil
}
if err := iter.Close(); err != nil {
return nil, err
}
sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name })
return result, nil
}
func mergeIndexes(first, second []indexInfo) []indexInfo {
byName := make(map[string]indexInfo, len(first)+len(second))
for _, index := range first {
byName[index.Name] = index
}
for _, index := range second {
if existing, ok := byName[index.Name]; ok && len(index.Columns) == 0 {
index.Columns = existing.Columns
}
byName[index.Name] = index
}
names := sortedMapKeys(byName)
result := make([]indexInfo, 0, len(names))
for _, name := range names {
result = append(result, byName[name])
}
return result
}
func targetColumns(target string) []string {
target = strings.TrimSpace(target)
for _, wrapper := range []string{"values", "keys", "entries", "full"} {
prefix := wrapper + "("
if strings.HasPrefix(strings.ToLower(target), prefix) && strings.HasSuffix(target, ")") {
target = strings.TrimSpace(target[len(prefix) : len(target)-1])
break
}
}
target = strings.Trim(target, `"'`)
if target == "" {
return []string{}
}
return []string{target}
}
func optionalString(value string) *string {
if value == "" {
return nil
}
return &value
}
func indexesFromMetadata(metadata *gocql.TableMetadata) []indexInfo {
byName := map[string]*indexInfo{}
for _, columnName := range orderedColumnNames(metadata) {
column := metadata.Columns[columnName]
if column == nil || strings.TrimSpace(column.Index.Name) == "" {
continue
}
index := byName[column.Index.Name]
if index == nil {
indexType := strings.TrimSpace(column.Index.Type)
index = &indexInfo{Name: column.Index.Name, Columns: []string{}, IncludedColumns: []string{}}
if indexType != "" {
index.IndexType = &indexType
}
byName[index.Name] = index
}
index.Columns = append(index.Columns, columnName)
}
names := sortedMapKeys(byName)
result := make([]indexInfo, 0, len(names))
for _, name := range names {
result = append(result, *byName[name])
}
return result
}
func (s *server) getTableDDL(schema, table string) (string, error) {
metadata, err := s.tableMetadata(schema, table)
if err != nil {
return "", err
}
return tableDDLFromMetadata(schema, table, metadata)
}
func tableDDLFromMetadata(schema, table string, metadata *gocql.TableMetadata) (string, error) {
definitions := make([]string, 0, len(metadata.Columns)+1)
for _, name := range orderedColumnNames(metadata) {
column := metadata.Columns[name]
if column != nil {
definitions = append(definitions, " "+quoteCQLIdentifier(column.Name)+" "+cqlTypeName(column.Type))
}
}
partitionKeys := metadataColumnNames(metadata.PartitionKey)
clusteringKeys := metadataColumnNames(metadata.ClusteringColumns)
if len(partitionKeys) == 0 {
return "", fmt.Errorf("Cassandra table has no partition key: %s.%s", schema, table)
}
primaryParts := make([]string, 0, len(clusteringKeys)+1)
if len(partitionKeys) == 1 {
primaryParts = append(primaryParts, quoteCQLIdentifier(partitionKeys[0]))
} else {
quoted := make([]string, len(partitionKeys))
for index, name := range partitionKeys {
quoted[index] = quoteCQLIdentifier(name)
}
primaryParts = append(primaryParts, "("+strings.Join(quoted, ", ")+")")
}
for _, name := range clusteringKeys {
primaryParts = append(primaryParts, quoteCQLIdentifier(name))
}
definitions = append(definitions, " PRIMARY KEY ("+strings.Join(primaryParts, ", ")+")")
ddl := "CREATE TABLE " + quoteCQLIdentifier(schema) + "." + quoteCQLIdentifier(table) + " (\n" + strings.Join(definitions, ",\n") + "\n)"
orders := make([]string, 0, len(metadata.ClusteringColumns))
for _, column := range metadata.ClusteringColumns {
if column != nil {
order := "ASC"
if column.Order == gocql.DESC {
order = "DESC"
}
orders = append(orders, quoteCQLIdentifier(column.Name)+" "+order)
}
}
if len(orders) > 0 {
ddl += " WITH CLUSTERING ORDER BY (" + strings.Join(orders, ", ") + ")"
}
return ddl + ";", nil
}
func (s *server) completionAssistantSearch(input completionAssistantRequest) (completionAssistantResponse, error) {
limit := input.MaxResults
if limit <= 0 || limit > 1000 {
limit = 100
}
candidates := make([]completionAssistantCandidate, 0, limit+1)
kinds := stringSet(input.ObjectKinds)
if kinds["column"] && input.ParentName != "" {
schema := input.ParentSchema
if schema == "" {
schema = input.Schema
}
columns, err := s.getColumns(schema, input.ParentName)
if err != nil {
return completionAssistantResponse{}, err
}
for _, column := range columns {
if !completionNameMatches(column.Name, input) {
continue
}
dataType := column.DataType
candidates = append(candidates, completionAssistantCandidate{
Name: column.Name, Kind: "COLUMN", Schema: stringPtr(schema), ParentSchema: stringPtr(schema),
ParentName: stringPtr(input.ParentName), DataType: &dataType,
})
}
} else {
schemas := []string{input.Schema}
if input.GlobalSearch || input.Schema == "" {
var err error
schemas, err = s.listSchemas()
if err != nil {
return completionAssistantResponse{}, err
}
}
for _, schema := range schemas {
objects, err := s.listObjects(schema, metadataListConstraints{ObjectTypes: input.ObjectKinds})
if err != nil {
return completionAssistantResponse{}, err
}
for _, object := range objects {
if !completionNameMatches(object.Name, input) {
continue
}
candidates = append(candidates, completionAssistantCandidate{
Name: object.Name, Kind: object.ObjectType, Schema: stringPtr(schema),
})
if len(candidates) > limit {
return completionAssistantResponse{Candidates: candidates[:limit], Incomplete: true}, nil
}
}
}
}
incomplete := len(candidates) > limit
if incomplete {
candidates = candidates[:limit]
}
return completionAssistantResponse{Candidates: candidates, Incomplete: incomplete}, nil
}
func metadataListConstraintsFromParams(params map[string]json.RawMessage) metadataListConstraints {
return metadataListConstraints{
Filter: stringParam(params, "filter"),
Limit: intParam(params, "limit"),
Offset: intParam(params, "offset"),
ObjectTypes: stringSliceParam(params, "object_types"),
}
}
func orderedColumnNames(metadata *gocql.TableMetadata) []string {
if len(metadata.OrderedColumns) > 0 {
return append([]string(nil), metadata.OrderedColumns...)
}
return sortedMapKeys(metadata.Columns)
}
func metadataColumnNames(columns []*gocql.ColumnMetadata) []string {
result := make([]string, 0, len(columns))
for _, column := range columns {
if column != nil {
result = append(result, column.Name)
}
}
return result
}
func metadataNameMatches(name, filter string) bool {
return filter == "" || strings.Contains(strings.ToLower(name), strings.ToLower(filter))
}
func applyMetadataWindow[T any](values []T, offset, limit int) []T {
if offset < 0 {
offset = 0
}
if offset >= len(values) {
return []T{}
}
values = values[offset:]
if limit > 0 && limit < len(values) {
values = values[:limit]
}
return values
}
func sortedMapKeys[T any](values map[string]T) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func stringSet(values []string) map[string]bool {
result := make(map[string]bool, len(values))
for _, value := range values {
result[strings.ToLower(strings.TrimSpace(value))] = true
}
return result
}
func completionNameMatches(name string, input completionAssistantRequest) bool {
mask := input.Mask
if mask == "" {
return true
}
if !input.CaseSensitive {
name = strings.ToLower(name)
mask = strings.ToLower(mask)
}
if strings.EqualFold(input.MatchMode, "contains") {
return strings.Contains(name, mask)
}
return strings.HasPrefix(name, mask)
}
func quoteCQLIdentifier(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
func stringPtr(value string) *string {
return &value
}

View File

@ -0,0 +1,86 @@
package main
import (
"reflect"
"testing"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
func TestColumnsIndexesAndDDLFromMetadata(t *testing.T) {
textType := gocql.NewNativeType(4, gocql.TypeVarchar, "")
intType := gocql.NewNativeType(4, gocql.TypeInt, "")
id := &gocql.ColumnMetadata{Name: "tenant", Kind: gocql.ColumnPartitionKey, Type: textType}
bucket := &gocql.ColumnMetadata{Name: "bucket", Kind: gocql.ColumnPartitionKey, Type: intType}
created := &gocql.ColumnMetadata{Name: "created_at", Kind: gocql.ColumnClusteringKey, Type: textType, Order: gocql.DESC}
email := &gocql.ColumnMetadata{
Name: "email", Kind: gocql.ColumnRegular, Type: textType,
Index: gocql.ColumnIndexMetadata{Name: "users_email_idx", Type: "COMPOSITES"},
}
metadata := &gocql.TableMetadata{
OrderedColumns: []string{"tenant", "bucket", "created_at", "email"},
PartitionKey: []*gocql.ColumnMetadata{id, bucket},
ClusteringColumns: []*gocql.ColumnMetadata{created},
Columns: map[string]*gocql.ColumnMetadata{
"tenant": id, "bucket": bucket, "created_at": created, "email": email,
},
}
columns := columnsFromMetadata(metadata)
if len(columns) != 4 || !columns[0].IsPrimaryKey || columns[0].IsNullable || columns[3].IsPrimaryKey || !columns[3].IsNullable {
t.Fatalf("unexpected columns: %#v", columns)
}
if columns[2].Extra == nil || *columns[2].Extra != "clustering_key" {
t.Fatalf("unexpected clustering metadata: %#v", columns[2])
}
indexes := indexesFromMetadata(metadata)
if len(indexes) != 1 || indexes[0].Name != "users_email_idx" || !reflect.DeepEqual(indexes[0].Columns, []string{"email"}) {
t.Fatalf("unexpected indexes: %#v", indexes)
}
ddl, err := tableDDLFromMetadata("app", "users", metadata)
if err != nil {
t.Fatal(err)
}
want := "CREATE TABLE \"app\".\"users\" (\n" +
" \"tenant\" text,\n" +
" \"bucket\" int,\n" +
" \"created_at\" text,\n" +
" \"email\" text,\n" +
" PRIMARY KEY ((\"tenant\", \"bucket\"), \"created_at\")\n" +
") WITH CLUSTERING ORDER BY (\"created_at\" DESC);"
if ddl != want {
t.Fatalf("unexpected DDL:\n%s\nwant:\n%s", ddl, want)
}
}
func TestMetadataWindowAndFilter(t *testing.T) {
values := []string{"a", "b", "c", "d"}
if got := applyMetadataWindow(values, 1, 2); !reflect.DeepEqual(got, []string{"b", "c"}) {
t.Fatalf("unexpected window: %#v", got)
}
if !metadataNameMatches("CustomerEvents", "event") || metadataNameMatches("users", "event") {
t.Fatal("metadata filter mismatch")
}
}
func TestTargetColumnsHandlesCollectionIndexes(t *testing.T) {
for input, want := range map[string]string{
"txt": "txt",
"values(tags)": "tags",
"keys(attrs)": "attrs",
`entries("attrs")`: "attrs",
} {
got := targetColumns(input)
if !reflect.DeepEqual(got, []string{want}) {
t.Fatalf("targetColumns(%q) = %#v", input, got)
}
}
}
func TestQuoteCQLIdentifierEscapesQuotes(t *testing.T) {
if got := quoteCQLIdentifier(`a"b`); got != `"a""b"` {
t.Fatalf("unexpected quoted identifier: %s", got)
}
}

View File

@ -0,0 +1,156 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"strings"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data *rpcErrorData `json:"data,omitempty"`
}
type rpcErrorData struct {
Category string `json:"category"`
Retryable bool `json:"retryable"`
SessionDisposition string `json:"sessionDisposition"`
Stage string `json:"stage"`
ContractVersion int `json:"contractVersion"`
OperationOutcome string `json:"operationOutcome"`
SQLState string `json:"sqlState,omitempty"`
ExceptionClass string `json:"exceptionClass,omitempty"`
AgentSessionID string `json:"agentSessionId,omitempty"`
}
func classifyRPCError(method, agentSessionID string, err error) *rpcError {
stage := rpcErrorStage(method)
data := &rpcErrorData{
Category: "protocol",
Retryable: false,
SessionDisposition: "keep",
Stage: stage,
ContractVersion: 1,
OperationOutcome: rpcOperationOutcome(stage),
ExceptionClass: safeRPCDiagnostic(fmt.Sprintf("%T", err), 160),
AgentSessionID: strings.TrimSpace(agentSessionID),
}
if errors.Is(err, errOperationCapacity) {
data.Category = "resource"
data.Retryable = true
return &rpcError{Code: -1, Message: err.Error(), Data: data}
}
var requestError gocql.RequestError
if errors.As(err, &requestError) {
data.SQLState = fmt.Sprintf("0x%04x", requestError.Code())
switch requestError.Code() {
case gocql.ErrCodeUnavailable, gocql.ErrCodeOverloaded, gocql.ErrCodeBootstrapping:
data.Category = "resource"
data.Retryable = true
case gocql.ErrCodeWriteTimeout, gocql.ErrCodeReadTimeout:
data.Category = "timeout"
data.Retryable = true
case gocql.ErrCodeCredentials:
data.Category = "connection"
data.Retryable = stage == "connect" || stage == "validate"
case gocql.ErrCodeSyntax, gocql.ErrCodeUnauthorized, gocql.ErrCodeInvalid,
gocql.ErrCodeConfig, gocql.ErrCodeAlreadyExists, gocql.ErrCodeUnprepared:
data.Category = "sql"
default:
data.Category = "sql"
}
} else if errors.Is(err, context.Canceled) {
data.Category = "canceled"
data.SessionDisposition = "quarantine"
} else if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, gocql.ErrTimeoutNoResponse) || isTimeoutError(err) {
data.Category = "timeout"
data.SessionDisposition = "quarantine"
} else if isConnectionError(err) {
data.Category = "connection"
data.Retryable = stage == "connect" || stage == "validate"
if stage != "connect" {
data.SessionDisposition = "quarantine"
}
}
return &rpcError{Code: -1, Message: err.Error(), Data: data}
}
func rpcErrorStage(method string) string {
switch method {
case "connect", "open_session", "test_connection":
return "connect"
case "validate_connection", "validate_session":
return "validate"
case "cancel_session":
return "cancel"
case "close_session", "disconnect", "close_query_session", "close_table_read_session", "shutdown":
return "close"
case "fetch_query_page", "fetch_table_read_page":
return "fetch"
case "handshake", "":
return "request"
default:
return "execute"
}
}
func rpcOperationOutcome(stage string) string {
switch stage {
case "request", "connect", "validate":
return "not_started"
default:
return "unknown"
}
}
func isTimeoutError(err error) bool {
var timeout interface{ Timeout() bool }
return errors.As(err, &timeout) && timeout.Timeout()
}
func isConnectionError(err error) bool {
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return true
}
var networkError *net.OpError
if errors.As(err, &networkError) {
return true
}
lower := strings.ToLower(err.Error())
for _, marker := range []string{
"connection refused",
"connection reset",
"broken pipe",
"connection closed",
"connection lost",
"unexpected eof",
"no route to host",
"no hosts available",
} {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
func safeRPCDiagnostic(value string, maxLength int) string {
var result strings.Builder
for _, char := range value {
if result.Len() >= maxLength {
break
}
if char >= 0x21 && char <= 0x7e {
result.WriteRune(char)
}
}
return result.String()
}

View File

@ -0,0 +1,67 @@
package main
import (
"context"
"encoding/json"
"reflect"
"testing"
)
func TestHandshakeAdvertisesMultiSessionAndStructuredErrors(t *testing.T) {
result, shutdown, err := newRuntimeServer().dispatch("handshake", nil)
if err != nil || shutdown {
t.Fatalf("unexpected handshake result: shutdown=%t err=%v", shutdown, err)
}
capabilities := result.(map[string]any)["capabilities"].([]string)
want := []string{"connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "structured_error_v1", "multi_session"}
if !reflect.DeepEqual(capabilities, want) {
t.Fatalf("unexpected capabilities: %#v", capabilities)
}
}
func TestHandleLineRejectsMissingSession(t *testing.T) {
params, _ := json.Marshal(map[string]any{"agentSessionId": "missing"})
line := `{"jsonrpc":"2.0","id":7,"method":"validate_session","params":` + string(params) + `}`
response, _ := newRuntimeServer().handleLine(line)
if response.Error == nil || response.Error.Data == nil || response.Error.Data.Stage != "validate" {
t.Fatalf("unexpected error response: %#v", response)
}
}
func TestClassifyCanceledQuery(t *testing.T) {
err := classifyRPCError("execute_query", "session-1", context.Canceled)
if err.Data.Category != "canceled" || err.Data.SessionDisposition != "quarantine" {
t.Fatalf("unexpected cancellation classification: %#v", err)
}
}
func TestRuntimeIdentityIncludesCredentials(t *testing.T) {
first := connectionRuntimeKey(connectParams{Host: "localhost", Username: "user", Password: "one"})
second := connectionRuntimeKey(connectParams{Host: "localhost", Username: "user", Password: "two"})
if first == second {
t.Fatal("runtime identities must not share sessions across credentials")
}
}
func TestTrimStatementSQL(t *testing.T) {
if got := trimStatementSQL(" SELECT * FROM t;;; \n"); got != "SELECT * FROM t" {
t.Fatalf("unexpected trimmed SQL: %q", got)
}
}
func TestIsSchemaChangingCQL(t *testing.T) {
for _, sql := range []string{
"CREATE TABLE app.events (id int PRIMARY KEY)",
" alter keyspace app with replication = {'class': 'SimpleStrategy'} ",
"DROP INDEX app.events_idx;",
} {
if !isSchemaChangingCQL(sql) {
t.Fatalf("expected schema-changing CQL: %q", sql)
}
}
for _, sql := range []string{"SELECT * FROM app.events", "INSERT INTO app.events (id) VALUES (1)", "TRUNCATE app.events"} {
if isSchemaChangingCQL(sql) {
t.Fatalf("unexpected schema-changing CQL: %q", sql)
}
}
}

View File

@ -0,0 +1,345 @@
package main
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
func (s *server) executeQuery(options queryOptions) (queryResult, error) {
start := time.Now()
maxRows := options.MaxRows
if maxRows <= 0 {
maxRows = defaultMaxRows
}
session, err := s.runtime.sessionFor(s.keyspaceForOptions(options))
if err != nil {
return queryResult{}, err
}
ctx, cancel := s.beginOperation(options.TimeoutSecs)
defer s.endOperation(cancel)
query := session.Query(trimStatementSQL(options.SQL)).WithContext(ctx)
if options.FetchSize > 0 {
query = query.PageSize(options.FetchSize)
}
iter := query.Iter()
columns := iter.Columns()
result := queryResult{
Columns: columnNames(columns),
ColumnTypes: columnTypeNames(columns),
Rows: make([][]any, 0, min(maxRows, 1024)),
}
if len(columns) == 0 {
err := iter.Close()
if err == nil && isSchemaChangingCQL(options.SQL) {
s.runtime.invalidateMetadataSession()
}
result.ExecutionTimeMS = time.Since(start).Milliseconds()
return result, err
}
for len(result.Rows) < maxRows {
row, ok, scanErr := scanCQLRow(iter, columns)
if scanErr != nil {
_ = iter.Close()
return queryResult{}, scanErr
}
if !ok {
break
}
result.Rows = append(result.Rows, row)
}
if len(result.Rows) == maxRows {
_, hasExtra, scanErr := scanCQLRow(iter, columns)
if scanErr != nil {
_ = iter.Close()
return queryResult{}, scanErr
}
result.Truncated = hasExtra
}
if err := iter.Close(); err != nil {
return queryResult{}, err
}
result.ExecutionTimeMS = time.Since(start).Milliseconds()
return result, nil
}
func (s *server) executeQueryPage(options queryOptions, pageSize int) (queryPageResult, error) {
if pageSize <= 0 {
pageSize = options.FetchSize
}
if pageSize <= 0 {
pageSize = defaultPageSize
}
remaining := options.MaxRows
if remaining <= 0 {
remaining = defaultMaxRows
}
result, nextState, err := s.fetchCQLPage(options.SQL, s.keyspaceForOptions(options), nil, pageSize, remaining, options.TimeoutSecs)
if err != nil {
return queryPageResult{}, err
}
remaining -= len(result.Rows)
if len(nextState) == 0 || remaining <= 0 {
result.HasMore = false
result.Truncated = len(nextState) > 0 && remaining <= 0
return result, nil
}
s.nextSessionID++
id := fmt.Sprintf("cassandra-query-%d", s.nextSessionID)
s.querySessions[id] = &querySession{
sql: trimStatementSQL(options.SQL),
keyspace: s.keyspaceForOptions(options),
pageState: append([]byte(nil), nextState...),
remaining: remaining,
}
result.SessionID = &id
result.HasMore = true
return result, nil
}
func (s *server) fetchQueryPage(id string, pageSize int) (queryPageResult, error) {
state := s.querySessions[id]
if state == nil {
return queryPageResult{}, fmt.Errorf("query session not found: %s", id)
}
if pageSize <= 0 {
pageSize = defaultPageSize
}
result, nextState, err := s.fetchCQLPage(state.sql, state.keyspace, state.pageState, pageSize, state.remaining, 0)
if err != nil {
return queryPageResult{}, err
}
state.remaining -= len(result.Rows)
if len(nextState) == 0 || state.remaining <= 0 {
delete(s.querySessions, id)
result.HasMore = false
result.Truncated = len(nextState) > 0 && state.remaining <= 0
return result, nil
}
state.pageState = append(state.pageState[:0], nextState...)
result.SessionID = &id
result.HasMore = true
return result, nil
}
func (s *server) fetchCQLPage(sql, keyspace string, pageState []byte, pageSize, remaining, timeoutSecs int) (queryPageResult, []byte, error) {
start := time.Now()
if remaining < pageSize {
pageSize = remaining
}
if pageSize <= 0 {
return queryPageResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}}, nil, nil
}
session, err := s.runtime.sessionFor(keyspace)
if err != nil {
return queryPageResult{}, nil, err
}
ctx, cancel := s.beginOperation(timeoutSecs)
defer s.endOperation(cancel)
iter := session.Query(trimStatementSQL(sql)).WithContext(ctx).PageSize(pageSize).PageState(pageState).Iter()
columns := iter.Columns()
result := queryPageResult{
Columns: columnNames(columns),
ColumnTypes: columnTypeNames(columns),
Rows: make([][]any, 0, pageSize),
}
if len(columns) == 0 {
err := iter.Close()
result.ExecutionTimeMS = time.Since(start).Milliseconds()
return result, nil, err
}
for len(result.Rows) < pageSize {
row, ok, scanErr := scanCQLRow(iter, columns)
if scanErr != nil {
_ = iter.Close()
return queryPageResult{}, nil, scanErr
}
if !ok {
break
}
result.Rows = append(result.Rows, row)
}
nextState := append([]byte(nil), iter.PageState()...)
if err := iter.Close(); err != nil {
return queryPageResult{}, nil, err
}
result.ExecutionTimeMS = time.Since(start).Milliseconds()
return result, nextState, nil
}
func (s *server) closeQuerySession(id string) bool {
if _, exists := s.querySessions[id]; !exists {
return false
}
delete(s.querySessions, id)
return true
}
func (s *server) executeStatements(params map[string]json.RawMessage, transactional bool) (queryResult, error) {
statements := stringSliceParam(params, "statements")
if len(statements) == 0 {
return queryResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}}, nil
}
keyspace := strings.TrimSpace(stringParam(params, "schema"))
if keyspace == "" {
keyspace = strings.TrimSpace(stringParam(params, "database"))
}
if keyspace == "" {
keyspace = strings.TrimSpace(s.params.Database)
}
session, err := s.runtime.sessionFor(keyspace)
if err != nil {
return queryResult{}, err
}
batchType := gocql.UnloggedBatch
if transactional {
batchType = gocql.LoggedBatch
}
batch := session.NewBatch(batchType)
for _, statement := range statements {
statement = trimStatementSQL(statement)
if statement != "" {
batch.Query(statement)
}
}
ctx, cancel := s.beginOperation(intParam(params, "timeoutSecs"))
defer s.endOperation(cancel)
start := time.Now()
if err := session.ExecuteBatch(batch.WithContext(ctx)); err != nil {
return queryResult{}, err
}
return queryResult{
Columns: []string{},
ColumnTypes: []string{},
Rows: [][]any{},
AffectedRows: 0,
ExecutionTimeMS: time.Since(start).Milliseconds(),
}, nil
}
func (s *server) keyspaceForOptions(options queryOptions) string {
if schema := strings.TrimSpace(options.Schema); schema != "" {
return schema
}
if database := strings.TrimSpace(options.Database); database != "" {
return database
}
return s.defaultKeyspace()
}
func scanCQLRow(iter *gocql.Iter, columns []gocql.ColumnInfo) ([]any, bool, error) {
destinations := make([]any, 0, len(columns))
extractors := make([]func() any, 0, len(columns))
for _, column := range columns {
if tuple, ok := column.TypeInfo.(gocql.TupleTypeInfo); ok {
tupleDestinations := make([]*cqlDestination, 0, len(tuple.Elems))
for _, element := range tuple.Elems {
destination := newCQLDestination(element)
tupleDestinations = append(tupleDestinations, destination)
destinations = append(destinations, destination.destination)
}
extractors = append(extractors, func() any {
values := make([]any, len(tupleDestinations))
allNull := true
for index, destination := range tupleDestinations {
value, present := destination.value()
if present {
allNull = false
values[index] = value
}
}
if allNull {
return nil
}
return normalizeCQLValue(values)
})
continue
}
destination := newCQLDestination(column.TypeInfo)
destinations = append(destinations, destination.destination)
extractors = append(extractors, func() any {
value, present := destination.value()
if !present {
return nil
}
return normalizeCQLValue(value)
})
}
if !iter.Scan(destinations...) {
return nil, false, nil
}
row := make([]any, len(columns))
for index, extract := range extractors {
row[index] = extract()
}
return row, true, nil
}
type cqlDestination struct {
destination any
holder reflect.Value
fallback *any
}
func newCQLDestination(typeInfo gocql.TypeInfo) *cqlDestination {
zero := typeInfo.Zero()
valueType := reflect.TypeOf(zero)
if valueType == nil {
var fallback any
return &cqlDestination{destination: &fallback, fallback: &fallback}
}
holder := reflect.New(reflect.PointerTo(valueType))
return &cqlDestination{destination: holder.Interface(), holder: holder}
}
func (destination *cqlDestination) value() (any, bool) {
if destination.fallback != nil {
return *destination.fallback, *destination.fallback != nil
}
pointer := destination.holder.Elem()
if pointer.IsNil() {
return nil, false
}
return pointer.Elem().Interface(), true
}
func columnNames(columns []gocql.ColumnInfo) []string {
result := make([]string, len(columns))
for index, column := range columns {
result[index] = column.Name
}
return result
}
func columnTypeNames(columns []gocql.ColumnInfo) []string {
result := make([]string, len(columns))
for index, column := range columns {
result[index] = cqlTypeName(column.TypeInfo)
}
return result
}
func trimStatementSQL(sql string) string {
trimmed := strings.TrimSpace(sql)
for strings.HasSuffix(trimmed, ";") {
trimmed = strings.TrimSpace(strings.TrimSuffix(trimmed, ";"))
}
return trimmed
}
func isSchemaChangingCQL(sql string) bool {
fields := strings.Fields(trimStatementSQL(sql))
if len(fields) == 0 {
return false
}
switch strings.ToUpper(fields[0]) {
case "CREATE", "ALTER", "DROP":
return true
default:
return false
}
}

View File

@ -0,0 +1,214 @@
package main
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
const (
defaultRuntimePoolSize = 32
defaultRuntimeMetadataLimit = 8
operationPermitTimeout = 30 * time.Second
)
var errOperationCapacity = errors.New("agent operation capacity is temporarily exhausted")
type connectionRuntime struct {
mu sync.Mutex
config cassandraConfig
sessions map[string]*gocql.Session
retiredSessions []*gocql.Session
permits chan struct{}
metadataPermits chan struct{}
activeOperations int
references int
closed bool
}
func newConnectionRuntime(cp connectParams) (*connectionRuntime, error) {
config, err := parseCassandraConfig(cp)
if err != nil {
return nil, err
}
poolSize := runtimePoolSize()
return &connectionRuntime{
config: config,
sessions: map[string]*gocql.Session{},
permits: make(chan struct{}, poolSize),
metadataPermits: make(chan struct{}, runtimeMetadataLimit(poolSize)),
}, nil
}
func (r *connectionRuntime) sessionFor(keyspace string) (*gocql.Session, error) {
keyspace = strings.TrimSpace(keyspace)
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return nil, errors.New("Cassandra connection runtime is closed")
}
if session := r.sessions[keyspace]; session != nil && !session.Closed() {
return session, nil
}
cluster, err := r.config.clusterConfig(keyspace)
if err != nil {
return nil, err
}
session, err := cluster.CreateSession()
if err != nil {
return nil, err
}
r.sessions[keyspace] = session
return session, nil
}
func (r *connectionRuntime) invalidateMetadataSession() {
var retiredSession *gocql.Session
r.mu.Lock()
if session := r.sessions[""]; session != nil {
delete(r.sessions, "")
if r.activeOperations == 0 {
retiredSession = session
} else {
r.retiredSessions = append(r.retiredSessions, session)
}
}
r.mu.Unlock()
if retiredSession != nil {
retiredSession.Close()
}
}
func (r *connectionRuntime) acquire(metadata bool) (func(), error) {
ctx, cancel := context.WithTimeout(context.Background(), operationPermitTimeout)
defer cancel()
metadataAcquired := false
if metadata {
select {
case r.metadataPermits <- struct{}{}:
metadataAcquired = true
case <-ctx.Done():
return nil, errOperationCapacity
}
}
select {
case r.permits <- struct{}{}:
r.mu.Lock()
r.activeOperations++
r.mu.Unlock()
return func() {
var retiredSessions []*gocql.Session
r.mu.Lock()
r.activeOperations--
if r.activeOperations == 0 && len(r.retiredSessions) > 0 {
retiredSessions = r.retiredSessions
r.retiredSessions = nil
}
r.mu.Unlock()
<-r.permits
if metadataAcquired {
<-r.metadataPermits
}
for _, session := range retiredSessions {
session.Close()
}
}, nil
case <-ctx.Done():
if metadataAcquired {
<-r.metadataPermits
}
return nil, errOperationCapacity
}
}
func (r *connectionRuntime) close() {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return
}
r.closed = true
sessions := r.sessions
retiredSessions := r.retiredSessions
r.sessions = map[string]*gocql.Session{}
r.retiredSessions = nil
r.mu.Unlock()
for _, session := range sessions {
session.Close()
}
for _, session := range retiredSessions {
session.Close()
}
}
func (r *runtimeServer) acquireRuntime(cp connectParams) (*connectionRuntime, string, error) {
key := connectionRuntimeKey(cp)
r.runtimesMu.Lock()
defer r.runtimesMu.Unlock()
runtime := r.runtimes[key]
if runtime == nil {
var err error
runtime, err = newConnectionRuntime(cp)
if err != nil {
return nil, "", err
}
r.runtimes[key] = runtime
}
runtime.references++
return runtime, key, nil
}
func (r *runtimeServer) releaseRuntime(key string) {
if key == "" {
return
}
r.runtimesMu.Lock()
runtime := r.runtimes[key]
shouldClose := false
if runtime != nil && runtime.references > 0 {
runtime.references--
}
if runtime != nil && runtime.references == 0 {
delete(r.runtimes, key)
shouldClose = true
}
r.runtimesMu.Unlock()
if shouldClose {
runtime.close()
}
}
func connectionRuntimeKey(cp connectParams) string {
data, _ := json.Marshal(cp)
digest := sha256.Sum256(data)
return fmt.Sprintf("%x", digest[:])
}
func runtimePoolSize() int {
value := defaultRuntimePoolSize
if raw := os.Getenv("DBX_AGENT_CASSANDRA_MAX_CONCURRENT_OPERATIONS"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 1 && parsed <= 128 {
value = parsed
}
}
return value
}
func runtimeMetadataLimit(poolSize int) int {
value := min(defaultRuntimeMetadataLimit, poolSize)
if raw := os.Getenv("DBX_AGENT_CASSANDRA_MAX_CONCURRENT_METADATA"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 1 && parsed <= poolSize {
value = parsed
}
}
return value
}

View File

@ -0,0 +1,60 @@
package main
import (
"testing"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
func TestInvalidateMetadataSessionDefersCloseUntilOperationsFinish(t *testing.T) {
runtime := &connectionRuntime{
sessions: map[string]*gocql.Session{"": {}},
permits: make(chan struct{}, 2),
metadataPermits: make(chan struct{}, 1),
}
releaseFirst, err := runtime.acquire(false)
if err != nil {
t.Fatal(err)
}
releaseSecond, err := runtime.acquire(false)
if err != nil {
t.Fatal(err)
}
metadataSession := runtime.sessions[""]
runtime.invalidateMetadataSession()
if metadataSession.Closed() {
t.Fatal("metadata session closed while operations were active")
}
if len(runtime.retiredSessions) != 1 {
t.Fatalf("unexpected retired session count: %d", len(runtime.retiredSessions))
}
releaseFirst()
if metadataSession.Closed() {
t.Fatal("metadata session closed before the final operation completed")
}
releaseSecond()
if !metadataSession.Closed() {
t.Fatal("metadata session was not closed after the final operation completed")
}
if len(runtime.retiredSessions) != 0 {
t.Fatalf("retired sessions were not cleared: %d", len(runtime.retiredSessions))
}
}
func TestInvalidateMetadataSessionClosesImmediatelyWithoutOperations(t *testing.T) {
metadataSession := &gocql.Session{}
runtime := &connectionRuntime{sessions: map[string]*gocql.Session{"": metadataSession}}
runtime.invalidateMetadataSession()
if !metadataSession.Closed() {
t.Fatal("idle metadata session was not closed immediately")
}
if _, exists := runtime.sessions[""]; exists {
t.Fatal("invalidated metadata session remains cached")
}
}

View File

@ -0,0 +1,119 @@
package main
import (
"encoding/hex"
"fmt"
"math/big"
"net"
"reflect"
"sort"
"strings"
"time"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
func normalizeCQLValue(value any) any {
if value == nil {
return nil
}
return cqlString(value)
}
func cqlString(value any) string {
if value == nil {
return "null"
}
switch typed := value.(type) {
case string:
return typed
case []byte:
return "0x" + hex.EncodeToString(typed)
case time.Time:
return typed.Format(time.RFC3339Nano)
case time.Duration:
return typed.String()
case gocql.Duration:
return fmt.Sprintf("%dmo%dd%dns", typed.Months, typed.Days, typed.Nanoseconds)
case gocql.UUID:
return typed.String()
case net.IP:
return typed.String()
case *big.Int:
if typed == nil {
return ""
}
return typed.String()
case big.Int:
return typed.String()
case fmt.Stringer:
return typed.String()
}
valueOf := reflect.ValueOf(value)
for valueOf.Kind() == reflect.Pointer {
if valueOf.IsNil() {
return ""
}
valueOf = valueOf.Elem()
}
switch valueOf.Kind() {
case reflect.Map:
entries := make([]string, 0, valueOf.Len())
iterator := valueOf.MapRange()
for iterator.Next() {
entries = append(entries, cqlString(iterator.Key().Interface())+"="+cqlString(iterator.Value().Interface()))
}
sort.Strings(entries)
return "{" + strings.Join(entries, ", ") + "}"
case reflect.Slice, reflect.Array:
values := make([]string, valueOf.Len())
for index := range values {
values[index] = cqlString(valueOf.Index(index).Interface())
}
return "[" + strings.Join(values, ", ") + "]"
default:
return fmt.Sprint(value)
}
}
func cqlTypeName(typeInfo gocql.TypeInfo) string {
if typeInfo == nil {
return "unknown"
}
switch typed := typeInfo.(type) {
case gocql.CollectionType:
switch typed.Type() {
case gocql.TypeMap:
return "map<" + cqlTypeName(typed.Key) + ", " + cqlTypeName(typed.Elem) + ">"
case gocql.TypeList:
return "list<" + cqlTypeName(typed.Elem) + ">"
case gocql.TypeSet:
return "set<" + cqlTypeName(typed.Elem) + ">"
}
case gocql.TupleTypeInfo:
parts := make([]string, len(typed.Elems))
for index, element := range typed.Elems {
parts[index] = cqlTypeName(element)
}
return "tuple<" + strings.Join(parts, ", ") + ">"
case gocql.UDTTypeInfo:
return quoteCQLIdentifier(typed.Name)
case gocql.VectorType:
return fmt.Sprintf("vector<%s, %d>", cqlTypeName(typed.SubType), typed.Dimensions)
}
names := map[gocql.Type]string{
gocql.TypeCustom: "custom", gocql.TypeAscii: "ascii", gocql.TypeBigInt: "bigint",
gocql.TypeBlob: "blob", gocql.TypeBoolean: "boolean", gocql.TypeCounter: "counter",
gocql.TypeDecimal: "decimal", gocql.TypeDouble: "double", gocql.TypeFloat: "float",
gocql.TypeInt: "int", gocql.TypeText: "text", gocql.TypeTimestamp: "timestamp",
gocql.TypeUUID: "uuid", gocql.TypeVarchar: "text", gocql.TypeVarint: "varint",
gocql.TypeTimeUUID: "timeuuid", gocql.TypeInet: "inet", gocql.TypeDate: "date",
gocql.TypeTime: "time", gocql.TypeSmallInt: "smallint", gocql.TypeTinyInt: "tinyint",
gocql.TypeDuration: "duration", gocql.TypeUDT: "udt", gocql.TypeTuple: "tuple",
gocql.TypeList: "list", gocql.TypeMap: "map", gocql.TypeSet: "set",
}
if name := names[typeInfo.Type()]; name != "" {
return name
}
return "unknown"
}

View File

@ -0,0 +1,45 @@
package main
import (
"net"
"reflect"
"testing"
"time"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
func TestNormalizeCQLValuePreservesLegacyStringContract(t *testing.T) {
uuid, err := gocql.ParseUUID("00112233-4455-6677-8899-aabbccddeeff")
if err != nil {
t.Fatal(err)
}
tests := []struct {
value any
want any
}{
{nil, nil},
{42, "42"},
{true, "true"},
{[]byte{0x00, 0xff}, "0x00ff"},
{uuid, "00112233-4455-6677-8899-aabbccddeeff"},
{net.ParseIP("127.0.0.1"), "127.0.0.1"},
{time.Date(2026, 8, 3, 12, 34, 56, 7, time.UTC), "2026-08-03T12:34:56.000000007Z"},
{gocql.Duration{Months: 1, Days: 2, Nanoseconds: 3}, "1mo2d3ns"},
{[]int{1, 2}, "[1, 2]"},
{[]any{1, nil, "three"}, "[1, null, three]"},
{map[string]int{"b": 2, "a": 1}, "{a=1, b=2}"},
}
for _, test := range tests {
if got := normalizeCQLValue(test.value); !reflect.DeepEqual(got, test.want) {
t.Fatalf("normalizeCQLValue(%#v) = %#v, want %#v", test.value, got, test.want)
}
}
}
func TestCQLTypeNameUsesCQLSyntax(t *testing.T) {
typeInfo := gocql.NewNativeType(4, gocql.TypeList, "varchar")
if got := cqlTypeName(typeInfo); got != "list<text>" {
t.Fatalf("unexpected collection type name: %s", got)
}
}

View File

@ -1,10 +0,0 @@
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.ing.data:cassandra-jdbc-wrapper:4.12.0'
}
tasks.named('shadowJar') {
manifest {
attributes('Agent-Label': 'Apache Cassandra', 'Main-Class': 'com.dbx.agent.cassandra.CassandraAgent')
}
}

View File

@ -1,194 +0,0 @@
package com.dbx.agent.cassandra;
import com.dbx.agent.AbstractJdbcAgent;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class CassandraAgent extends AbstractJdbcAgent {
private static final Pattern TARGET_PATTERN = Pattern.compile("target[\"']?\\s*[:=]\\s*[\"']?([\\w]+)");
@Override
protected String driverClass() {
return "com.ing.data.cassandra.jdbc.CassandraDriver";
}
@Override
protected String buildJdbcUrl(ConnectParams params) {
return buildUrl(params);
}
@Override
public String setSchemaSQL(String schema) {
return "USE " + JdbcIdentifiers.INSTANCE.doubleQuote(schema);
}
@Override
public List<DatabaseInfo> listDatabases() {
return unchecked(() -> {
List<DatabaseInfo> result = new ArrayList<>();
String sql = "SELECT keyspace_name FROM system_schema.keyspaces";
try (java.sql.Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
result.add(new DatabaseInfo(rs.getString(1)));
}
}
result.sort(Comparator.comparing(DatabaseInfo::getName));
return result;
});
}
@Override
public List<String> listSchemas() {
List<String> result = new ArrayList<>();
for (DatabaseInfo database : listDatabases()) {
result.add(database.getName());
}
return result;
}
@Override
public List<TableInfo> listTables(String schema) {
return unchecked(() -> {
List<TableInfo> result = new ArrayList<>();
String sql = "SELECT table_name FROM system_schema.tables WHERE keyspace_name = ?";
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new TableInfo(rs.getString(1), "TABLE", null));
}
}
}
result.sort(Comparator.comparing(TableInfo::getName));
return result;
});
}
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
return unchecked(() -> {
List<ColumnInfo> result = new ArrayList<>();
String sql = "SELECT column_name, type, kind FROM system_schema.columns WHERE keyspace_name = ? AND table_name = ?";
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String kind = coalesce(rs.getString("kind"));
boolean isPrimaryKey = "partition_key".equals(kind) || "clustering".equals(kind);
result.add(new ColumnInfo(
rs.getString("column_name"),
coalesce(rs.getString("type"), "unknown"),
!isPrimaryKey,
null,
isPrimaryKey,
kind.trim().isEmpty() ? null : kind,
null,
null,
null,
null
));
}
}
}
return result;
});
}
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
return unchecked(() -> {
List<IndexInfo> result = new ArrayList<>();
String sql = "SELECT index_name, options FROM system_schema.indexes WHERE keyspace_name = ? AND table_name = ?";
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String indexName = coalesce(rs.getString("index_name"));
String options = coalesce(rs.getString("options"));
result.add(new IndexInfo(
indexName,
targetColumns(options),
false,
false,
null,
null,
null,
null
));
}
}
}
result.sort(Comparator.comparing(IndexInfo::getName));
return result;
});
}
@Override
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
return Collections.emptyList();
}
@Override
public List<TriggerInfo> listTriggers(String schema, String table) {
return Collections.emptyList();
}
@Override
protected Object resultValue(ResultSet rs, int index, int sqlType) {
return unchecked(() -> {
Object value = rs.getObject(index);
return rs.wasNull() ? null : value == null ? null : value.toString();
});
}
static String buildUrl(ConnectParams params) {
String baseUrl = "jdbc:cassandra://" + params.getHost() + ":" + params.getPort();
String keyspace = coalesce(params.getDatabase()).trim();
// Cassandra rejects an empty keyspace path; omit it so DBX can connect first and list keyspaces.
String url = keyspace.isEmpty() ? baseUrl : baseUrl + "/" + keyspace;
// Multi-DC clusters require localdatacenter=<dc>
String extraParams = coalesce(params.getUrl_params()).trim();
while (extraParams.startsWith("?") || extraParams.startsWith("&")) {
extraParams = extraParams.substring(1);
}
return extraParams.isEmpty() ? url : url + "?" + extraParams;
}
private static List<String> targetColumns(String options) {
Matcher matcher = TARGET_PATTERN.matcher(options);
if (!matcher.find()) {
return Collections.emptyList();
}
return Collections.singletonList(matcher.group(1));
}
private static String coalesce(String value) {
return value == null ? "" : value;
}
private static String coalesce(String value, String fallback) {
return value == null ? fallback : value;
}
public static void main(String[] args) {
new MultiSessionJsonRpcServer(CassandraAgent::new).run();
}
}

View File

@ -1,52 +0,0 @@
package com.dbx.agent.cassandra;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.DatabaseAgent;
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CassandraAgentTest extends JdbcFakeExecutionBehaviorTest {
@Override
protected DatabaseAgent createAgent() {
return new CassandraAgent();
}
@Override
protected String resultSetSql() {
return "LIST ROLES";
}
@Test
void buildsServerUrlWhenKeyspaceIsEmpty() {
ConnectParams params = new ConnectParams("127.0.0.1", 9042, "", "cassandra", "cassandra", "", "", false);
assertEquals("jdbc:cassandra://127.0.0.1:9042", CassandraAgent.buildUrl(params));
}
@Test
void buildsKeyspaceUrlWhenKeyspaceIsSet() {
ConnectParams params = new ConnectParams("127.0.0.1", 9042, "app_keyspace", "cassandra", "cassandra", "", "", false);
assertEquals("jdbc:cassandra://127.0.0.1:9042/app_keyspace", CassandraAgent.buildUrl(params));
}
@Test
void appendsUrlParamsForMultiDcLocalDatacenter() {
ConnectParams params = new ConnectParams(
"127.0.0.1", 9042, "app_keyspace", "cassandra", "cassandra", "localdatacenter=dc1", "", false
);
assertEquals("jdbc:cassandra://127.0.0.1:9042/app_keyspace?localdatacenter=dc1", CassandraAgent.buildUrl(params));
}
@Test
void stripsLeadingQuestionMarkFromUrlParams() {
ConnectParams params = new ConnectParams(
"127.0.0.1", 9042, "", "cassandra", "cassandra", "?localdatacenter=dc1", "", false
);
assertEquals("jdbc:cassandra://127.0.0.1:9042?localdatacenter=dc1", CassandraAgent.buildUrl(params));
}
}

View File

@ -28,6 +28,11 @@ import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.SQLNonTransientConnectionException;
import java.sql.SQLRecoverableException;
import java.sql.SQLSyntaxErrorException;
import java.sql.SQLTransientConnectionException;
import java.sql.SQLXML;
import java.sql.Statement;
import java.sql.Types;
@ -102,6 +107,77 @@ public final class DamengAgent extends AbstractJdbcAgent {
connectedUsername = params.getUsername();
}
@Override
protected void afterPhysicalConnect(ConnectParams params, Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
statement.execute("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;");
} catch (SQLException error) {
if (!isIgnorableDbmsOutputError(error)) {
throw error;
}
}
}
private static boolean isIgnorableDbmsOutputError(SQLException error) {
for (Throwable current = error; current != null; current = current.getCause()) {
if (current instanceof SQLException sqlError) {
for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) {
if (isConnectionError(candidate)) {
return false;
}
}
}
}
for (Throwable current = error; current != null; current = current.getCause()) {
if (current instanceof SQLException sqlError) {
for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) {
if (candidate instanceof SQLFeatureNotSupportedException || candidate instanceof SQLSyntaxErrorException) {
return true;
}
String sqlState = candidate.getSQLState();
if ("0A000".equalsIgnoreCase(sqlState)
|| "42000".equalsIgnoreCase(sqlState)
|| "42501".equalsIgnoreCase(sqlState)) {
return true;
}
String message = candidate.getMessage();
if (message != null && isDbmsOutputUnavailableMessage(message.toLowerCase(Locale.ROOT))) {
return true;
}
}
}
}
return false;
}
private static boolean isConnectionError(SQLException error) {
String sqlState = error.getSQLState();
return error instanceof SQLNonTransientConnectionException
|| error instanceof SQLRecoverableException
|| error instanceof SQLTransientConnectionException
|| (sqlState != null && sqlState.toUpperCase(Locale.ROOT).startsWith("08"));
}
private static boolean isDbmsOutputUnavailableMessage(String message) {
if (!message.contains("dbms_output")) {
return false;
}
return message.contains("权限")
|| message.contains("privilege")
|| message.contains("permission")
|| message.contains("access denied")
|| message.contains("not authorized")
|| message.contains("不支持")
|| message.contains("unsupported")
|| message.contains("not supported")
|| message.contains("不存在")
|| message.contains("not exist")
|| message.contains("not found")
|| message.contains("未找到")
|| message.contains("undefined")
|| message.contains("未定义");
}
/**
* The DM JDBC driver writes a banner to {@code System.out} during
* {@code Class.forName} / driver initialization. This corrupts the
@ -140,7 +216,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
return listVisibleSchemas();
} catch (SQLException catalogError) {
try {
return listVisibleUsers();
return listJdbcSchemas();
} catch (Exception fallbackError) {
catalogError.addSuppressed(fallbackError);
throw catalogError;
@ -162,6 +238,19 @@ public final class DamengAgent extends AbstractJdbcAgent {
return result;
}
private List<String> listJdbcSchemas() throws Exception {
Set<String> schemas = new LinkedHashSet<>();
try (ResultSet rs = requireConnected().getMetaData().getSchemas()) {
while (rs.next()) {
String schema = rs.getString("TABLE_SCHEM");
if (schema != null && !schema.isBlank()) {
schemas.add(schema);
}
}
}
return schemas.stream().sorted().toList();
}
private List<String> listVisibleSchemas() throws Exception {
List<String> result = new ArrayList<>();
String sql = "SELECT NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' ORDER BY NAME";
@ -198,6 +287,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
try {
return executeConstrainedTables(buildConstrainedTablesQuery(schema, constraints), constraints);
} catch (RuntimeException e) {
if (isDamengInvalidDatetimeMetadataError(e)) {
return executeJdbcMetadataTables(schema, constraints);
}
if (!isDamengMetadataPermissionError(e)) {
throw e;
}
@ -210,6 +302,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
constraints
);
} catch (RuntimeException e) {
if (isDamengInvalidDatetimeMetadataError(e)) {
return executeJdbcMetadataTables(schema, constraints);
}
if (!isDamengMetadataPermissionError(e)) {
throw e;
}
@ -223,6 +318,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
constraints
);
} catch (RuntimeException e) {
if (isDamengInvalidDatetimeMetadataError(e)) {
return executeJdbcMetadataTables(schema, constraints);
}
if (!isDamengMetadataPermissionError(e)) {
throw e;
}
@ -232,6 +330,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
try {
return executeRawConstrainedTables(schema, constraints);
} catch (RuntimeException e) {
if (isDamengInvalidDatetimeMetadataError(e)) {
return executeJdbcMetadataTables(schema, constraints);
}
if (!isDamengMetadataPermissionError(e)) {
throw e;
}
@ -283,6 +384,21 @@ public final class DamengAgent extends AbstractJdbcAgent {
.replace("%", escape + "%");
}
private static boolean isDamengInvalidDatetimeMetadataError(Throwable error) {
// DM7 ALL_OBJECTS casts SYSOBJINFOS.ALTTIME text to DATETIME and can fail on legacy catalog values.
for (Throwable current = error; current != null; current = current.getCause()) {
if (!(current instanceof SQLException sqlError)) {
continue;
}
for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) {
if (candidate.getErrorCode() == -6118) {
return true;
}
}
}
return false;
}
private static boolean isDamengMetadataPermissionError(Throwable error) {
for (Throwable current = error; current != null; current = current.getCause()) {
if (!(current instanceof SQLException sqlError)) {
@ -297,7 +413,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
boolean metadataObject = normalized.contains("all_objects")
|| normalized.contains("sysobjects")
|| normalized.contains("all_dependencies")
|| normalized.contains("all_tab_comments");
|| normalized.contains("all_tab_comments")
|| normalized.contains("dbms_metadata")
|| normalized.contains("get_ddl");
boolean permissionDenied = normalized.contains("权限")
|| normalized.contains("privilege")
|| normalized.contains("permission denied")
@ -795,25 +913,37 @@ public final class DamengAgent extends AbstractJdbcAgent {
@Override
public String getTableDdl(String schema, String table) {
return unchecked(() -> {
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
String ddl = null;
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, "TABLE");
stmt.setString(2, table);
stmt.setString(3, schema);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
ddl = coalesce(readTextColumn(rs, 1));
try {
return unchecked(() -> {
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
String ddl = null;
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, "TABLE");
stmt.setString(2, table);
stmt.setString(3, schema);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
ddl = coalesce(readTextColumn(rs, 1));
}
}
}
if (ddl != null) {
ddl = appendTableAndColumnComments(ddl, schema, table);
return appendIndependentIndexDdl(ddl, schema, table);
}
throw new IllegalArgumentException("Table not found: " + schema + "." + table);
});
} catch (RuntimeException error) {
if (!isDamengMetadataPermissionError(error)) {
throw error;
}
if (ddl != null) {
ddl = appendTableAndColumnComments(ddl, schema, table);
return appendIndependentIndexDdl(ddl, schema, table);
try {
return super.getTableDdl(schema, table);
} catch (RuntimeException fallbackError) {
fallbackError.addSuppressed(error);
throw fallbackError;
}
throw new IllegalArgumentException("Table not found: " + schema + "." + table);
});
}
}
@Override
@ -1026,13 +1156,38 @@ public final class DamengAgent extends AbstractJdbcAgent {
sql,
schema,
this::setSchemaSQL,
() -> "",
options.getMaxRows(),
options.getFetchSize(),
options.getTimeoutSecs(),
this::resultValue
this::resultValue,
DamengAgent::statementPrintMessages
);
}
static List<String> statementPrintMessages(Statement statement) {
try {
Object target = statement;
Method method;
try {
method = statement.getClass().getMethod("getPrintMsg");
} catch (NoSuchMethodException ignored) {
// Pooled connections expose a Hikari proxy rather than DmdbStatement directly.
Class<?> damengStatementClass = Class.forName("dm.jdbc.driver.DmdbStatement");
target = statement.unwrap(damengStatementClass);
method = damengStatementClass.getMethod("getPrintMsg");
}
Object value = method.invoke(target);
if (!(value instanceof String)) {
return List.of();
}
String message = (String) value;
return message.isEmpty() ? List.of() : message.lines().toList();
} catch (Exception ignored) {
return List.of();
}
}
private QueryResult executeExplainQuery(String sql, String schema, ExecuteQueryOptions options) {
return explainQueryResult(sql, schema, options.getTimeoutSecs(), options.getMaxRows());
}
@ -1125,7 +1280,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
schema,
this::setSchemaSQL,
options,
this::resultValue
this::resultValue,
DamengAgent::statementPrintMessages
);
}

View File

@ -133,6 +133,31 @@ class DamengAgentMetadataTest {
Assertions.assertEquals(List.of("catalog=null,schema=APP\\_DATA\\%2026,table=%,types=null"), jdbcMetadataCalls);
}
@Test
void fallsBackImmediatelyWhenAllObjectsContainsInvalidDatetimeMetadata() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
List<String> jdbcMetadataCalls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, restrictedTableConnection(
sqls,
jdbcMetadataCalls,
List.of(
List.of("VIEW_B", "VIEW", "view comment"),
List.of("TABLE_A", "TABLE", "table comment"),
List.of("MTAB$_INTERNAL", "TABLE", "internal table")
),
null,
new SQLException("非法的时间日期类型数据", "22015", -6118)
));
MetadataListConstraints constraints = new MetadataListConstraints(null, 20, null, List.of("TABLE"));
List<TableInfo> tables = agent.listTables("APP", constraints);
Assertions.assertEquals(List.of("TABLE_A"), tables.stream().map(TableInfo::getName).toList());
Assertions.assertEquals(1, sqls.size(), String.join("\n", sqls));
Assertions.assertEquals(List.of("catalog=null,schema=APP,table=%,types=null"), jdbcMetadataCalls);
}
@Test
void returnsEmptyWhenRestrictedSchemaJdbcMetadataHasNoTables() {
DamengAgent agent = new DamengAgent();
@ -341,17 +366,31 @@ class DamengAgentMetadataTest {
}
@Test
void listSchemasFallsBackToAllUsersWithoutSysObjectsPrivilege() {
void listSchemasFallsBackToJdbcMetadataWithoutSysObjectsPrivilege() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, restrictedSchemaConnection(sqls));
List<String> jdbcMetadataCalls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, restrictedSchemaConnection(sqls, jdbcMetadataCalls, null));
List<String> schemas = agent.listSchemas();
Assertions.assertEquals(List.of("APP", "REPORTING", "SYSDBA"), schemas);
Assertions.assertEquals(2, sqls.size(), String.join("\n", sqls));
Assertions.assertEquals(List.of("APP", "REPORTING", "REPORTING_ARCHIVE", "SYSDBA"), schemas);
Assertions.assertEquals(1, sqls.size(), String.join("\n", sqls));
Assertions.assertTrue(sqls.get(0).contains("SYS.SYSOBJECTS"), sqls.get(0));
Assertions.assertTrue(sqls.get(1).contains("ALL_USERS"), sqls.get(1));
Assertions.assertEquals(List.of("getSchemas"), jdbcMetadataCalls);
}
@Test
void listSchemasPreservesCatalogErrorWhenJdbcMetadataFails() {
DamengAgent agent = new DamengAgent();
SQLException metadataError = new SQLException("JDBC metadata getSchemas failed");
TestSupport.setPrivateConnection(agent, restrictedSchemaConnection(new ArrayList<>(), new ArrayList<>(), metadataError));
RuntimeException error = Assertions.assertThrows(RuntimeException.class, agent::listSchemas);
Assertions.assertEquals("no SYS.SYSOBJECTS privilege", error.getCause().getMessage());
Assertions.assertEquals(1, error.getCause().getSuppressed().length);
Assertions.assertSame(metadataError, error.getCause().getSuppressed()[0]);
}
@Test
@ -610,6 +649,42 @@ class DamengAgentMetadataTest {
Assertions.assertTrue(ddl.contains("DBX_FULL_DDL_END"), ddl);
}
@Test
void fallsBackToGeneratedTableDdlWhenDbmsMetadataPermissionIsDenied() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, metadataConnectionWithDbmsMetadataError(
sqls,
"没有[SYS.DBMS_METADATA.GET_DDL]对象的执行权限"
));
String ddl = agent.getTableDdl("APP", "USERS");
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
Assertions.assertTrue(ddl.contains("\"ID\" NUMBER(10) NOT NULL"), ddl);
Assertions.assertEquals(1, sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("ALL_TAB_COLUMNS")), String.join("\n", sqls));
}
@Test
void propagatesNonPermissionDbmsMetadataErrorsWithoutFallback() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, metadataConnectionWithDbmsMetadataError(
sqls,
"DBMS_METADATA.GET_DDL connection reset"
));
RuntimeException error = Assertions.assertThrows(
RuntimeException.class,
() -> agent.getTableDdl("APP", "USERS")
);
Assertions.assertEquals("DBMS_METADATA.GET_DDL connection reset", error.getCause().getMessage());
Assertions.assertEquals(1, sqls.size());
Assertions.assertTrue(sqls.get(0).contains("DBMS_METADATA.GET_DDL"), sqls.toString());
}
@Test
void appendsIndependentIndexesToTableDdl() {
DamengAgent agent = new DamengAgent();
@ -685,6 +760,19 @@ class DamengAgentMetadataTest {
);
}
private static Connection metadataConnectionWithDbmsMetadataError(List<String> sqls, String message) {
return metadataConnection(
"id comment",
null,
false,
List.of(),
sqls,
"CREATE TABLE \"APP\".\"USERS\" (\n \"ID\" NUMBER\n);",
defaultColumnMetadataRows("id comment"),
message
);
}
private static Connection metadataConnection(
String allColumnComment,
String fallbackColumnComment,
@ -734,6 +822,28 @@ class DamengAgentMetadataTest {
List<String> sqls,
String dbmsMetadataDdl,
List<List<Object>> columnRows
) {
return metadataConnection(
allColumnComment,
fallbackColumnComment,
includeMaterializedView,
independentIndexes,
sqls,
dbmsMetadataDdl,
columnRows,
null
);
}
private static Connection metadataConnection(
String allColumnComment,
String fallbackColumnComment,
boolean includeMaterializedView,
List<List<Object>> independentIndexes,
List<String> sqls,
String dbmsMetadataDdl,
List<List<Object>> columnRows,
String dbmsMetadataError
) {
boolean[] dbmsMetadataResultOpen = {false};
return proxy(Connection.class, (method, args) -> {
@ -747,6 +857,9 @@ class DamengAgentMetadataTest {
sqls.add(sql);
}
if (sql.contains("DBMS_METADATA.GET_DDL")) {
if (dbmsMetadataError != null) {
return failingMetadataStatement(dbmsMetadataError);
}
return dbmsMetadataStatement(dbmsMetadataDdl, dbmsMetadataResultOpen);
}
if (sql.startsWith("SELECT NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH'")) {
@ -966,6 +1079,22 @@ class DamengAgentMetadataTest {
List<List<Object>> rows,
SQLException jdbcMetadataError,
String catalogError
) {
return restrictedTableConnection(
sqls,
jdbcMetadataCalls,
rows,
jdbcMetadataError,
new SQLException(catalogError)
);
}
private static Connection restrictedTableConnection(
List<String> sqls,
List<String> jdbcMetadataCalls,
List<List<Object>> rows,
SQLException jdbcMetadataError,
SQLException catalogError
) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
@ -1013,7 +1142,11 @@ class DamengAgentMetadataTest {
});
}
private static Connection restrictedSchemaConnection(List<String> sqls) {
private static Connection restrictedSchemaConnection(
List<String> sqls,
List<String> jdbcMetadataCalls,
SQLException jdbcMetadataError
) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
if ("prepareStatement".equals(name)) {
@ -1022,11 +1155,11 @@ class DamengAgentMetadataTest {
if (sql.contains("SYS.SYSOBJECTS")) {
return failingMetadataStatement("no SYS.SYSOBJECTS privilege");
}
if (sql.contains("ALL_USERS")) {
return metadataStatement(List.of(List.of("APP"), List.of("REPORTING"), List.of("SYSDBA")));
}
throw new AssertionError("Unexpected SQL: " + sql);
}
if ("getMetaData".equals(name)) {
return jdbcSchemaMetadata(jdbcMetadataCalls, jdbcMetadataError);
}
if ("close".equals(name)) {
return null;
}
@ -1037,6 +1170,25 @@ class DamengAgentMetadataTest {
});
}
private static DatabaseMetaData jdbcSchemaMetadata(List<String> calls, SQLException failure) {
return proxy(DatabaseMetaData.class, (method, args) -> {
if ("getSchemas".equals(method.getName())) {
calls.add("getSchemas");
if (failure != null) {
throw failure;
}
return metadataResultSet(List.of(
List.of("REPORTING_ARCHIVE"),
List.of("APP"),
List.of("REPORTING"),
List.of("SYSDBA"),
List.of("APP")
));
}
return defaultValue(method.getReturnType());
});
}
private static Connection schemaConnection(List<String> params) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
@ -1054,9 +1206,13 @@ class DamengAgentMetadataTest {
}
private static PreparedStatement failingMetadataStatement(String message) {
return failingMetadataStatement(new SQLException(message));
}
private static PreparedStatement failingMetadataStatement(SQLException error) {
return proxy(PreparedStatement.class, (method, args) -> {
if ("executeQuery".equals(method.getName())) {
throw new SQLException(message);
throw error;
}
if ("close".equals(method.getName())) {
return null;
@ -1109,7 +1265,7 @@ class DamengAgentMetadataTest {
return value == null ? null : value.toString();
}
return switch (((String) args[0]).toUpperCase()) {
case "TABLE_NAME", "OBJECT_NAME" -> string(rows, index[0], 0);
case "TABLE_NAME", "TABLE_SCHEM", "OBJECT_NAME" -> string(rows, index[0], 0);
case "TABLE_TYPE", "OBJECT_TYPE" -> string(rows, index[0], 1);
case "COLUMN_NAME" -> string(rows, index[0], 0);
case "DATA_TYPE" -> string(rows, index[0], 1);

View File

@ -12,11 +12,22 @@ import com.dbx.agent.test.JdbcAgentFake;
import com.dbx.agent.test.TestSupport;
import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.SQLTransientConnectionException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ -47,6 +58,80 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
assertEquals(List.of("executeQuery"), JdbcAgentFake.calls);
}
@Test
void physicalConnectionsEnableDbmsOutputWithoutChangingUserSql() throws Exception {
List<String> executedSql = new ArrayList<>();
DamengAgent agent = new DamengAgent();
agent.afterPhysicalConnect(null, printMessageConnection(null, executedSql));
assertEquals(List.of("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;"), executedSql);
}
@Test
void physicalConnectionsIgnoreUnsupportedOrRestrictedDbmsOutput() {
DamengAgent agent = new DamengAgent();
assertDoesNotThrow(() -> agent.afterPhysicalConnect(
null,
failingDbmsOutputConnection(new SQLFeatureNotSupportedException("unsupported", "0A000"))
));
assertDoesNotThrow(() -> agent.afterPhysicalConnect(
null,
failingDbmsOutputConnection(new SQLException("permission denied", "42000"))
));
}
@Test
void physicalConnectionsPropagateConnectionFailures() {
DamengAgent agent = new DamengAgent();
SQLException transientFailure = new SQLTransientConnectionException("connection closed");
SQLException sqlStateFailure = new SQLException("connection failure", "08006");
SQLException wrappedFailure = new SQLException("permission denied", "42000");
wrappedFailure.initCause(new SQLTransientConnectionException("connection closed"));
assertSame(transientFailure, assertThrows(
SQLException.class,
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(transientFailure))
));
assertSame(sqlStateFailure, assertThrows(
SQLException.class,
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(sqlStateFailure))
));
assertSame(wrappedFailure, assertThrows(
SQLException.class,
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(wrappedFailure))
));
}
@Test
void physicalConnectionsPropagateUnrelatedSetupFailures() {
DamengAgent agent = new DamengAgent();
SQLException failure = new SQLException("resource busy", "HY000");
assertSame(failure, assertThrows(
SQLException.class,
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(failure))
));
}
@Test
void executeQueryReturnsDamengPrintMessagesForLogOnlyProcedures() {
List<String> executedSql = new ArrayList<>();
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, printMessageConnection("first\n中文日志\n", executedSql));
QueryResult result = agent.executeQuery(
"CALL LOG_ONLY_PROCEDURE('input')",
null,
new ExecuteQueryOptions()
);
assertEquals(List.of("Message"), result.getColumns());
assertEquals(List.of(List.of("first"), List.of("中文日志")), result.getRows());
assertEquals(List.of("CALL LOG_ONLY_PROCEDURE('input')"), executedSql);
}
@Test
void executeQueryPageReturnsPlanRowsForExplainStatements() {
DamengAgent agent = new DamengAgent();
@ -274,4 +359,67 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
assertTrue(query.sql().endsWith("LIMIT ? OFFSET ?"));
assertEquals(List.of("REPORTING", "VIEW", "MATERIALIZED_VIEW", "%S%A%L%E%S%", 10, 30), query.args());
}
private static Connection printMessageConnection(String printMessage, List<String> executedSql) {
return statementConnection(printMessage, executedSql, null);
}
private static Connection failingDbmsOutputConnection(SQLException failure) {
return statementConnection(null, new ArrayList<>(), failure);
}
private static Connection statementConnection(
String printMessage,
List<String> executedSql,
SQLException executeFailure
) {
InvocationHandler statementHandler = (Object unused, Method method, Object[] args) -> {
switch (method.getName()) {
case "execute":
if (executeFailure != null) {
throw executeFailure;
}
executedSql.add((String) args[0]);
return false;
case "getPrintMsg":
return printMessage;
case "getUpdateCount":
return -1;
default:
return defaultValue(method.getReturnType());
}
};
Statement statement = (Statement) Proxy.newProxyInstance(
DamengAgentTest.class.getClassLoader(),
new Class<?>[]{Statement.class, PrintMessageStatement.class},
statementHandler
);
InvocationHandler connectionHandler = (Object unused, Method method, Object[] args) -> {
if (method.getName().equals("createStatement")) {
return statement;
}
return defaultValue(method.getReturnType());
};
return (Connection) Proxy.newProxyInstance(
DamengAgentTest.class.getClassLoader(),
new Class<?>[]{Connection.class},
connectionHandler
);
}
private static Object defaultValue(Class<?> type) {
if (type == Boolean.TYPE) return false;
if (type == Byte.TYPE) return (byte) 0;
if (type == Short.TYPE) return (short) 0;
if (type == Integer.TYPE) return 0;
if (type == Long.TYPE) return 0L;
if (type == Float.TYPE) return 0f;
if (type == Double.TYPE) return 0.0d;
if (type == Character.TYPE) return '\0';
return null;
}
public interface PrintMessageStatement {
String getPrintMsg();
}
}

View File

@ -0,0 +1,2 @@
[target.'cfg(all(windows, target_env = "msvc"))']
rustflags = ["-C", "target-feature=+crt-static"]

View File

@ -7,7 +7,8 @@ existing newline-delimited JSON worker runtime, while keeping DuckDB and
## Build
```bash
cargo build --manifest-path agents/drivers/duckdb/Cargo.toml --release --bin dbx-duckdb-driver
cd agents/drivers/duckdb
cargo build --release --bin dbx-duckdb-driver
```
Point DBX at the resulting executable with:
@ -35,6 +36,9 @@ DBX decompresses the package itself, so users do not need to install `zstd`,
DuckDB, or a separate database driver. The existing aggregate offline `.zip`
packages remain supported for backward compatibility.
Windows MSVC artifacts statically link the Visual C++ runtime so they also run
on fresh Windows installations without a separate redistributable package.
## Current scope
The driver implements connect, execute, database/schema/table/column metadata,

View File

@ -45,6 +45,22 @@ mod tests {
assert_eq!(result.rows[2][0], serde_json::json!(99.999));
}
#[test]
fn duckdb_execute_returns_dml_returning_rows() {
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
con.execute_batch("CREATE TABLE users (id INTEGER, name VARCHAR)").expect("create table");
let inserted = duckdb_execute(&con, "INSERT INTO users VALUES (1, 'Ada') RETURNING id, name")
.expect("insert returning");
let updated = duckdb_execute(&con, "UPDATE users SET name = 'Ada Lovelace' RETURNING id, name")
.expect("update returning");
let deleted = duckdb_execute(&con, "DELETE FROM users RETURNING id, name").expect("delete returning");
assert_eq!(inserted.rows, vec![vec![serde_json::json!(1), serde_json::json!("Ada")]]);
assert_eq!(updated.rows, vec![vec![serde_json::json!(1), serde_json::json!("Ada Lovelace")]]);
assert_eq!(deleted.rows, vec![vec![serde_json::json!(1), serde_json::json!("Ada Lovelace")]]);
}
#[test]
fn duckdb_execute_returns_rows_for_from_first_query() {
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");

View File

@ -202,6 +202,62 @@ pub fn starts_with_duckdb_result_sql_keyword(sql: &str) -> bool {
.any(|keyword| {
token.eq_ignore_ascii_case(keyword) || (*keyword == "DESCRIBE" && token.eq_ignore_ascii_case("DESC"))
})
|| matches!(token.to_ascii_uppercase().as_str(), "INSERT" | "UPDATE" | "DELETE" | "MERGE")
&& contains_unquoted_sql_keyword(sql, "RETURNING")
}
fn contains_unquoted_sql_keyword(sql: &str, keyword: &str) -> bool {
let bytes = sql.as_bytes();
let mut index = 0;
let mut in_single = false;
let mut in_double = false;
while index < bytes.len() {
match bytes[index] {
b'\'' if !in_double => {
if in_single && bytes.get(index + 1) == Some(&b'\'') {
index += 2;
continue;
}
in_single = !in_single;
index += 1;
}
b'"' if !in_single => {
if in_double && bytes.get(index + 1) == Some(&b'"') {
index += 2;
continue;
}
in_double = !in_double;
index += 1;
}
b'-' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'-') => {
index += 2;
while index < bytes.len() && bytes[index] != b'\n' {
index += 1;
}
}
b'/' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'*') => {
index += 2;
while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') {
index += 1;
}
index = (index + 2).min(bytes.len());
}
byte if !in_single && !in_double && (byte.is_ascii_alphabetic() || byte == b'_') => {
let start = index;
index += 1;
while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') {
index += 1;
}
if sql[start..index].eq_ignore_ascii_case(keyword) {
return true;
}
}
_ => index += 1,
}
}
false
}
fn first_executable_sql_token(sql: &str) -> Option<&str> {
@ -269,6 +325,8 @@ mod tests {
fn detects_result_statements_after_comments() {
assert!(starts_with_duckdb_result_sql_keyword("/* note */ WITH rows AS (SELECT 1) SELECT * FROM rows"));
assert!(starts_with_duckdb_result_sql_keyword("DESC SELECT 1"));
assert!(starts_with_duckdb_result_sql_keyword("INSERT INTO items VALUES (1) RETURNING id"));
assert!(!starts_with_duckdb_result_sql_keyword("INSERT INTO items(note) VALUES ('RETURNING')"));
assert!(!starts_with_duckdb_result_sql_keyword("INSERT INTO items VALUES (1)"));
}
}

View File

@ -227,6 +227,13 @@ async fn worker_process_recovers_after_parser_error() {
.await
.expect("create events table");
let typed_err = client
.execute_typed(None, "select * from table limit 19;".to_string(), Some(10), None, Some(Duration::from_secs(5)))
.await
.expect_err("reserved word query should fail");
assert_eq!(typed_err.code, "duckdb_execute_failed");
assert!(typed_err.message.contains("Parser Error"), "unexpected error: {}", typed_err.message);
let err = client
.execute(None, "select * from table limit 19;".to_string(), Some(10), None, Some(Duration::from_secs(5)))
.await

View File

@ -87,6 +87,10 @@ public final class EtcdAgent {
private static final Gson GSON = new Gson();
private static final int DEFAULT_LIMIT = 100;
private static final int RPC_TIMEOUT_SECONDS = 30;
static final int DEFAULT_GRPC_MAX_INBOUND_MESSAGE_SIZE = 32 * 1024 * 1024;
static final int MIN_GRPC_MAX_INBOUND_MESSAGE_SIZE = 1024 * 1024;
static final int MAX_GRPC_MAX_INBOUND_MESSAGE_SIZE = 256 * 1024 * 1024;
private static final String GRPC_MAX_INBOUND_MESSAGE_SIZE_KEY = "grpc_max_inbound_message_size";
private static final int PRESERVE_LEASE_MAX_ATTEMPTS = 3;
private static final long HISTORY_DEFAULT_REVISION_WINDOW = 10_000L;
private static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
@ -167,7 +171,8 @@ public final class EtcdAgent {
List<String> endpoints = endpoints(connection);
ClientBuilder builder = Client.builder()
.endpoints(endpoints.toArray(String[]::new))
.connectTimeout(Duration.ofSeconds(connectTimeoutSeconds(connection)));
.connectTimeout(Duration.ofSeconds(connectTimeoutSeconds(connection)))
.maxInboundMessageSize(grpcMaxInboundMessageSize(connection));
String username = stringOrEmpty(connection, "username");
String password = stringOrEmpty(connection, "password");
if (!username.isBlank()) {
@ -184,6 +189,19 @@ public final class EtcdAgent {
return Math.min(300, Math.max(1, intOrDefault(connection, "connect_timeout_secs", RPC_TIMEOUT_SECONDS)));
}
static int grpcMaxInboundMessageSize(JsonObject connection) {
int configured = intOrDefault(
connection,
GRPC_MAX_INBOUND_MESSAGE_SIZE_KEY,
intUrlParamOrDefault(
stringOrEmpty(connection, "url_params"),
GRPC_MAX_INBOUND_MESSAGE_SIZE_KEY,
DEFAULT_GRPC_MAX_INBOUND_MESSAGE_SIZE
)
);
return Math.min(MAX_GRPC_MAX_INBOUND_MESSAGE_SIZE, Math.max(MIN_GRPC_MAX_INBOUND_MESSAGE_SIZE, configured));
}
private static Map<String, Object> validateConnectedClient() throws Exception {
EtcdSessionState state = sessionState();
Client active = requireClient();
@ -1630,6 +1648,26 @@ public final class EtcdAgent {
return element == null || element.isJsonNull() ? fallback : element.getAsInt();
}
private static int intUrlParamOrDefault(String params, String key, int fallback) {
if (params == null || params.isBlank()) {
return fallback;
}
for (String entry : params.replaceFirst("^\\?", "").split("&")) {
int separator = entry.indexOf('=');
String entryKey = separator < 0 ? entry : entry.substring(0, separator);
if (!key.equals(entryKey)) {
continue;
}
String value = separator < 0 ? "" : entry.substring(separator + 1);
try {
return Integer.parseInt(value);
} catch (NumberFormatException ignored) {
return fallback;
}
}
return fallback;
}
private static boolean boolOrDefault(JsonObject object, String key, boolean fallback) {
JsonElement element = object.get(key);
return element == null || element.isJsonNull() ? fallback : element.getAsBoolean();

View File

@ -89,6 +89,41 @@ final class EtcdAgentTest {
);
}
@Test
void grpcInboundLimitDefaultsTo32MiBAndUsesSafeBounds() {
Assertions.assertEquals(
32 * 1024 * 1024,
EtcdAgent.grpcMaxInboundMessageSize(new JsonObject())
);
Assertions.assertEquals(
64 * 1024 * 1024,
EtcdAgent.grpcMaxInboundMessageSize(
JsonParser.parseString("{\"grpc_max_inbound_message_size\":67108864}").getAsJsonObject()
)
);
Assertions.assertEquals(
1024 * 1024,
EtcdAgent.grpcMaxInboundMessageSize(
JsonParser.parseString("{\"grpc_max_inbound_message_size\":0}").getAsJsonObject()
)
);
Assertions.assertEquals(
256 * 1024 * 1024,
EtcdAgent.grpcMaxInboundMessageSize(
JsonParser.parseString("{\"grpc_max_inbound_message_size\":536870912}").getAsJsonObject()
)
);
}
@Test
void grpcInboundLimitCanBeConfiguredThroughConnectionUrlParams() {
JsonObject connection = JsonParser.parseString(
"{\"url_params\":\"foo=bar&grpc_max_inbound_message_size=50331648\"}"
).getAsJsonObject();
Assertions.assertEquals(48 * 1024 * 1024, EtcdAgent.grpcMaxInboundMessageSize(connection));
}
@Test
void validateConnectionRequiresAnActiveSession() {
String response = EtcdAgent.handleRequest(

View File

@ -46,11 +46,17 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
private String tableCacheSchema = "";
private long tableCacheTimeMillis;
private List<TableInfo> tableCache = Collections.emptyList();
private ConnectParams databaseListParams;
public Gbase8sAgent() {
super(GBASE8S_PROFILE);
}
@Override
public boolean supportsConnectionPooling() {
return false;
}
public static String buildUrl(ConnectParams params) {
if (!params.getConnection_string().trim().isEmpty()) {
return params.getConnection_string();
@ -78,6 +84,38 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
return defaultGbaseServer(params.getHost());
}
static String buildUrlForDatabase(ConnectParams params, String database) {
return buildUrl(paramsForDatabase(params, database));
}
private static ConnectParams paramsForDatabase(ConnectParams params, String database) {
String connectionString = trim(params.getConnection_string());
if (!connectionString.isEmpty()) {
int schemeEnd = connectionString.indexOf("://");
int databaseStart = schemeEnd < 0 ? -1 : connectionString.indexOf('/', schemeEnd + 3);
if (databaseStart >= 0) {
int paramsStart = connectionString.indexOf(':', databaseStart + 1);
String suffix = paramsStart >= 0 ? connectionString.substring(paramsStart) : "";
connectionString = connectionString.substring(0, databaseStart + 1) + database + suffix;
}
}
ConnectParams databaseParams = new ConnectParams(
params.getHost(),
params.getPort(),
database,
params.getUsername(),
params.getPassword(),
params.getUrl_params(),
connectionString,
params.isMysql_compat_mode(),
params.getJdbc_driver_class(),
params.getJdbc_driver_paths()
);
databaseParams.setGbase_server(getGbaseServer(params));
return databaseParams;
}
@Override
protected String buildJdbcUrl(ConnectParams params) {
return buildUrl(params);
@ -86,11 +124,13 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
@Override
protected void afterConnect(ConnectParams params, Connection connection) {
super.afterConnect(params, connection);
databaseListParams = paramsForDatabase(params, "sysmaster");
clearMetadataCache();
}
@Override
protected void afterDisconnect() {
databaseListParams = null;
clearMetadataCache();
}
@ -109,13 +149,7 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
if (cached != null) {
return cached;
}
List<String> names = queryDatabaseNamesInCatalog("sysmaster", "SELECT name FROM sysdatabases ORDER BY name");
if (names.isEmpty()) {
names = queryDatabaseNames("SELECT name FROM sysmaster:sysdatabases ORDER BY name");
}
if (names.isEmpty()) {
names = queryDatabaseNames("SELECT name FROM sysdatabases ORDER BY name");
}
List<String> names = queryDatabaseNamesFromSysmaster();
if (names.isEmpty()) {
return super.listDatabases();
}
@ -135,8 +169,14 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
if (cached != null) {
return cached;
}
Connection connection = requireConnection();
if (!connection.getMetaData().supportsSchemasInDataManipulation()) {
List<String> schemas = Collections.emptyList();
cacheSchemas(catalog, schemas);
return schemas;
}
Set<String> schemas = new LinkedHashSet<>();
try (PreparedStatement stmt = requireConnection().prepareStatement(
try (PreparedStatement stmt = connection.prepareStatement(
"SELECT DISTINCT owner FROM systables WHERE tabid >= 100 AND tabtype IN ('T', 'V') ORDER BY owner"
); ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
@ -419,33 +459,13 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
return value.toLowerCase(Locale.ROOT).contains(needle.toLowerCase(Locale.ROOT));
}
private List<String> queryDatabaseNames(String sql) {
try {
return queryDatabaseNames(requireConnection(), sql);
} catch (Exception ignored) {
private List<String> queryDatabaseNamesFromSysmaster() {
ConnectParams params = databaseListParams;
if (params == null) {
return Collections.emptyList();
}
}
private List<String> queryDatabaseNamesInCatalog(String catalog, String sql) {
try {
Connection connection = requireConnection();
String previousCatalog = "";
try {
previousCatalog = trim(connection.getCatalog());
} catch (Exception ignored) {
}
connection.setCatalog(catalog);
try {
return queryDatabaseNames(connection, sql);
} finally {
if (!previousCatalog.isEmpty()) {
try {
connection.setCatalog(previousCatalog);
} catch (Exception ignored) {
}
}
}
try (Connection connection = openInitializedConnection(params)) {
return queryDatabaseNames(connection, "SELECT name FROM sysdatabases ORDER BY name");
} catch (Exception ignored) {
return Collections.emptyList();
}

View File

@ -14,6 +14,7 @@ import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
@ -31,6 +32,7 @@ class Gbase8sAgentTest {
Assertions.assertEquals("jdbc:gbasedbt-sqli://{host}:{port}/{database}:GBASEDBTSERVER=gbase8s", agent.getProfile().getUrlTemplate());
Assertions.assertEquals(9088, agent.getProfile().getDefaultPort());
Assertions.assertTrue(agent.getProfile().getSkipExecutionContext());
Assertions.assertFalse(agent.supportsConnectionPooling());
}
@Test
@ -114,6 +116,71 @@ class Gbase8sAgentTest {
Assertions.assertEquals("jdbc:gbasedbt-sqli://db.example.com:20013/app:GBASEDBTSERVER=gbase01", url);
}
@Test
void buildsSysmasterUrlWithoutChangingTheConfiguredDatabase() {
ConnectParams params = new ConnectParams(
"db.example.com",
20013,
"appdb",
"user",
"password",
"CLIENT_LOCALE=zh_cn.utf8",
"",
false
);
params.setGbase_server("gbase01");
Assertions.assertEquals(
"jdbc:gbasedbt-sqli://db.example.com:20013/sysmaster:GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_cn.utf8",
Gbase8sAgent.buildUrlForDatabase(params, "sysmaster")
);
}
@Test
void replacesDatabaseInCustomConnectionStringForDatabaseListing() {
ConnectParams params = new ConnectParams(
"",
0,
"",
"user",
"password",
"",
"jdbc:gbasedbt-sqli://db.example.com:20013/appdb:GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_cn.utf8",
false
);
Assertions.assertEquals(
"jdbc:gbasedbt-sqli://db.example.com:20013/sysmaster:GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_cn.utf8",
Gbase8sAgent.buildUrlForDatabase(params, "sysmaster")
);
}
@Test
void omitsOwnerSchemasWhenTheDatabaseCannotUseThemInDml() {
List<String> sql = new ArrayList<>();
Gbase8sAgent agent = new Gbase8sAgent();
TestSupport.setPrivateConnection(
agent,
schemaConnection(false, sql, resultSet(new String[]{"owner"}, new Object[][]{{"gbasedbt"}}))
);
Assertions.assertTrue(agent.listSchemas().isEmpty());
Assertions.assertTrue(sql.isEmpty());
}
@Test
void listsOwnerSchemasWhenTheDatabaseSupportsThemInDml() {
List<String> sql = new ArrayList<>();
Gbase8sAgent agent = new Gbase8sAgent();
TestSupport.setPrivateConnection(
agent,
schemaConnection(true, sql, resultSet(new String[]{"owner"}, new Object[][]{{"gbasedbt"}}))
);
Assertions.assertEquals(List.of("gbasedbt"), agent.listSchemas());
Assertions.assertEquals(1, sql.size());
}
@Test
void constrainedListTablesUsesGbase8sSystemTableQuery() {
List<String> sql = new ArrayList<>();
@ -367,6 +434,37 @@ class Gbase8sAgentTest {
});
}
private static Connection schemaConnection(boolean supportsSchemasInDml, List<String> sql, ResultSet resultSet) {
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
if ("supportsSchemasInDataManipulation".equals(method.getName())) {
return supportsSchemasInDml;
}
return defaultValue(method.getReturnType());
});
PreparedStatement statement = proxy(PreparedStatement.class, (method, args) -> {
if ("executeQuery".equals(method.getName())) {
return resultSet;
}
return defaultValue(method.getReturnType());
});
return proxy(Connection.class, (method, args) -> {
if ("getCatalog".equals(method.getName())) {
return "appdb";
}
if ("getMetaData".equals(method.getName())) {
return metadata;
}
if ("prepareStatement".equals(method.getName())) {
sql.add(String.valueOf(args[0]));
return statement;
}
if ("isClosed".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
}
private static ResultSet resultSet(String[] columns, Object[][] rows) {
int[] index = {-1};
Object[] lastValue = {null};

View File

@ -14,6 +14,14 @@ public final class HighgoAgent extends PostgresLikeAgent {
super(HIGHGO_PROFILE);
}
@Override
public String setSchemaSQL(String schema) {
if ("public".equals(schema)) {
return super.setSchemaSQL(schema);
}
return super.setSchemaSQL(schema) + ", public";
}
public static void main(String[] args) {
new MultiSessionJsonRpcServer(HighgoAgent::new).run();
}

View File

@ -28,6 +28,15 @@ class HighgoAgentTest extends JdbcFakeExecutionBehaviorTest {
Assertions.assertEquals("jdbc:highgo://{host}:{port}/{database}", agent.getProfile().getUrlTemplate());
}
@Test
void preservesPublicFunctionsWhenSwitchingSchemas() {
HighgoAgent agent = new HighgoAgent();
Assertions.assertEquals("SET search_path TO \"app\", public", agent.setSchemaSQL("app"));
Assertions.assertEquals("SET search_path TO \"public\"", agent.setSchemaSQL("public"));
Assertions.assertEquals("SET search_path TO \"PUBLIC\", public", agent.setSchemaSQL("PUBLIC"));
}
@Test
void readsViewSourceWithQuotedRegclassParameter() {
HighgoAgent agent = new HighgoAgent();

View File

@ -17,6 +17,7 @@ import com.dbx.agent.QueryResult;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@ -86,6 +87,26 @@ public final class HiveAgent extends AbstractJdbcAgent {
});
}
@Override
public String getTableDdl(String schema, String table) {
return unchecked(() -> {
String qualifiedName = schema == null || schema.trim().isEmpty()
? JdbcIdentifiers.INSTANCE.backtick(table)
: JdbcIdentifiers.INSTANCE.backtick(schema) + "." + JdbcIdentifiers.INSTANCE.backtick(table);
try (Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery("SHOW CREATE TABLE " + qualifiedName)) {
StringBuilder ddl = new StringBuilder();
while (rs.next()) {
String line = rs.getString(1);
if (line != null) {
ddl.append(line).append('\n');
}
}
return ddl.toString();
}
});
}
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
return Collections.emptyList();

View File

@ -1,8 +1,18 @@
package com.dbx.agent.hive;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.test.TestSupport;
import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class HiveAgentTest {
@ -39,4 +49,79 @@ class HiveAgentTest {
HiveAgent.buildUrl(params)
);
}
@Test
void getTableDdlUsesHiveShowCreateTable() {
HiveAgent agent = new HiveAgent();
List<String> queries = new ArrayList<>();
String expectedDdl = "CREATE TABLE `hive_test`.`cleaned_data_table`(col string)\n"
+ "ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'\n"
+ "LOCATION 'hdfs://warehouse/cleaned_data_table'";
ResultSet resultSet = proxy(ResultSet.class, new InvocationHandler() {
private int row = -1;
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
if ("next".equals(method.getName())) {
return ++row == 0;
}
if ("getString".equals(method.getName())) {
return expectedDdl;
}
return defaultValue(method.getReturnType());
}
});
Statement statement = proxy(Statement.class, (proxy, method, args) -> {
if ("executeQuery".equals(method.getName())) {
queries.add((String) args[0]);
return resultSet;
}
return defaultValue(method.getReturnType());
});
Connection connection = proxy(Connection.class, (proxy, method, args) -> {
if ("createStatement".equals(method.getName())) {
return statement;
}
if ("isClosed".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
TestSupport.setPrivateConnection(agent, connection);
assertEquals(expectedDdl + "\n", agent.getTableDdl("hive_test", "cleaned_data_table"));
assertEquals(List.of("SHOW CREATE TABLE `hive_test`.`cleaned_data_table`"), queries);
}
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
}
private static Object defaultValue(Class<?> type) {
if (type == boolean.class) {
return false;
}
if (type == int.class) {
return 0;
}
if (type == long.class) {
return 0L;
}
if (type == float.class) {
return 0F;
}
if (type == double.class) {
return 0D;
}
if (type == byte.class) {
return (byte) 0;
}
if (type == short.class) {
return (short) 0;
}
if (type == char.class) {
return '\0';
}
return null;
}
}

View File

@ -24,10 +24,10 @@ import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
public final class InformixAgent extends AbstractJdbcAgent {
private String loginOwner = "";
@ -187,28 +187,25 @@ public final class InformixAgent extends AbstractJdbcAgent {
}
}
}
return mergeSchemaOwners(catalogOwners, loginOwner);
return normalizeSchemaOwners(catalogOwners);
});
}
static String schemaCatalogSql() {
// Informix JDBC catalogs are databases; schemas are the object owners in the current database.
// Informix schemas are object owners. Include routine-only owners because
// the same sidebar node also exposes procedures and functions.
return "SELECT owner FROM systables WHERE tabid >= 100 AND owner IS NOT NULL "
+ "UNION SELECT owner FROM sysprocedures WHERE owner IS NOT NULL ORDER BY owner";
}
static List<String> mergeSchemaOwners(List<String> catalogOwners, String loginOwner) {
Set<String> owners = new TreeSet<>();
static List<String> normalizeSchemaOwners(List<String> catalogOwners) {
Set<String> owners = new LinkedHashSet<>();
for (String owner : catalogOwners) {
String normalized = normalizeOwner(owner);
if (!normalized.isEmpty()) {
owners.add(normalized);
}
}
String normalizedLoginOwner = normalizeOwner(loginOwner);
if (!normalizedLoginOwner.isEmpty()) {
owners.add(normalizedLoginOwner);
}
return new ArrayList<>(owners);
}

View File

@ -173,7 +173,7 @@ class InformixAgentTest {
}
@Test
void listsSchemasFromTableRoutineAndCurrentLoginOwners() {
void listsSchemasFromTableAndRoutineOwnersWithoutLoginFallback() {
InformixAgent agent = new InformixAgent();
java.sql.Connection connection = JdbcMetadataSqlFake.connection();
TestSupport.setPrivateConnection(agent, connection);
@ -181,7 +181,7 @@ class InformixAgentTest {
params.setUsername("current_owner");
agent.afterConnect(params, connection);
Assertions.assertEquals(List.of("current_owner"), agent.listSchemas());
Assertions.assertEquals(List.of(), agent.listSchemas());
Assertions.assertEquals(
List.of("SELECT owner FROM systables WHERE tabid >= 100 AND owner IS NOT NULL "
@ -189,12 +189,11 @@ class InformixAgentTest {
JdbcMetadataSqlFake.statements
);
Assertions.assertEquals(
List.of("current_owner", "routine_owner", "table_owner"),
InformixAgent.mergeSchemaOwners(
List.of("table_owner", "routine_owner", "routine_owner", " "),
"current_owner"
)
List.of("table_owner", "routine_owner"),
InformixAgent.normalizeSchemaOwners(List.of("table_owner", "routine_owner", "routine_owner", " "))
);
Assertions.assertTrue(InformixAgent.schemaCatalogSql().contains("sysprocedures"));
Assertions.assertFalse(InformixAgent.normalizeSchemaOwners(List.of("routine_owner", " ")).contains("current_owner"));
Assertions.assertNotEquals(InformixAgent.databaseCatalogSql(), InformixAgent.schemaCatalogSql());
}

View File

@ -41,6 +41,8 @@ public final class KafkaAgent {
private static final PrintStream JSON_RPC_OUT = System.out;
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
private static final int DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
private static final int MAX_PEEK_MESSAGE_COUNT = 100;
private static final int MAX_PEEK_SCAN_RECORDS = 1_000;
private static final int DEFAULT_SESSION_TIMEOUT_MS = 30_000;
private static final int DEFAULT_ZOOKEEPER_CONNECTION_TIMEOUT_MS = 10_000;
private static final String ZOOKEEPER_PROPERTY_PREFIX = "zookeeper.";
@ -1025,31 +1027,43 @@ public final class KafkaAgent {
String topic = stringOrEmpty(params, "topic");
Integer partition = integerOrNull(params, "partition");
Long offset = longOrNull(params, "offset");
int count = Math.max(1, intOrDefault(params, "count", 10));
int count = validatedPeekCount(intOrDefault(params, "count", 10));
PeekStartPosition startPosition = peekStartPosition(params);
boolean explicitStartPosition = stringOrNull(params, "startPosition") != null;
validatePeekRequest(startPosition, explicitStartPosition, partition, offset);
boolean legacyOffsetRequest = !explicitStartPosition && offset != null;
JsonObject conn = activeConnection;
if (conn == null) {
throw new IllegalStateException("Kafka Agent is not connected");
}
Properties props = peekConsumerProperties(conn, count);
Duration requestTimeout = Duration.ofMillis(peekRequestTimeoutMs(conn, props));
try (KafkaConsumer<String, byte[]> consumer = new KafkaConsumer<>(props)) {
List<TopicPartition> candidatePartitions = resolvePeekPartitions(consumer, topic, partition);
List<TopicPartition> candidatePartitions = resolvePeekPartitions(
consumer, topic, partition, requestTimeout
);
if (candidatePartitions.isEmpty()) {
return Collections.singletonMap("messages", Collections.emptyList());
return peekMessagesResult(Collections.emptyList(), false);
}
Map<TopicPartition, Long> beginningOffsets =
consumer.beginningOffsets(candidatePartitions, Duration.ofSeconds(5));
consumer.beginningOffsets(candidatePartitions, requestTimeout);
Map<TopicPartition, Long> endOffsets =
consumer.endOffsets(candidatePartitions, Duration.ofSeconds(5));
consumer.endOffsets(candidatePartitions, requestTimeout);
List<TopicPartition> readablePartitions = new ArrayList<>();
Map<TopicPartition, Long> seekOffsets = new LinkedHashMap<>();
for (TopicPartition tp : candidatePartitions) {
long beginningOffset = beginningOffsets.getOrDefault(tp, 0L);
long endOffset = endOffsets.getOrDefault(tp, beginningOffset);
long requestedOffset = offset != null ? offset : beginningOffset;
Long requestedOffset = requestedPeekOffset(
startPosition, offset, legacyOffsetRequest, beginningOffset, endOffset
);
if (requestedOffset == null) {
continue;
}
Long seekOffset = normalizePeekOffset(requestedOffset, beginningOffset, endOffset);
if (seekOffset == null) {
continue;
@ -1058,7 +1072,23 @@ public final class KafkaAgent {
seekOffsets.put(tp, seekOffset);
}
if (readablePartitions.isEmpty()) {
return Collections.singletonMap("messages", Collections.emptyList());
return peekMessagesResult(Collections.emptyList(), false);
}
int messagesPerPartition = peekMessagesPerPartition(count, readablePartitions.size());
int scanLimit = peekScanLimit(count, readablePartitions.size());
Map<TopicPartition, Long> snapshotEndOffsets = new LinkedHashMap<>();
if (startPosition == PeekStartPosition.LATEST) {
for (TopicPartition tp : readablePartitions) {
long beginningOffset = beginningOffsets.getOrDefault(tp, 0L);
long endOffset = endOffsets.getOrDefault(tp, beginningOffset);
seekOffsets.put(tp, recentPeekStartOffset(
beginningOffset, endOffset, messagesPerPartition
));
}
}
for (TopicPartition tp : readablePartitions) {
snapshotEndOffsets.put(tp, endOffsets.getOrDefault(tp, 0L));
}
consumer.assign(readablePartitions);
@ -1066,27 +1096,89 @@ public final class KafkaAgent {
consumer.seek(entry.getKey(), entry.getValue());
}
List<Map<String, Object>> messages = collectPeekedMessages(
timeout -> consumer.poll(timeout),
() -> {
Map<TopicPartition, Long> positions = new LinkedHashMap<>();
for (TopicPartition tp : readablePartitions) {
positions.put(tp, consumer.position(tp));
}
return allPeekPartitionsCaughtUp(readablePartitions, positions, endOffsets);
},
count,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(500)
);
sortPeekedMessages(messages);
long deadlineNs = System.nanoTime() + requestTimeout.toNanos();
Duration pollTimeout = Duration.ofMillis(Math.min(500L, requestTimeout.toMillis()));
PeekCollectionState collection;
if (startPosition == PeekStartPosition.LATEST) {
collection = collectLatestPeekedMessages(
consumer,
readablePartitions,
beginningOffsets,
seekOffsets,
snapshotEndOffsets,
messagesPerPartition,
scanLimit,
deadlineNs,
pollTimeout
);
} else {
PeekCollectionCompletionChecker snapshotComplete = state -> allPeekPartitionsComplete(
readablePartitions,
state.remainingByPartition,
currentPeekPositions(consumer, readablePartitions),
snapshotEndOffsets
);
collection = new PeekCollectionState(readablePartitions, messagesPerPartition);
collection.incomplete = !collectPeekedMessages(
timeout -> consumer.poll(timeout),
snapshotComplete,
record -> recordIsBeforeEndOffset(record, snapshotEndOffsets),
collection,
scanLimit,
deadlineNs,
pollTimeout
);
}
List<Map<String, Object>> messages = collection.messages;
sortPeekedMessages(messages, startPosition);
if (messages.size() > count) {
messages = new ArrayList<>(messages.subList(0, count));
}
return Collections.singletonMap("messages", messages);
return peekMessagesResult(messages, collection.incomplete);
}
}
static Map<String, Object> peekMessagesResult(List<Map<String, Object>> messages, boolean incomplete) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("messages", messages);
result.put("incomplete", incomplete);
return result;
}
static int validatedPeekCount(int count) {
if (count < 1 || count > MAX_PEEK_MESSAGE_COUNT) {
throw new IllegalArgumentException(
"Peek message count must be between 1 and " + MAX_PEEK_MESSAGE_COUNT
);
}
return count;
}
static int peekRequestTimeoutMs(JsonObject conn, Properties props) {
Integer connectionTimeout = integerOrNull(conn, "request_timeout_ms");
if (connectionTimeout != null) {
return positiveTimeoutMs("request_timeout_ms", connectionTimeout);
}
String configuredTimeout = props.getProperty(
ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG,
String.valueOf(DEFAULT_REQUEST_TIMEOUT_MS)
);
try {
return positiveTimeoutMs(ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG, Integer.parseInt(configuredTimeout));
} catch (NumberFormatException error) {
throw new IllegalArgumentException(
ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG + " must be a positive integer", error
);
}
}
private static int positiveTimeoutMs(String name, int timeoutMs) {
if (timeoutMs <= 0) {
throw new IllegalArgumentException(name + " must be a positive integer");
}
return timeoutMs;
}
static Properties peekConsumerProperties(JsonObject conn, int count) {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers(conn));
@ -1103,19 +1195,45 @@ public final class KafkaAgent {
}
/**
* Poll until {@code count} messages are collected, every assigned partition has reached its
* end offset, or {@code deadlineNs} expires. Empty polls retry until caught-up or deadline
* they must not abort early (broker / network / first-fetch latency can exceed one poll).
* Reads until each partition supplies its share of the page or reaches the snapshot boundary.
* This counts retained records, rather than treating an offset range as a record count.
*/
static List<Map<String, Object>> collectPeekedMessages(
PeekRecordPoller poller,
PeekCaughtUpChecker caughtUpChecker,
int count,
PeekRecordFilter recordFilter,
List<TopicPartition> partitions,
int messagesPerPartition,
int maxScanRecords,
long deadlineNs,
Duration pollTimeout
) {
List<Map<String, Object>> messages = new ArrayList<>();
while (messages.size() < count && System.nanoTime() < deadlineNs) {
PeekCollectionState collection = new PeekCollectionState(partitions, messagesPerPartition);
collectPeekedMessages(
poller,
state -> state.allPartitionQuotasSatisfied() || caughtUpChecker.allPartitionsCaughtUp(),
recordFilter,
collection,
maxScanRecords,
deadlineNs,
pollTimeout
);
return collection.messages;
}
private static boolean collectPeekedMessages(
PeekRecordPoller poller,
PeekCollectionCompletionChecker completionChecker,
PeekRecordFilter recordFilter,
PeekCollectionState collection,
int maxScanRecords,
long deadlineNs,
Duration pollTimeout
) {
while (System.nanoTime() < deadlineNs) {
if (completionChecker.isComplete(collection)) {
return true;
}
long remainingNs = deadlineNs - System.nanoTime();
if (remainingNs <= 0) {
break;
@ -1125,19 +1243,205 @@ public final class KafkaAgent {
: pollTimeout;
ConsumerRecords<String, byte[]> records = poller.poll(timeout);
if (records.isEmpty()) {
if (caughtUpChecker.allPartitionsCaughtUp()) {
break;
}
continue;
}
for (ConsumerRecord<String, byte[]> record : records) {
messages.add(peekedMessageFromRecord(record));
if (messages.size() >= count) {
break;
if (++collection.scannedRecords > maxScanRecords) {
return false;
}
TopicPartition partition = new TopicPartition(record.topic(), record.partition());
int remaining = collection.remainingByPartition.getOrDefault(partition, 0);
if (remaining <= 0 || !recordFilter.include(record)) {
continue;
}
collection.messages.add(peekedMessageFromRecord(record));
collection.remainingByPartition.put(partition, remaining - 1);
}
}
return messages;
return completionChecker.isComplete(collection);
}
/**
* Starts at the snapshot tail and widens backward when compacted or retained-offset gaps
* leave a partition short of its record quota.
*/
private static PeekCollectionState collectLatestPeekedMessages(
KafkaConsumer<String, byte[]> consumer,
List<TopicPartition> partitions,
Map<TopicPartition, Long> beginningOffsets,
Map<TopicPartition, Long> initialSeekOffsets,
Map<TopicPartition, Long> snapshotEndOffsets,
int messagesPerPartition,
int maxScanRecords,
long deadlineNs,
Duration pollTimeout
) {
Map<TopicPartition, Long> rangeStartOffsets = new LinkedHashMap<>(initialSeekOffsets);
Map<TopicPartition, Long> rangeEndOffsets = new LinkedHashMap<>(snapshotEndOffsets);
Map<TopicPartition, Long> rangeWidths = new LinkedHashMap<>();
for (TopicPartition partition : partitions) {
long rangeStart = rangeStartOffsets.getOrDefault(partition, 0L);
long rangeEnd = rangeEndOffsets.getOrDefault(partition, rangeStart);
rangeWidths.put(partition, Math.max(1L, rangeEnd - rangeStart));
}
PeekCollectionState collection = new PeekCollectionState(partitions, messagesPerPartition);
while (!collection.allPartitionQuotasSatisfied()) {
PeekCollectionCompletionChecker rangeComplete = state -> allPeekPartitionsComplete(
partitions,
state.remainingByPartition,
currentPeekPositions(consumer, partitions),
rangeEndOffsets
);
if (!collectLatestPeekRange(
timeout -> consumer.poll(timeout),
rangeComplete,
record -> recordIsBeforeEndOffset(record, rangeEndOffsets),
collection,
maxScanRecords,
deadlineNs,
pollTimeout
)) {
collection.incomplete = true;
break;
}
if (collection.allPartitionQuotasSatisfied()) {
break;
}
boolean expanded = false;
for (TopicPartition partition : partitions) {
if (collection.remainingByPartition.getOrDefault(partition, 0) <= 0) {
continue;
}
long beginningOffset = beginningOffsets.getOrDefault(partition, 0L);
long currentStart = rangeStartOffsets.getOrDefault(partition, beginningOffset);
if (currentStart <= beginningOffset) {
continue;
}
long nextStart = previousLatestPeekStartOffset(
beginningOffset,
currentStart,
rangeWidths.getOrDefault(partition, 1L)
);
rangeEndOffsets.put(partition, currentStart);
rangeStartOffsets.put(partition, nextStart);
rangeWidths.put(partition, Math.max(1L, currentStart - nextStart));
consumer.seek(partition, nextStart);
expanded = true;
}
if (!expanded) {
break;
}
}
return collection;
}
/**
* Scans one backward-expanded range to its end, retaining only each partition's newest
* remaining records. Stopping early would select older records from a widened range.
*/
private static boolean collectLatestPeekRange(
PeekRecordPoller poller,
PeekCollectionCompletionChecker rangeComplete,
PeekRecordFilter recordFilter,
PeekCollectionState collection,
int maxScanRecords,
long deadlineNs,
Duration pollTimeout
) {
Map<TopicPartition, Deque<Map<String, Object>>> rangeMessages = new HashMap<>();
while (System.nanoTime() < deadlineNs) {
if (rangeComplete.isComplete(collection)) {
break;
}
long remainingNs = deadlineNs - System.nanoTime();
if (remainingNs <= 0) {
break;
}
Duration timeout = pollTimeout.toNanos() > remainingNs
? Duration.ofNanos(remainingNs)
: pollTimeout;
ConsumerRecords<String, byte[]> records = poller.poll(timeout);
for (ConsumerRecord<String, byte[]> record : records) {
if (++collection.scannedRecords > maxScanRecords) {
commitLatestPeekRange(rangeMessages, collection);
return false;
}
TopicPartition partition = new TopicPartition(record.topic(), record.partition());
int remaining = collection.remainingByPartition.getOrDefault(partition, 0);
if (remaining <= 0 || !recordFilter.include(record)) {
continue;
}
Deque<Map<String, Object>> latestRecords = rangeMessages.computeIfAbsent(
partition,
ignored -> new ArrayDeque<>()
);
retainLatestPeekRecord(latestRecords, peekedMessageFromRecord(record), remaining);
}
}
boolean complete = rangeComplete.isComplete(collection);
commitLatestPeekRange(rangeMessages, collection);
return complete;
}
private static void commitLatestPeekRange(
Map<TopicPartition, Deque<Map<String, Object>>> rangeMessages,
PeekCollectionState collection
) {
for (Map.Entry<TopicPartition, Deque<Map<String, Object>>> entry : rangeMessages.entrySet()) {
int retainedCount = entry.getValue().size();
collection.messages.addAll(entry.getValue());
collection.remainingByPartition.computeIfPresent(
entry.getKey(),
(ignored, remaining) -> remaining - retainedCount
);
}
}
static <T> void retainLatestPeekRecord(Deque<T> records, T record, int maxRecords) {
records.addLast(record);
if (records.size() > maxRecords) {
records.removeFirst();
}
}
private static boolean recordIsBeforeEndOffset(
ConsumerRecord<String, byte[]> record,
Map<TopicPartition, Long> endOffsets
) {
Long endOffset = endOffsets.get(new TopicPartition(record.topic(), record.partition()));
return endOffset != null && record.offset() < endOffset;
}
private static Map<TopicPartition, Long> currentPeekPositions(
KafkaConsumer<String, byte[]> consumer,
List<TopicPartition> partitions
) {
Map<TopicPartition, Long> positions = new LinkedHashMap<>();
for (TopicPartition partition : partitions) {
positions.put(partition, consumer.position(partition));
}
return positions;
}
static boolean allPeekPartitionsComplete(
List<TopicPartition> partitions,
Map<TopicPartition, Integer> remainingByPartition,
Map<TopicPartition, Long> positions,
Map<TopicPartition, Long> endOffsets
) {
for (TopicPartition partition : partitions) {
if (remainingByPartition.getOrDefault(partition, 0) <= 0) {
continue;
}
long endOffset = endOffsets.getOrDefault(partition, 0L);
long position = positions.getOrDefault(partition, 0L);
if (position < endOffset) {
return false;
}
}
return true;
}
static boolean allPeekPartitionsCaughtUp(
@ -1165,16 +1469,44 @@ public final class KafkaAgent {
boolean allPartitionsCaughtUp();
}
@FunctionalInterface
private interface PeekCollectionCompletionChecker {
boolean isComplete(PeekCollectionState collection);
}
@FunctionalInterface
interface PeekRecordFilter {
boolean include(ConsumerRecord<String, byte[]> record);
}
private static final class PeekCollectionState {
private final List<Map<String, Object>> messages = new ArrayList<>();
private final Map<TopicPartition, Integer> remainingByPartition = new HashMap<>();
private int scannedRecords;
private boolean incomplete;
private PeekCollectionState(List<TopicPartition> partitions, int messagesPerPartition) {
for (TopicPartition partition : partitions) {
remainingByPartition.put(partition, messagesPerPartition);
}
}
private boolean allPartitionQuotasSatisfied() {
return remainingByPartition.values().stream().allMatch(remaining -> remaining <= 0);
}
}
/** When partition is null, peek across every partition of the topic. */
static List<TopicPartition> resolvePeekPartitions(
KafkaConsumer<String, byte[]> consumer,
String topic,
Integer partition
Integer partition,
Duration timeout
) {
if (partition != null) {
return resolvePeekPartitions(topic, partition, Collections.emptyList());
}
List<PartitionInfo> infos = consumer.partitionsFor(topic, Duration.ofSeconds(5));
List<PartitionInfo> infos = consumer.partitionsFor(topic, timeout);
if (infos == null || infos.isEmpty()) {
return Collections.emptyList();
}
@ -1195,13 +1527,103 @@ public final class KafkaAgent {
.collect(Collectors.toList());
}
enum PeekStartPosition {
EARLIEST,
LATEST,
OFFSET,
}
/** Omitting startPosition preserves the old earliest default (or explicit legacy offset) behavior. */
static PeekStartPosition peekStartPosition(JsonObject params) {
String value = stringOrNull(params, "startPosition");
if (value == null) {
return PeekStartPosition.EARLIEST;
}
return switch (value.trim().toLowerCase(Locale.ROOT)) {
case "earliest" -> PeekStartPosition.EARLIEST;
case "latest" -> PeekStartPosition.LATEST;
case "offset" -> PeekStartPosition.OFFSET;
default -> throw new IllegalArgumentException("Unsupported peek startPosition: " + value);
};
}
static void validatePeekRequest(
PeekStartPosition startPosition,
boolean explicitStartPosition,
Integer partition,
Long offset
) {
if (partition != null && partition < 0) {
throw new IllegalArgumentException("partition must be non-negative");
}
if (!explicitStartPosition) {
// Older clients used offset directly without a startPosition field.
if (offset != null && offset < 0) {
throw new IllegalArgumentException("offset must be non-negative");
}
return;
}
if (startPosition != PeekStartPosition.OFFSET) {
if (offset != null) {
throw new IllegalArgumentException("offset is only supported when startPosition is offset");
}
return;
}
if (offset == null) {
throw new IllegalArgumentException("offset is required when startPosition is offset");
}
if (offset < 0) {
throw new IllegalArgumentException("offset must be non-negative when startPosition is offset");
}
}
static Long requestedPeekOffset(
PeekStartPosition startPosition,
Long offset,
boolean legacyOffsetRequest,
long beginningOffset,
long endOffset
) {
return switch (startPosition) {
case LATEST -> endOffset > beginningOffset ? beginningOffset : null;
case OFFSET -> offset;
case EARLIEST -> legacyOffsetRequest ? offset : beginningOffset;
};
}
static int peekScanLimit(int count, int readablePartitionCount) {
int fetchCount = recentPeekFetchCount(
peekMessagesPerPartition(count, readablePartitionCount), readablePartitionCount
);
if (fetchCount > MAX_PEEK_SCAN_RECORDS) {
throw new IllegalArgumentException(
"Kafka message browse would scan more than " + MAX_PEEK_SCAN_RECORDS + " records"
);
}
return MAX_PEEK_SCAN_RECORDS;
}
static void sortPeekedMessages(List<Map<String, Object>> messages) {
messages.sort((left, right) -> {
sortPeekedMessages(messages, PeekStartPosition.EARLIEST);
}
static void sortPeekedMessages(List<Map<String, Object>> messages, PeekStartPosition startPosition) {
if (startPosition == PeekStartPosition.OFFSET) {
messages.sort(Comparator
.comparingLong((Map<String, Object> message) ->
((Number) message.getOrDefault("offset", 0L)).longValue()
)
.thenComparingInt(message -> ((Number) message.getOrDefault("partition", 0)).intValue())
);
return;
}
Comparator<Map<String, Object>> comparator = (left, right) -> {
long leftTs = ((Number) left.getOrDefault("timestamp", 0L)).longValue();
long rightTs = ((Number) right.getOrDefault("timestamp", 0L)).longValue();
int byTs = Long.compare(leftTs, rightTs);
if (byTs != 0) {
return byTs;
return startPosition == PeekStartPosition.LATEST ? -byTs : byTs;
}
int leftPartition = ((Number) left.getOrDefault("partition", 0)).intValue();
int rightPartition = ((Number) right.getOrDefault("partition", 0)).intValue();
@ -1212,7 +1634,8 @@ public final class KafkaAgent {
long leftOffset = ((Number) left.getOrDefault("offset", 0L)).longValue();
long rightOffset = ((Number) right.getOrDefault("offset", 0L)).longValue();
return Long.compare(leftOffset, rightOffset);
});
};
messages.sort(comparator);
}
private static Map<String, Object> peekedMessageFromRecord(ConsumerRecord<String, byte[]> record) {
@ -1224,7 +1647,7 @@ public final class KafkaAgent {
msg.put("key", record.key());
Map<String, String> headers = new LinkedHashMap<>();
record.headers().forEach(h ->
headers.put(h.key(), new String(h.value(), StandardCharsets.UTF_8)));
headers.put(h.key(), h.value() == null ? "" : new String(h.value(), StandardCharsets.UTF_8)));
msg.put("headers", headers);
if (record.value() != null) {
msg.put("payloadBase64", Base64.getEncoder().encodeToString(record.value()));
@ -1398,13 +1821,14 @@ public final class KafkaAgent {
int timeout = requestTimeout(params);
DescribeClusterResult cluster = admin.describeCluster();
DescribeMetadataQuorumResult metadataQuorum = admin.describeMetadataQuorum();
String clusterId = cluster.clusterId().get(timeout, TimeUnit.MILLISECONDS);
Node controller = cluster.controller().get(timeout, TimeUnit.MILLISECONDS);
Collection<Node> nodes = cluster.nodes().get(timeout, TimeUnit.MILLISECONDS);
Map<String, Object> controller = resolveClusterController(metadataQuorum, cluster, nodes, timeout);
Map<String, Object> result = new LinkedHashMap<>();
result.put("clusterId", clusterId);
result.put("controller", controller != null ? nodeToMap(controller) : null);
result.put("controller", controller);
List<Map<String, Object>> brokerList = new ArrayList<>();
for (Node node : nodes) {
brokerList.add(nodeToMap(node));
@ -1414,6 +1838,52 @@ public final class KafkaAgent {
return result;
}
private static Map<String, Object> resolveClusterController(
DescribeMetadataQuorumResult metadataQuorum,
DescribeClusterResult cluster,
Collection<Node> brokers,
int timeout
) throws Exception {
try {
QuorumInfo quorum = metadataQuorum.quorumInfo().get(timeout, TimeUnit.MILLISECONDS);
Map<Integer, List<RaftVoterEndpoint>> endpointsByNode = new HashMap<>();
for (Map.Entry<Integer, QuorumInfo.Node> entry : quorum.nodes().entrySet()) {
endpointsByNode.put(entry.getKey(), entry.getValue().endpoints());
}
return metadataQuorumControllerToMap(quorum.leaderId(), brokers, endpointsByNode);
} catch (Exception e) {
if (isUnsupportedVersionError(e)) {
Node controller = cluster.controller().get(timeout, TimeUnit.MILLISECONDS);
return controller != null ? nodeToMap(controller) : null;
}
logger().warn("Unable to resolve Kafka metadata quorum leader; omitting the controller", e);
return null;
}
}
static Map<String, Object> metadataQuorumControllerToMap(
int leaderId,
Collection<Node> brokers,
Map<Integer, List<RaftVoterEndpoint>> endpointsByNode
) {
if (leaderId < 0) {
return null;
}
for (Node broker : brokers) {
if (broker.id() == leaderId) {
return nodeToMap(broker);
}
}
List<RaftVoterEndpoint> endpoints = endpointsByNode.getOrDefault(leaderId, Collections.emptyList());
if (!endpoints.isEmpty()) {
RaftVoterEndpoint endpoint = endpoints.get(0);
return nodeToMap(new Node(leaderId, endpoint.host(), endpoint.port()));
}
Map<String, Object> controller = new LinkedHashMap<>();
controller.put("id", leaderId);
return controller;
}
private static Object getConsumerLag(JsonObject params) throws Exception {
AdminClient admin = requireAdmin();
int timeout = requestTimeout(params);
@ -1584,6 +2054,34 @@ public final class KafkaAgent {
return requestedOffset;
}
/** Splits the requested page quota across partitions. */
static int peekMessagesPerPartition(int count, int partitionCount) {
int safePartitionCount = Math.max(1, partitionCount);
return (int) (((long) count + safePartitionCount - 1) / safePartitionCount);
}
static long recentPeekStartOffset(long beginningOffset, long endOffset, int messagesPerPartition) {
return Math.max(beginningOffset, endOffset - messagesPerPartition);
}
static long previousLatestPeekStartOffset(
long beginningOffset,
long currentStartOffset,
long currentWindowWidth
) {
long safeWindowWidth = Math.max(1L, currentWindowWidth);
long expandedWindowWidth = safeWindowWidth > Long.MAX_VALUE / 2
? Long.MAX_VALUE
: safeWindowWidth * 2;
long distanceToBeginning = currentStartOffset - beginningOffset;
return currentStartOffset - Math.min(distanceToBeginning, expandedWindowWidth);
}
static int recentPeekFetchCount(int messagesPerPartition, int partitionCount) {
long total = (long) messagesPerPartition * Math.max(1, partitionCount);
return (int) Math.min(Integer.MAX_VALUE, total);
}
private static String stringOrNull(JsonObject object, String key) {
JsonElement element = object.get(key);
return element == null || element.isJsonNull() ? null : element.getAsString();

View File

@ -1,9 +1,11 @@
package com.dbx.agent.kafka;
import com.google.gson.JsonObject;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.JsonParser;
@ -12,8 +14,11 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -26,6 +31,8 @@ import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.admin.AlterConfigOp;
import org.apache.kafka.clients.admin.Config;
import org.apache.kafka.clients.admin.ConfigEntry;
import org.apache.kafka.clients.admin.RaftVoterEndpoint;
import org.apache.kafka.common.Node;
import org.apache.kafka.common.TopicPartition;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.Watcher;
@ -242,6 +249,58 @@ class KafkaAgentTest {
assertFalse(KafkaAgent.isAclDisabledError(new RuntimeException("Timed out waiting for broker response")));
}
@Test
void metadataQuorumControllerUsesMatchingBrokerEndpoint() {
Map<String, Object> controller = KafkaAgent.metadataQuorumControllerToMap(
1,
Arrays.asList(
new Node(1, "broker-1", 9092),
new Node(2, "broker-2", 9092)
),
Collections.emptyMap()
);
assertEquals(1, controller.get("id"));
assertEquals("broker-1", controller.get("host"));
assertEquals(9092, controller.get("port"));
}
@Test
void metadataQuorumControllerUsesIsolatedControllerEndpoint() {
Map<String, Object> controller = KafkaAgent.metadataQuorumControllerToMap(
9,
Collections.singletonList(new Node(1, "broker-1", 9092)),
Collections.singletonMap(
9,
Collections.singletonList(new RaftVoterEndpoint("CONTROLLER", "controller-9", 19093))
)
);
assertEquals(9, controller.get("id"));
assertEquals("controller-9", controller.get("host"));
assertEquals(19093, controller.get("port"));
}
@Test
void metadataQuorumControllerKeepsLeaderIdWithoutEndpoint() {
Map<String, Object> controller = KafkaAgent.metadataQuorumControllerToMap(
9,
Collections.singletonList(new Node(1, "broker-1", 9092)),
Collections.emptyMap()
);
assertEquals(Collections.singletonMap("id", 9), controller);
}
@Test
void metadataQuorumControllerReturnsNullWithoutLeader() {
assertNull(KafkaAgent.metadataQuorumControllerToMap(
-1,
Collections.singletonList(new Node(1, "broker-1", 9092)),
Collections.emptyMap()
));
}
@Test
void legacyTopicConfigAppliesSetAndDeleteWithoutLosingExistingOverrides() {
Config current = new Config(Arrays.asList(
@ -302,6 +361,183 @@ class KafkaAgentTest {
assertNull(KafkaAgent.normalizePeekOffset(0, 5, 5));
}
@Test
void peekStartPositionDefaultsToEarliestForOlderClients() {
assertEquals(KafkaAgent.PeekStartPosition.EARLIEST,
KafkaAgent.peekStartPosition(new JsonObject()));
}
@Test
void peekStartPositionRecognizesEveryExplicitMode() {
JsonObject latest = new JsonObject();
latest.addProperty("startPosition", "latest");
JsonObject earliest = new JsonObject();
earliest.addProperty("startPosition", "earliest");
JsonObject offset = new JsonObject();
offset.addProperty("startPosition", "offset");
assertEquals(KafkaAgent.PeekStartPosition.LATEST, KafkaAgent.peekStartPosition(latest));
assertEquals(KafkaAgent.PeekStartPosition.EARLIEST, KafkaAgent.peekStartPosition(earliest));
assertEquals(KafkaAgent.PeekStartPosition.OFFSET, KafkaAgent.peekStartPosition(offset));
}
@Test
void peekStartPositionRejectsUnknownValues() {
JsonObject params = new JsonObject();
params.addProperty("startPosition", "middle");
assertThrows(IllegalArgumentException.class, () -> KafkaAgent.peekStartPosition(params));
}
@Test
void offsetStartPositionAllowsAllPartitionsButRequiresANonNegativeOffset() {
assertDoesNotThrow(() ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, null, 0L));
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, 0, null));
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, -1, 0L));
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, 0, -1L));
}
@Test
void nonOffsetStartPositionsRejectAnOffset() {
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.LATEST, true, 0, 7L));
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, true, 0, 7L));
}
@Test
void latestSkipsEmptyPartitions() {
assertNull(KafkaAgent.requestedPeekOffset(
KafkaAgent.PeekStartPosition.LATEST, null, false, 5L, 5L
));
}
@Test
void everyStartPositionRejectsNegativePartitions() {
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.LATEST, true, -1, null));
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, true, -1, null));
}
@Test
void legacyOffsetWithoutStartPositionKeepsTheExistingReadBehavior() {
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, false, null, 7L);
assertEquals(7L, KafkaAgent.requestedPeekOffset(
KafkaAgent.PeekStartPosition.EARLIEST, 7L, true, 0L, 10L
));
}
@Test
void explicitEarliestDoesNotReuseAnOffsetFromAnOlderRequest() {
assertEquals(0L, KafkaAgent.requestedPeekOffset(
KafkaAgent.PeekStartPosition.EARLIEST, 7L, false, 0L, 10L
));
}
@Test
void offsetSortUsesPartitionAsADeterministicTieBreaker() {
var messages = new java.util.ArrayList<Map<String, Object>>();
messages.add(Map.of("partition", 2, "offset", 7L));
messages.add(Map.of("partition", 1, "offset", 7L));
messages.add(Map.of("partition", 0, "offset", 8L));
KafkaAgent.sortPeekedMessages(messages, KafkaAgent.PeekStartPosition.OFFSET);
assertEquals(1, messages.get(0).get("partition"));
assertEquals(2, messages.get(1).get("partition"));
assertEquals(8L, messages.get(2).get("offset"));
}
@Test
void splitsMessageWindowAcrossPartitions() {
assertEquals(4, KafkaAgent.peekMessagesPerPartition(10, 3));
assertEquals(10, KafkaAgent.peekMessagesPerPartition(10, 1));
}
@Test
void startsLatestMessageWindowNearThePartitionEnd() {
assertEquals(90L, KafkaAgent.recentPeekStartOffset(0, 100, 10));
assertEquals(5L, KafkaAgent.recentPeekStartOffset(5, 8, 10));
}
@Test
void boundsThePerPartitionMessageQuotaBeforeTrimmingTheResult() {
assertEquals(12, KafkaAgent.recentPeekFetchCount(4, 3));
assertEquals(4, KafkaAgent.peekMessagesPerPartition(10, 3));
}
@Test
void peekRejectsAWindowThatExceedsTheScanLimit() {
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.peekScanLimit(100, 1_001));
}
@Test
void peekWindowCalculationDoesNotOverflow() {
assertEquals(1, KafkaAgent.peekMessagesPerPartition(
Integer.MAX_VALUE, Integer.MAX_VALUE
));
}
@Test
void latestPeekExpandsBackwardAcrossSparseOffsetGaps() {
assertEquals(6L, KafkaAgent.recentPeekStartOffset(0L, 11L, 5));
assertEquals(0L, KafkaAgent.previousLatestPeekStartOffset(0L, 6L, 5L));
assertEquals(12L, KafkaAgent.previousLatestPeekStartOffset(0L, 32L, 10L));
}
@Test
void latestPeekRetainsTheNewestRecordsFromAnExpandedRange() {
Deque<Long> latestOffsets = new ArrayDeque<>();
for (long offset = 86L; offset <= 95L; offset++) {
KafkaAgent.retainLatestPeekRecord(latestOffsets, offset, 4);
}
assertEquals(List.of(92L, 93L, 94L, 95L), new ArrayList<>(latestOffsets));
}
@Test
void peekCountMustStayWithinTheServiceLimit() {
assertEquals(100, KafkaAgent.validatedPeekCount(100));
assertThrows(IllegalArgumentException.class, () -> KafkaAgent.validatedPeekCount(0));
assertThrows(IllegalArgumentException.class, () -> KafkaAgent.validatedPeekCount(101));
}
@Test
void peekUsesTheConfiguredConsumerRequestTimeout() {
Properties properties = new Properties();
properties.put("request.timeout.ms", "1500");
assertEquals(1_500, KafkaAgent.peekRequestTimeoutMs(new JsonObject(), properties));
}
@Test
void peekRequestTimeoutPrefersTheConnectionOverrideAndRejectsInvalidValues() {
Properties properties = new Properties();
properties.put("request.timeout.ms", "1500");
JsonObject connection = new JsonObject();
connection.addProperty("request_timeout_ms", 2_500);
assertEquals(2_500, KafkaAgent.peekRequestTimeoutMs(connection, properties));
properties.put("request.timeout.ms", "0");
assertThrows(IllegalArgumentException.class, () -> KafkaAgent.peekRequestTimeoutMs(new JsonObject(), properties));
}
@Test
void incompletePeekResultsAreExplicitlyMarked() {
Map<String, Object> partial = KafkaAgent.peekMessagesResult(List.of(), true);
Map<String, Object> complete = KafkaAgent.peekMessagesResult(List.of(), false);
assertEquals(true, partial.get("incomplete"));
assertEquals(false, complete.get("incomplete"));
}
@Test
void resolvePeekPartitionsUsesSinglePartitionWhenSpecified() {
var partitions = KafkaAgent.resolvePeekPartitions("events", 2, List.of(0, 1, 2));
@ -330,6 +566,37 @@ class KafkaAgentTest {
assertEquals(20L, messages.get(3).get("timestamp"));
}
@Test
void sortPeekedMessagesCanOrderNewestFirst() {
var messages = new java.util.ArrayList<Map<String, Object>>();
messages.add(Map.of("timestamp", 20L, "partition", 1, "offset", 1L));
messages.add(Map.of("timestamp", 10L, "partition", 0, "offset", 5L));
messages.add(Map.of("timestamp", 10L, "partition", 0, "offset", 2L));
messages.add(Map.of("timestamp", 10L, "partition", 1, "offset", 0L));
KafkaAgent.sortPeekedMessages(messages, KafkaAgent.PeekStartPosition.LATEST);
assertEquals(20L, messages.get(0).get("timestamp"));
assertEquals(0, messages.get(1).get("partition"));
assertEquals(2L, messages.get(1).get("offset"));
assertEquals(5L, messages.get(2).get("offset"));
assertEquals(1, messages.get(3).get("partition"));
}
@Test
void sortPeekedMessagesOrdersOffsetModeByOffsetAscending() {
var messages = new java.util.ArrayList<Map<String, Object>>();
messages.add(Map.of("timestamp", 10L, "partition", 0, "offset", 5L));
messages.add(Map.of("timestamp", 30L, "partition", 0, "offset", 2L));
messages.add(Map.of("timestamp", 20L, "partition", 0, "offset", 3L));
KafkaAgent.sortPeekedMessages(messages, KafkaAgent.PeekStartPosition.OFFSET);
assertEquals(2L, messages.get(0).get("offset"));
assertEquals(3L, messages.get(1).get("offset"));
assertEquals(5L, messages.get(2).get("offset"));
}
@Test
void allPeekPartitionsCaughtUpRequiresEveryPartitionAtEndOffset() {
TopicPartition p0 = new TopicPartition("events", 0);
@ -348,6 +615,33 @@ class KafkaAgentTest {
));
}
@Test
void peekCompletionStopsAfterEachPartitionSuppliesItsQuota() {
TopicPartition p0 = new TopicPartition("events", 0);
TopicPartition p1 = new TopicPartition("events", 1);
List<TopicPartition> partitions = List.of(p0, p1);
Map<TopicPartition, Long> endOffsets = Map.of(p0, 100L, p1, 100L);
assertTrue(KafkaAgent.allPeekPartitionsComplete(
partitions,
Map.of(p0, 0, p1, 0),
Map.of(p0, 1L, p1, 1L),
endOffsets
));
assertFalse(KafkaAgent.allPeekPartitionsComplete(
partitions,
Map.of(p0, 0, p1, 1),
Map.of(p0, 1L, p1, 1L),
endOffsets
));
assertTrue(KafkaAgent.allPeekPartitionsComplete(
partitions,
Map.of(p0, 0, p1, 1),
Map.of(p0, 1L, p1, 100L),
endOffsets
));
}
@Test
void collectPeekedMessagesRetriesAfterEmptyFirstPoll() {
TopicPartition tp = new TopicPartition("events", 0);
@ -365,8 +659,11 @@ class KafkaAgentTest {
AtomicInteger polls = new AtomicInteger();
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
timeout -> polls.getAndIncrement() == 0 ? ConsumerRecords.empty() : withData,
() -> false,
() -> polls.get() >= 2,
ignored -> true,
List.of(tp),
1,
1_000,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(1)
);
@ -378,7 +675,8 @@ class KafkaAgentTest {
}
@Test
void collectPeekedMessagesStopsOnEmptyPollWhenCaughtUp() {
void collectPeekedMessagesDoesNotPollWhenAlreadyCaughtUp() {
TopicPartition tp = new TopicPartition("events", 0);
AtomicInteger polls = new AtomicInteger();
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
timeout -> {
@ -386,13 +684,208 @@ class KafkaAgentTest {
return ConsumerRecords.empty();
},
() -> true,
record -> true,
List.of(tp),
10,
1_000,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(1)
);
assertEquals(0, polls.get());
assertTrue(messages.isEmpty());
}
@Test
void collectPeekedMessagesExcludesRecordsPastTheSnapshotEndOffset() {
TopicPartition tp = new TopicPartition("events", 0);
ConsumerRecord<String, byte[]> included = new ConsumerRecord<>(
"events", 0, 9L, "before", "before".getBytes(StandardCharsets.UTF_8)
);
ConsumerRecord<String, byte[]> excluded = new ConsumerRecord<>(
"events", 0, 10L, "after", "after".getBytes(StandardCharsets.UTF_8)
);
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(tp, List.of(included, excluded)));
AtomicInteger polls = new AtomicInteger();
AtomicInteger caughtUpChecks = new AtomicInteger();
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
timeout -> {
polls.incrementAndGet();
return batch;
},
() -> caughtUpChecks.getAndIncrement() > 0,
record -> record.offset() < 10L,
List.of(tp),
2,
1_000,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(1)
);
assertEquals(1, polls.get());
assertTrue(messages.isEmpty());
assertEquals(1, messages.size());
assertEquals(9L, messages.get(0).get("offset"));
}
@Test
void peekCollectsFromEveryPartitionBeforeTrimming() {
TopicPartition p0 = new TopicPartition("events", 0);
TopicPartition p1 = new TopicPartition("events", 1);
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(
p0, List.of(
new ConsumerRecord<>("events", 0, 9L, "p0-first", "one".getBytes(StandardCharsets.UTF_8)),
new ConsumerRecord<>("events", 0, 10L, "p0-second", "two".getBytes(StandardCharsets.UTF_8))
),
p1, List.of(
new ConsumerRecord<>("events", 1, 7L, "p1-first", "three".getBytes(StandardCharsets.UTF_8))
)
));
AtomicInteger polls = new AtomicInteger();
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
timeout -> polls.getAndIncrement() == 0 ? batch : ConsumerRecords.empty(),
() -> polls.get() > 0,
record -> true,
List.of(p0, p1),
1,
1_000,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(1)
);
assertEquals(2, messages.size());
assertTrue(messages.stream().anyMatch(message -> message.get("partition").equals(0)));
assertTrue(messages.stream().anyMatch(message -> message.get("partition").equals(1)));
}
@Test
void peekWaitsForEveryPartitionWindowWhenOnePartitionRespondsFirst() {
TopicPartition p0 = new TopicPartition("events", 0);
TopicPartition p1 = new TopicPartition("events", 1);
ConsumerRecords<String, byte[]> firstPartition = new ConsumerRecords<>(Map.of(p0, List.of(
new ConsumerRecord<>("events", 0, 0L, "p0-first", "one".getBytes(StandardCharsets.UTF_8)),
new ConsumerRecord<>("events", 0, 1L, "p0-second", "two".getBytes(StandardCharsets.UTF_8))
)));
ConsumerRecords<String, byte[]> secondPartition = new ConsumerRecords<>(Map.of(p1, List.of(
new ConsumerRecord<>("events", 1, 0L, "p1-first", "three".getBytes(StandardCharsets.UTF_8))
)));
AtomicInteger polls = new AtomicInteger();
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
timeout -> polls.getAndIncrement() == 0 ? firstPartition : secondPartition,
() -> polls.get() >= 2,
ignored -> true,
List.of(p0, p1),
1,
1_000,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(1)
);
assertEquals(2, polls.get());
assertEquals(2, messages.size());
assertTrue(messages.stream().anyMatch(message -> message.get("partition").equals(0)));
assertTrue(messages.stream().anyMatch(message -> message.get("partition").equals(1)));
}
@Test
void peekRetainsRecordsReadBeforeTheScanLimit() {
TopicPartition partition = new TopicPartition("events", 0);
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(partition, List.of(
new ConsumerRecord<>("events", 0, 9L, "first", "one".getBytes(StandardCharsets.UTF_8)),
new ConsumerRecord<>("events", 0, 10L, "second", "two".getBytes(StandardCharsets.UTF_8))
)));
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
timeout -> batch,
() -> false,
record -> true,
List.of(partition),
2,
1,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(1)
);
assertEquals(1, messages.size());
assertEquals(9L, messages.get(0).get("offset"));
}
@Test
void peekCountsSparseOffsetsAsRecordsInsteadOfOffsetWindowWidth() {
TopicPartition partition = new TopicPartition("events", 0);
// A compacted topic can retain these two records while offsets 1..9 are absent.
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(partition, List.of(
new ConsumerRecord<>("events", 0, 0L, "first", "one".getBytes(StandardCharsets.UTF_8)),
new ConsumerRecord<>("events", 0, 10L, "second", "two".getBytes(StandardCharsets.UTF_8))
)));
AtomicInteger polls = new AtomicInteger();
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
timeout -> polls.getAndIncrement() == 0 ? batch : ConsumerRecords.empty(),
() -> polls.get() > 0,
record -> true,
List.of(partition),
5,
1_000,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(1)
);
assertEquals(2, messages.size());
assertEquals(0L, messages.get(0).get("offset"));
assertEquals(10L, messages.get(1).get("offset"));
}
@Test
void peekHandlesKafkaHeadersWithNullValues() {
TopicPartition partition = new TopicPartition("events", 0);
ConsumerRecord<String, byte[]> record = new ConsumerRecord<>(
"events", 0, 0L, "key", "value".getBytes(StandardCharsets.UTF_8)
);
record.headers().add("tombstone", null);
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(partition, List.of(record)));
AtomicInteger polls = new AtomicInteger();
List<Map<String, Object>> messages = KafkaAgent.collectPeekedMessages(
timeout -> {
polls.incrementAndGet();
return batch;
},
() -> polls.get() > 0,
ignored -> true,
List.of(partition),
1,
1_000,
System.nanoTime() + Duration.ofSeconds(5).toNanos(),
Duration.ofMillis(1)
);
assertEquals("", ((Map<?, ?>) messages.get(0).get("headers")).get("tombstone"));
}
@Test
void incompleteCollectCanBeReturnedWithAnExplicitStatus() {
TopicPartition partition = new TopicPartition("events", 0);
ConsumerRecords<String, byte[]> batch = new ConsumerRecords<>(Map.of(partition, List.of(
new ConsumerRecord<>("events", 0, 0L, "only", "one".getBytes(StandardCharsets.UTF_8))
)));
List<Map<String, Object>> partial = KafkaAgent.collectPeekedMessages(
timeout -> batch,
() -> false,
record -> true,
List.of(partition),
5,
1_000,
System.nanoTime() - 1,
Duration.ofMillis(1)
);
assertEquals(0, partial.size());
Map<String, Object> result = KafkaAgent.peekMessagesResult(partial, true);
assertEquals(true, result.get("incomplete"));
}
@Test

View File

@ -100,6 +100,10 @@ func TestKingbaseIntegration(t *testing.T) {
if err != nil || !strings.Contains(fmt.Sprint(source["source"]), function) {
t.Fatalf("get function source failed: source=%v err=%v", source, err)
}
viewSource, err := server.getObjectSource("public", view, "VIEW")
if err != nil || !strings.Contains(fmt.Sprint(viewSource["source"]), parent) {
t.Fatalf("get view source failed: source=%v err=%v", viewSource, err)
}
transactionParams := map[string]json.RawMessage{
"schema": rawJSON("public"),

View File

@ -16,9 +16,14 @@ import (
const metadataTimeout = 15 * time.Second
// Escape '_' so only Kingbase internal SYS_/XLOG_ prefixes are hidden; names
// such as SYSTEMS and SYSLOG may be user-created schemas in MySQL mode.
const kingbaseMySQLCompatListSchemasSQL = `SELECT schema_name FROM information_schema.schemata WHERE UPPER(schema_name) <> 'INFORMATION_SCHEMA' AND UPPER(schema_name) NOT LIKE 'SYS\_%' ESCAPE '\' AND UPPER(schema_name) NOT LIKE 'XLOG\_%' ESCAPE '\' ORDER BY schema_name`
const (
kingbaseListDatabasesSQL = "SELECT datname FROM sys_catalog.sys_database WHERE datallowconn AND LOWER(datname) NOT IN ('template0', 'template1') ORDER BY datname"
kingbaseListDatabasesPostgresSQL = "SELECT datname FROM pg_catalog.pg_database WHERE datallowconn AND LOWER(datname) NOT IN ('template0', 'template1') ORDER BY datname"
)
// Escape '_' so only Kingbase internal SYS_/XLOG_ prefixes are hidden; use a
// non-backslash escape because MySQL mode treats backslash as a string escape.
const kingbaseMySQLCompatListSchemasSQL = `SELECT schema_name FROM information_schema.schemata WHERE UPPER(schema_name) <> 'INFORMATION_SCHEMA' AND UPPER(schema_name) NOT LIKE 'SYS#_%' ESCAPE '#' AND UPPER(schema_name) NOT LIKE 'XLOG#_%' ESCAPE '#' ORDER BY schema_name`
var kingbaseDataTypes = []string{
"bigint", "bigserial", "bit", "bit varying", "boolean", "bytea", "char", "character",
@ -191,8 +196,8 @@ func (s *server) connectionInfo() (map[string]any, error) {
func (s *server) listDatabases() ([]databaseInfo, error) {
queries := []string{
"SELECT datname FROM sys_catalog.sys_database WHERE NOT datistemplate AND datallowconn ORDER BY datname",
"SELECT datname FROM pg_catalog.pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname",
kingbaseListDatabasesSQL,
kingbaseListDatabasesPostgresSQL,
"SELECT current_database()",
}
for _, query := range queries {
@ -539,16 +544,45 @@ func isUndefinedColumn(err error, columnName string) bool {
}
func (s *server) informationSchemaColumns(schema, table string, primary map[string]bool) ([]columnInfo, error) {
result, err := s.queryInformationSchemaColumns(schema, table, primary, true)
if err != nil && isUndefinedColumn(err, "column_type") {
return s.queryInformationSchemaColumns(schema, table, primary, false)
// Cache the optional information_schema capabilities for this connection so
// subsequent table metadata requests do not repeat known failing probes.
includeColumnType := !s.infoColumnTypeUnsupported
includeUdtName := !s.infoUdtNameUnsupported
for {
result, err := s.queryInformationSchemaColumns(schema, table, primary, includeColumnType, includeUdtName)
if err == nil {
return result, nil
}
switch {
case includeColumnType && isUndefinedColumn(err, "column_type"):
includeColumnType = false
s.infoColumnTypeUnsupported = true
case includeUdtName && isUndefinedColumn(err, "udt_name"):
includeUdtName = false
s.infoUdtNameUnsupported = true
default:
return nil, err
}
}
return result, err
}
func (s *server) queryInformationSchemaColumns(schema, table string, primary map[string]bool, includeFullDataType bool) ([]columnInfo, error) {
fullDataTypeExpression := "c.column_type"
if !includeFullDataType {
func (s *server) queryInformationSchemaColumns(schema, table string, primary map[string]bool, includeColumnType, includeUdtName bool) ([]columnInfo, error) {
var fullDataTypeExpression string
switch {
case includeColumnType && includeUdtName:
fullDataTypeExpression = `CASE
WHEN UPPER(TRIM(c.data_type)) IN ('USER-DEFINED', 'USER_DEFINED')
AND UPPER(COALESCE(NULLIF(TRIM(c.column_type), ''), 'USER-DEFINED')) IN ('USER-DEFINED', 'USER_DEFINED')
THEN c.udt_name
ELSE c.column_type
END`
case includeColumnType:
fullDataTypeExpression = "c.column_type"
case includeUdtName:
fullDataTypeExpression = `CASE
WHEN UPPER(TRIM(c.data_type)) IN ('USER-DEFINED', 'USER_DEFINED') THEN c.udt_name
END AS column_type`
default:
fullDataTypeExpression = "NULL AS column_type"
}
query := fmt.Sprintf(`SELECT c.column_name, c.data_type, %s, c.is_nullable, c.column_default,
@ -707,17 +741,35 @@ func (s *server) getObjectSource(schema, name, objectType string) (map[string]an
catalog, prefix, function := "sys_catalog", "sys", "sys_get_viewdef"
if s.mode.postgresCatalog {
catalog, prefix, function = "pg_catalog", "pg", "pg_get_viewdef"
} else if s.usePgViewDefinition {
function = "pg_get_viewdef"
}
querySource := func(definitionFunction string) error {
query := fmt.Sprintf("SELECT %s(c.oid) FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relname=%s LIMIT 1", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name))
return s.requireDBQueryRow(query, &source)
}
err = querySource(function)
if err != nil && function == "sys_get_viewdef" && isUndefinedFunction(err, function) {
s.usePgViewDefinition = true
err = querySource("pg_get_viewdef")
}
query := fmt.Sprintf("SELECT %s(c.oid) FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relname=%s LIMIT 1", function, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name))
err = s.requireDBQueryRow(query, &source)
}
} else if kind == "FUNCTION" || kind == "PROCEDURE" {
catalog, prefix, function := "sys_catalog", "sys", "sys_get_functiondef"
if s.mode.postgresCatalog {
catalog, prefix, function = "pg_catalog", "pg", "pg_get_functiondef"
} else if s.usePgFunctionDefinition {
function = "pg_get_functiondef"
}
querySource := func(definitionFunction string) error {
query := fmt.Sprintf("SELECT %s(p.oid) FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s AND p.proname=%s ORDER BY CASE WHEN p.prorettype=2278 THEN 0 ELSE 1 END LIMIT 1", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name))
return s.requireDBQueryRow(query, &source)
}
err = querySource(function)
if err != nil && function == "sys_get_functiondef" && isUndefinedFunction(err, function) {
s.usePgFunctionDefinition = true
err = querySource("pg_get_functiondef")
}
query := fmt.Sprintf("SELECT %s(p.oid) FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s AND p.proname=%s ORDER BY CASE WHEN p.prorettype=2278 THEN 0 ELSE 1 END LIMIT 1", function, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name))
err = s.requireDBQueryRow(query, &source)
}
if err != nil && err != sql.ErrNoRows {
return nil, err

View File

@ -134,7 +134,11 @@ type server struct {
params connectParams
mode kingbaseMode
usePgDefaultExpression bool
usePgViewDefinition bool
usePgFunctionDefinition bool
catalogIdentityUnsupported bool
infoColumnTypeUnsupported bool
infoUdtNameUnsupported bool
currentSchema string
schemaSet bool
sessions map[string]*querySession
@ -454,7 +458,11 @@ func (s *server) connect(cp connectParams) error {
s.params = cp
s.mode = detectKingbaseMode(db, cp.MySQLCompatMode)
s.usePgDefaultExpression = false
s.usePgViewDefinition = false
s.usePgFunctionDefinition = false
s.catalogIdentityUnsupported = false
s.infoColumnTypeUnsupported = false
s.infoUdtNameUnsupported = false
return nil
}
@ -516,7 +524,11 @@ func (s *server) disconnect() error {
s.cancelActiveQuery()
s.closeAllQuerySessions()
s.usePgDefaultExpression = false
s.usePgViewDefinition = false
s.usePgFunctionDefinition = false
s.catalogIdentityUnsupported = false
s.infoColumnTypeUnsupported = false
s.infoUdtNameUnsupported = false
s.currentSchema = ""
s.schemaSet = false
if s.db == nil {
@ -904,7 +916,6 @@ func buildDSNWithSSLMode(cp connectParams, sslMode string) string {
"password=" + quoteDSNValue(cp.Password),
"dbname=" + quoteDSNValue(cp.Database),
"sslmode=" + sslMode,
"connect_timeout=15",
}
if cp.CACertPath != "" {
parts = append(parts, "sslrootcert="+quoteDSNValue(cp.CACertPath))
@ -915,11 +926,15 @@ func buildDSNWithSSLMode(cp connectParams, sslMode string) string {
if cp.ClientKeyPath != "" {
parts = append(parts, "sslkey="+quoteDSNValue(cp.ClientKeyPath))
}
for _, pair := range strings.FieldsFunc(cp.URLParams, func(r rune) bool { return r == '&' || r == ';' }) {
key, value, ok := strings.Cut(pair, "=")
if ok && isSafeParamKey(key) && !strings.EqualFold(strings.TrimSpace(key), "sslmode") {
parts = append(parts, strings.TrimSpace(key)+"="+quoteDSNValue(strings.TrimSpace(value)))
}
// Classify and de-duplicate the app-supplied url_params. The connect_timeout
// default is only applied when the user did not provide one (natively or via
// the connectTimeout alias), so the parameter is never emitted twice.
urlParams := normalizeURLParams(cp.URLParams)
if !hasDSNParam(urlParams, "connect_timeout") {
parts = append(parts, "connect_timeout=15")
}
for _, p := range urlParams {
parts = append(parts, p.key+"="+quoteDSNValue(p.value))
}
return strings.Join(parts, " ")
}
@ -994,19 +1009,39 @@ func rewriteNativeConnectionStringSSLMode(value, sslMode string) string {
if strings.HasPrefix(strings.ToLower(value), "kingbase://") {
baseAndQuery, fragment, hasFragment := strings.Cut(value, "#")
base, query, hasQuery := strings.Cut(baseAndQuery, "?")
pairs := make([]string, 0)
params := make([]dsnParam, 0)
if hasQuery {
for _, pair := range strings.Split(query, "&") {
key, _, _ := strings.Cut(pair, "=")
decodedKey, err := url.QueryUnescape(key)
if err == nil && strings.EqualFold(decodedKey, "sslmode") {
if pair == "" {
continue
}
if pair != "" {
pairs = append(pairs, pair)
rawKey, rawValue, _ := strings.Cut(pair, "=")
decodedKey, err := url.QueryUnescape(rawKey)
if err != nil {
decodedKey = rawKey
}
if strings.EqualFold(strings.TrimSpace(decodedKey), "sslmode") {
continue
}
decodedValue, err := url.QueryUnescape(rawValue)
if err != nil {
decodedValue = rawValue
}
nativeKey, keep := classifyDSNParam(decodedKey, decodedValue)
if !keep {
continue
}
params = append(params, dsnParam{
key: nativeKey,
value: rawValue, // preserve the original percent-encoding
fromAlias: !strings.EqualFold(strings.TrimSpace(decodedKey), nativeKey),
})
}
}
pairs := make([]string, 0, len(params)+1)
for _, p := range mergeDSNParams(params) {
pairs = append(pairs, url.QueryEscape(p.key)+"="+p.value)
}
pairs = append(pairs, "sslmode="+url.QueryEscape(sslMode))
result := base + "?" + strings.Join(pairs, "&")
if hasFragment {
@ -1016,14 +1051,32 @@ func rewriteNativeConnectionStringSSLMode(value, sslMode string) string {
}
fields := splitNativeDSNFields(value)
result := make([]string, 0, len(fields)+1)
params := make([]dsnParam, 0, len(fields))
passthrough := make([]string, 0)
for _, field := range fields {
key, _, ok := strings.Cut(field, "=")
if ok && strings.EqualFold(strings.TrimSpace(key), "sslmode") {
key, rawValue, ok := strings.Cut(field, "=")
if !ok {
passthrough = append(passthrough, field)
continue
}
result = append(result, field)
if strings.EqualFold(strings.TrimSpace(key), "sslmode") {
continue
}
nativeKey, keep := classifyDSNParam(key, unquoteNativeDSNValue(rawValue))
if !keep {
continue
}
params = append(params, dsnParam{
key: nativeKey,
value: rawValue, // preserve the original quoting
fromAlias: !strings.EqualFold(strings.TrimSpace(key), nativeKey),
})
}
result := make([]string, 0, len(params)+len(passthrough)+1)
for _, p := range mergeDSNParams(params) {
result = append(result, p.key+"="+p.value)
}
result = append(result, passthrough...)
result = append(result, "sslmode="+sslMode)
return strings.Join(result, " ")
}
@ -1096,6 +1149,87 @@ func quoteDSNValue(value string) string {
return "'" + strings.ReplaceAll(strings.ReplaceAll(value, `\`, `\\`), "'", `\'`) + "'"
}
// supportedDSNParams is the curated set of parameters known to be understood by
// the gokb driver or the Kingbase server. It is no longer a strict allow-list:
// classifyDSNParam also forwards unknown lower_snake_case names to the server as
// run-time parameters, because gokb passes every non-driver-setting to the
// startup packet (conn.go startup()). This set is what classifyDSNParam treats
// as definitely native, which short-circuits the camelCase JDBC heuristic so
// CamelCase GUCs such as DateStyle/TimeZone are still forwarded rather than
// dropped.
//
// The list mirrors the driver's own surface:
// - gokb conn.go isDriverSetting(): host, port, password, sslmode, sslcert,
// sslkey, sslrootcert, fallback_application_name, connect_timeout,
// disable_prepared_binary_result, binary_parameters, krbsrvname, krbspn;
// - the standard startup keywords user and dbname;
// - connector.go special handling: client_encoding (must be UTF8),
// datestyle, extra_float_digits;
// - common Kingbase/PostgreSQL run-time parameters that can be set in the
// startup packet: application_name, options, search_path,
// statement_timeout, work_mem, timezone and friends.
var supportedDSNParams = map[string]struct{}{
// gokb driver settings (conn.go isDriverSetting) and startup keywords
"host": {},
"port": {},
"user": {},
"password": {},
"dbname": {},
"sslmode": {},
"sslcert": {},
"sslkey": {},
"sslrootcert": {},
"fallback_application_name": {},
"connect_timeout": {},
"disable_prepared_binary_result": {},
"binary_parameters": {},
"krbsrvname": {},
"krbspn": {},
// connector.go special handling
"client_encoding": {},
"datestyle": {},
"extra_float_digits": {},
// Common run-time parameters the Kingbase server accepts in the startup
// packet (PostgreSQL-compatible GUCs).
"application_name": {},
"options": {},
"search_path": {},
"statement_timeout": {},
"lock_timeout": {},
"idle_in_transaction_session_timeout": {},
"idle_session_timeout": {},
"work_mem": {},
"maintenance_work_mem": {},
"temp_buffers": {},
"effective_cache_size": {},
"timezone": {},
"intervalstyle": {},
"lc_messages": {},
"lc_monetary": {},
"lc_numeric": {},
"lc_time": {},
"default_transaction_isolation": {},
"default_transaction_read_only": {},
"default_transaction_deferrable": {},
"synchronous_commit": {},
"client_min_messages": {},
"standard_conforming_strings": {},
"xmloption": {},
"role": {},
"session_replication_role": {},
"default_tablespace": {},
"temp_tablespaces": {},
"default_table_access_method": {},
"max_parallel_workers_per_gather": {},
}
func isSupportedDSNParam(key string) bool {
_, ok := supportedDSNParams[strings.ToLower(strings.TrimSpace(key))]
return ok
}
func isSafeParamKey(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
@ -1109,6 +1243,189 @@ func isSafeParamKey(value string) bool {
return true
}
// dsnParam is a single normalized connection parameter ready to be emitted into
// a DSN. value carries the surface-specific text (single-quoted for keyword
// DSNs, percent-encoded for kingbase:// URLs, raw for url_params) so callers can
// preserve the original quoting/encoding when only the key was rewritten.
type dsnParam struct {
key string
value string
fromAlias bool
}
// jdbcAliasParams maps a lowercased JDBC property to the native gokb/server
// parameter with equivalent semantics. clientEncoding is handled separately in
// classifyDSNParam because it also has to validate the value.
var jdbcAliasParams = map[string]string{
"connecttimeout": "connect_timeout", // both measured in seconds
"currentschema": "search_path", // both accept a comma-separated list
"applicationname": "application_name",
}
// jdbcOnlyParams lists client-side JDBC/driver properties that have no meaning to
// the Kingbase server. gokb forwards every non-driver-setting to the startup
// packet, so a value the server does not recognize fails the whole connection
// with "unrecognized configuration parameter". camelCase names are also caught by
// the heuristic in classifyDSNParam; this set additionally covers the lowercase
// JDBC properties the heuristic cannot detect and documents intent for the common
// MySQL/JDBC-style names.
var jdbcOnlyParams = map[string]struct{}{
"usessl": {},
"autoreconnect": {},
"characterencoding": {},
"servertimezone": {},
"rewritebatchedstatements": {},
"useserverprepstmts": {},
"sockettimeout": {},
"usecompression": {},
"zerodatetimebehavior": {},
"useaffectedrows": {},
"usecursorfetch": {},
"defaultfetchsize": {},
"allowmultiqueries": {},
"useunicode": {},
// Lowercase PgJDBC/Kingbase-JDBC client properties the camelCase heuristic
// would otherwise forward and break the connection.
"ssl": {},
"sslfactory": {},
"stringtype": {},
"gsslib": {},
"sspiservicename": {},
"protocolversion": {},
"loglevel": {},
}
// classifyDSNParam decides how one connection parameter should be treated and
// returns the native parameter name to emit plus whether to keep it. sslmode is
// handled separately by the callers and must not be passed here. decodedValue is
// the already-unquoted/decoded value, used only for the client_encoding check.
func classifyDSNParam(key, decodedValue string) (nativeKey string, keep bool) {
trimmed := strings.TrimSpace(key)
if !isSafeParamKey(trimmed) {
return "", false
}
lower := strings.ToLower(trimmed)
// client_encoding (native, or via the clientEncoding alias): gokb only
// accepts UTF-8, so map compatible values and drop everything else — a
// non-UTF8 value would otherwise fail the whole connection.
if lower == "client_encoding" || lower == "clientencoding" {
if isUTF8Encoding(decodedValue) {
return "client_encoding", true
}
return "", false
}
// JDBC properties with a direct native equivalent.
if native, ok := jdbcAliasParams[lower]; ok {
return native, true
}
// Curated native/server parameters are always forwarded. Matching here also
// keeps CamelCase GUCs such as DateStyle/TimeZone from being mistaken for JDBC
// camelCase properties by the heuristic below.
if isSupportedDSNParam(lower) {
return lower, true
}
// Known JDBC-only client properties never reach the server.
if _, ok := jdbcOnlyParams[lower]; ok {
return "", false
}
// Unknown parameter. Server GUCs are conventionally lower_snake_case while
// JDBC properties are camelCase, so forward snake_case names as run-time
// parameters (gokb passes them to the startup packet) and drop names carrying
// an uppercase letter as presumed client-side JDBC settings.
if hasUpperASCII(trimmed) {
return "", false
}
return lower, true
}
// mergeDSNParams applies duplicate-parameter precedence: an explicit native
// parameter beats a JDBC alias for the same key, and within the same class the
// first occurrence wins to preserve gokb's existing DSN behavior. Output order
// follows each key's first appearance.
func mergeDSNParams(params []dsnParam) []dsnParam {
result := make([]dsnParam, 0, len(params))
pos := make(map[string]int, len(params))
for _, p := range params {
if i, ok := pos[p.key]; ok {
// A later explicit native parameter may replace an earlier alias, but
// same-class duplicates keep the first value just as gokb does.
if result[i].fromAlias && !p.fromAlias {
result[i] = p
}
continue
}
pos[p.key] = len(result)
result = append(result, p)
}
return result
}
// normalizeURLParams classifies and de-duplicates the app-supplied url_params
// blob (a &/;-separated key=value list), excluding sslmode which is handled
// separately. Values are kept raw for later single-quoting.
func normalizeURLParams(raw string) []dsnParam {
params := make([]dsnParam, 0)
for _, pair := range strings.FieldsFunc(raw, func(r rune) bool { return r == '&' || r == ';' }) {
key, value, ok := strings.Cut(pair, "=")
if !ok {
continue
}
if strings.EqualFold(strings.TrimSpace(key), "sslmode") {
continue
}
val := strings.TrimSpace(value)
nativeKey, keep := classifyDSNParam(key, val)
if !keep {
continue
}
params = append(params, dsnParam{
key: nativeKey,
value: val,
fromAlias: !strings.EqualFold(strings.TrimSpace(key), nativeKey),
})
}
return mergeDSNParams(params)
}
func hasDSNParam(params []dsnParam, key string) bool {
for _, p := range params {
if p.key == key {
return true
}
}
return false
}
func hasUpperASCII(value string) bool {
for i := 0; i < len(value); i++ {
if value[i] >= 'A' && value[i] <= 'Z' {
return true
}
}
return false
}
// isUTF8Encoding mirrors gokb's isUTF8: it recognizes fuzzy variants of "UTF-8"
// (dropping non-alphanumerics, case-insensitively) as well as "unicode".
func isUTF8Encoding(name string) bool {
var b strings.Builder
for _, ch := range name {
switch {
case ch >= 'A' && ch <= 'Z':
b.WriteRune(ch + ('a' - 'A'))
case ch >= 'a' && ch <= 'z', ch >= '0' && ch <= '9':
b.WriteRune(ch)
}
}
s := b.String()
return s == "utf8" || s == "unicode"
}
func normalizeValue(value any) any {
switch typed := value.(type) {
case nil:

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