Compare commits

...
133 Commits
Author SHA1 Message Date
t8y2 095cb159ff chore(release): bump version to 0.5.56 2026-07-14 01:47:58 +08:00
zipg 95e50a0da7 feat(import): support text encoding selection 2026-07-14 01:35:52 +08:00
onenewcode 32b4acee3a feat(dameng): preserve character length units 2026-07-14 01:28:19 +08:00
zipg 8020fe5886 fix(sqlserver): preserve explicit instance ports 2026-07-14 01:27:40 +08:00
二丫讲梵 8e87ab2e39 fix(export): preserve CSV null semantics 2026-07-14 01:24:10 +08:00
t8y2 65e1100db8 test(query): cover editable Oracle subqueries 2026-07-14 01:21:17 +08:00
t8y2 e74af3e85f fix(oracle): enable deleting keyless query rows 2026-07-14 01:10:12 +08:00
Bagus Wahyu Aprianto 8497e34b31 feat(structure): add DDL copy button 2026-07-14 01:08:28 +08:00
二丫讲梵 e51004d79c fix(sidebar): keep partition pagination at schema root 2026-07-14 01:07:17 +08:00
t8y2 8cbd41e021 fix(turso): hide visible database filter 2026-07-14 00:48:01 +08:00
zipg 4475ccb7fc fix(dameng): serialize metadata queries 2026-07-13 23:57:45 +08:00
jischeng a9ca66a829 fix(doris): qualify external catalog tables 2026-07-13 23:56:53 +08:00
Bagus Wahyu Aprianto c9c2fddfe2 feat(mysql): add server metrics dashboard 2026-07-13 23:55:52 +08:00
zipg c02b3967df fix(agent): import JRE from offline bundle 2026-07-13 22:20:58 +08:00
miracle c81eda22b0 perf(sidebar): reduce schema tree memory usage 2026-07-13 22:19:59 +08:00
t8y2 8f619fb791 fix(editor): confirm unsaved structure changes 2026-07-13 21:27:02 +08:00
zipg 032a8d0f80 fix(sqlserver): restore legacy TLS RSA ciphers 2026-07-13 21:08:17 +08:00
jischeng 8de5becdef fix(connection-dialog): show Spark database icon 2026-07-13 21:08:05 +08:00
gggaiitx 8a6640e184 fix(ddl): include table comments in generated DDL 2026-07-13 21:06:08 +08:00
t8y2 5999fbf00c fix(web): stream table import uploads 2026-07-13 20:50:45 +08:00
Abeautifulsnow 78b3bb1ba1 feat(export): add streaming TXT data export 2026-07-13 20:44:57 +08:00
t8y2 99b84cbc92 fix(sidebar): use available width for table names 2026-07-13 20:34:27 +08:00
t8y2 dabbb58b0e fix(editor): keep comment columns in query ranges 2026-07-13 20:25:15 +08:00
t8y2 e2f53a786f fix(grid): inherit timeout for row counts 2026-07-13 20:13:32 +08:00
zipg 2cb58b0b5a fix(import): preserve Excel date styles outside A1 2026-07-13 20:13:19 +08:00
t8y2 446e995063 fix(mysql): harden process list refresh and kill handling 2026-07-13 20:03:16 +08:00
t8y2 c19f3e201b fix(mcp): preserve SQL literals during execution 2026-07-13 20:00:01 +08:00
t8y2 8cebc4a366 fix(query): preserve selected statement source range 2026-07-13 19:57:35 +08:00
shenmo 6bd49fcd19 docs(linux): add Spark Store and AmberPM installation 2026-07-13 19:45:01 +08:00
t8y2 852364db12 fix(grid): match unused header area background 2026-07-13 19:13:19 +08:00
t8y2 117b1731f7 fix(zookeeper): handle znodes without data 2026-07-13 19:13:18 +08:00
Bagus Wahyu Aprianto 2dec6ebeb8 feat(mysql): add process list viewer 2026-07-13 19:07:05 +08:00
二丫讲梵 6f5dffb279 feat(query): show result source ranges 2026-07-13 18:52:14 +08:00
zipg d1cce3ed1e fix(kingbase): quote MySQL-compatible identifiers 2026-07-13 18:51:32 +08:00
zipg 5ceca7fb3e fix(mongodb): preserve JSON-looking string values 2026-07-13 18:33:30 +08:00
onenewcode 66d7b965dc feat(grid): add list and range filters 2026-07-13 18:14:11 +08:00
zipg 8710bc9069 fix(nacos): preserve disabled service instances 2026-07-13 18:03:18 +08:00
rainhan c7991b316e feat(connection): test SSH tunnel profiles 2026-07-13 17:51:51 +08:00
zipg 27c60f0d30 fix(grid): keep BLOB download menu anchored 2026-07-13 17:33:36 +08:00
zipg 00101ff0ac fix(editor): improve Oracle SQL completion 2026-07-13 17:29:56 +08:00
zipg 8c11f2efaa fix(editor): complete MySQL date functions 2026-07-13 17:16:44 +08:00
zipg 16724074c0 feat(grid): improve cell detail panel 2026-07-13 17:16:38 +08:00
二丫讲梵 4c7205abe7 feat(grid): copy inserts when primary keys are hidden 2026-07-13 17:16:30 +08:00
zipg d794bbe768 fix(editor): continue completion after selecting schemas 2026-07-13 17:16:23 +08:00
Komh 35ca4e3a37 feat(duckdb): add connection bootstrap and remote catalogs 2026-07-13 17:03:33 +08:00
juve fc5e991a75 fix nix build & update deps 2026-07-13 16:27:19 +08:00
gggaiitx 6c661e8744 feat(grid): add configurable column width density 2026-07-13 16:20:45 +08:00
gggaiitx ea22dc3652 feat(editor): focus newly created query editors 2026-07-13 16:19:12 +08:00
Abeautifulsnow 9e1b0c478e feat(editor): prefill new queries from focused tables 2026-07-13 16:15:09 +08:00
zipg c2c4860a28 fix(mongodb): preserve document types when copying data 2026-07-13 16:15:02 +08:00
zipg cc9b802b49 fix(editor): handle Oracle line comments in DDL viewer 2026-07-13 16:14:56 +08:00
juve 29b351691a fix(grid): improve scrolling and sidebar responsiveness 2026-07-13 16:12:54 +08:00
t8y2 67541232a2 fix(agents): share runtimes across connection sessions 2026-07-13 15:13:48 +08:00
zipg 1280450b98 fix(import): keep table import actions visible 2026-07-13 15:06:51 +08:00
Guoyu Su a99a34b4ab fix(mongodb): use dedicated count for legacy collections 2026-07-13 13:21:50 +08:00
t8y2 03bc87c77a fix(postgres): fall back for routine signatures 2026-07-13 10:29:03 +08:00
t8y2 73f6801df9 fix(tunnels): restore profiles after settings navigation 2026-07-13 09:52:34 +08:00
t8y2 0745fbc53c feat(connection): increase default query timeout 2026-07-13 09:30:33 +08:00
t8y2 c720aeafd6 ci(agents): build driver modules in parallel 2026-07-13 08:55:14 +08:00
github-actions[bot] f0eca37344 chore: bump module versions [skip ci] 2026-07-13 00:44:38 +00:00
github-actions[bot] 67139a2ea9 chore(packages): release 0.4.28 [skip node-packages-release] 2026-07-13 00:42:31 +00:00
t8y2 be8ad972f4 chore(release): bump version to 0.5.55 2026-07-13 01:07:55 +08:00
t8y2 a05bc408a4 fix(ci): serialize mirror synchronization 2026-07-13 00:46:29 +08:00
Freedom 86dad5db9f feat(editor): show column hints for INSERT values 2026-07-13 00:39:36 +08:00
t8y2 ff570d5268 fix(redis): recognize ZRANDMEMBER command 2026-07-13 00:39:30 +08:00
Freedom d27f8e18a6 fix(grid): enable prepared row copy actions 2026-07-13 00:27:47 +08:00
t8y2 a3f3cadb39 fix(postgres): distinguish overloaded routines 2026-07-13 00:26:04 +08:00
zipg 823b4297d8 fix(window): align macOS traffic light controls 2026-07-13 00:04:27 +08:00
t8y2 4b44ccf86e feat(import): support configurable file row ranges 2026-07-12 23:14:23 +08:00
zipg 9a09bd49cb fix(mongodb): support array filters in updates 2026-07-12 23:13:36 +08:00
二丫讲梵 0e779f9edc fix(sql): improve read-only statement detection 2026-07-12 23:05:32 +08:00
juve 1ecd8cf406 feat(linux): add tray icon support 2026-07-12 23:00:04 +08:00
auth0 fa76c742c8 fix(mysql): disconnect failed export streams 2026-07-12 22:59:27 +08:00
t8y2 968d4488a6 fix(sync): send Gitee snippet files as JSON object 2026-07-12 22:41:00 +08:00
t8y2 72880d2933 feat(h2): add legacy 2.1 driver 2026-07-12 22:14:01 +08:00
zipg 0fb744591e fix(grid): improve dark data grid rendering 2026-07-12 22:08:07 +08:00
Anubis 2a92746dc0 feat(d1): add Cloudflare D1 database support 2026-07-12 22:03:57 +08:00
rainhan 977e7f78ca feat(connection): add reusable tunnel profiles 2026-07-12 22:03:46 +08:00
KomhandClaude Fable 5 4dcee1aca0 docs: add Flatpak (FlatPark) install method for Linux
Adds the Flatpak installation commands to the README (en/zh-CN) and the
website getting-started page (en/cn), as discussed in #3225.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:34:52 +08:00
wuxiemian 130477c48c fix(safety): guard object browser batch empty 2026-07-12 21:30:37 +08:00
onenewcode 7a42b1e96b fix(mysql): stop multi-statement execution after first error 2026-07-12 21:30:27 +08:00
t8y2 6368c49c16 fix(kingbase): support PostgreSQL catalog mode 2026-07-12 21:28:46 +08:00
t8y2 8fa97fa926 fix(release): serialize AtomGit asset uploads 2026-07-12 21:26:10 +08:00
t8y2 55c909e69d fix(ci): pass release sync credentials 2026-07-12 21:19:35 +08:00
github-actions[bot] 58868c5fa5 chore: bump module versions [skip ci] 2026-07-12 06:11:54 +00:00
github-actions[bot] 073ad5feca chore(packages): release 0.4.27 [skip node-packages-release] 2026-07-12 06:09:42 +00:00
二丫讲梵 4f1997d8eb feat(types): add database type help 2026-07-12 13:28:46 +08:00
t8y2 fdb33ef477 fix(test): update XLSX export argument assertions 2026-07-12 13:03:18 +08:00
t8y2 ee1007a636 chore(release): bump version to 0.5.54 2026-07-12 12:43:03 +08:00
t8y2 47e38b58ca chore: exclude tests from language statistics 2026-07-12 12:25:24 +08:00
vrustx 02e1b943e8 fix(editor): skip duplicate table aliases 2026-07-12 12:21:12 +08:00
zipg 0650428d1c feat(structure): persist local column order 2026-07-12 12:06:49 +08:00
t8y2 c3ec7fceef fix(sqlite): reuse in-memory database connection 2026-07-12 11:46:29 +08:00
miracle 16e879e40e perf(query): optimize result loading and connection reuse 2026-07-12 11:18:52 +08:00
onenewcode 24c63b6012 feat(elasticsearch): render REST JSON responses 2026-07-12 10:49:25 +08:00
t8y2 512f46a835 fix(ui): scope legacy WebView fallbacks 2026-07-12 10:30:34 +08:00
gggaiitx 3c4c16dc06 fix: 修复双击模式下单击对象失效,右键查看DDL、源码等复用右侧面板 2026-07-12 10:07:13 +08:00
t8y2 f7f0397407 fix(editor): avoid extra cursor in column selection 2026-07-12 10:05:58 +08:00
wuxiemian 1be66fb115 feat(safety): add production environment safeguards 2026-07-12 10:02:27 +08:00
t8y2 be66d6f46b fix(export): preserve numeric types in XLSX files 2026-07-12 09:45:27 +08:00
t8y2 c2b6aef315 feat(import): support batch files and Excel sheets 2026-07-12 00:59:36 +08:00
二丫讲梵 add57d3ae0 feat(toolbar): redesign query result toolbar layout 2026-07-12 00:30:43 +08:00
t8y2 fec91b9aef feat(update): mirror agent release downloads 2026-07-11 23:40:18 +08:00
t8y2 f4ff8b11a5 fix(jdbc): improve Java driver compatibility 2026-07-11 23:31:07 +08:00
t8y2 7c0fd73f7d fix(grid): keep select all inside cell details 2026-07-11 23:05:50 +08:00
t8y2 ead9a05c3c fix(quick-open): index grouped database objects 2026-07-11 22:45:04 +08:00
t8y2 bfdd7e5b1a feat(transfer): support Kingbase data transfer 2026-07-11 22:22:21 +08:00
zipgandtangheng 00d78d8b6a fix: shorten icon theme copy by platform
Co-authored-by: tangheng <tangheng@tanghengdeMacBook-Pro.local>
2026-07-11 21:55:11 +08:00
zipgandtangheng 8ff0f13bd4 fix: 兼容intel Mac旧版系统webview中样式问题
Co-authored-by: tangheng <tangheng@tanghengdeMacBook-Pro.local>
2026-07-11 21:53:29 +08:00
t8y2 361f7774e9 fix(objects): preserve database object source formatting 2026-07-11 21:38:24 +08:00
二丫讲梵 92b42d559d fix(redis): 优化自动刷新逻辑和显示TTL的计算
- 修改computeAutoRefreshTick函数,简化逻辑判断
- 更新computeDisplayTtl函数,确保倒计时为零时不显示过期的服务器TTL
2026-07-11 20:53:44 +08:00
miracle 1a309b6b63 feat(editor): configure SQL variable syntax 2026-07-11 20:45:07 +08:00
onenewcode 54632f4c16 feat(query): add MySQL standard explain table view 2026-07-11 20:34:54 +08:00
t8y2 830e3c2183 fix(mysql): stream large SQL file execution 2026-07-11 20:25:05 +08:00
t8y2 7fb9bce089 fix(redis): continue sparse key scans 2026-07-11 19:39:40 +08:00
t8y2 3afc0e74d2 fix(core): preserve query session connections 2026-07-11 19:38:45 +08:00
t8y2 c2040021f6 fix(mysql): preserve string defaults in schema diff 2026-07-11 19:38:28 +08:00
t8y2 1dd4623919 fix(sql-files): distinguish files with duplicate names 2026-07-11 18:32:17 +08:00
t8y2 e4563f9659 fix(web): harden JDBC uploads and CORS 2026-07-11 17:56:47 +08:00
zipg 3747f08baa fix(table): refresh data tabs after clearing tables 2026-07-11 17:48:51 +08:00
zipg 085d7661af fix(objects): add safe object browser batch empty action 2026-07-11 17:48:45 +08:00
t8y2 cb51832d7b fix(structure): default editor to columns 2026-07-11 13:45:49 +08:00
t8y2 a1b8174951 fix(doris): preserve external catalog in object browser 2026-07-11 13:34:18 +08:00
zipg 8d14a056b7 fix(tdengine): support table row editing 2026-07-11 13:01:33 +08:00
zipg 1a833714ba fix(agents): include Kerberos modules in managed JRE 2026-07-11 13:00:56 +08:00
gggaiitx 5bcd24df1c feat(objects): open database objects in side panel 2026-07-11 12:48:57 +08:00
t8y2 ce7739fcda fix(release): parallelize mirror asset uploads 2026-07-11 11:30:20 +08:00
t8y2 5764b3d9e0 fix(duckdb): include column comments in metadata 2026-07-11 11:20:59 +08:00
t8y2 7bb2042fe5 fix(grid): omit empty generated values on insert 2026-07-11 10:56:05 +08:00
github-actions[bot] ee8ba2f44e chore: bump module versions [skip ci] 2026-07-11 02:54:35 +00:00
github-actions[bot] 86defca7a2 chore(packages): release 0.4.26 [skip node-packages-release] 2026-07-11 02:52:41 +00:00
t8y2 28ee0ecc8c fix(access): support encrypted database files 2026-07-11 10:44:20 +08:00
github-actions[bot] a2fd38d542 chore(jdbc): bump plugin version [skip ci] 2026-07-11 01:51:38 +00:00
506 changed files with 37143 additions and 3600 deletions
+10
View File
@@ -1,3 +1,13 @@
# Keep formatter-sensitive source files consistent across platforms.
apps/desktop/src/**/*.ts text eol=lf
apps/desktop/src/**/*.vue text eol=lf
# Keep tests available for review and CI without counting them as shipped code
# in GitHub's repository language breakdown.
packages/app-tests/** linguist-vendored
apps/desktop/src/**/__tests__/** linguist-vendored
apps/desktop/src/**/*.spec.ts linguist-vendored
apps/desktop/src/**/*.test.ts linguist-vendored
packages/**/tests/** linguist-vendored
packages/**/*.spec.ts linguist-vendored
packages/**/*.test.ts linguist-vendored
+33 -11
View File
@@ -2,10 +2,11 @@
import { basename, join } from "node:path";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
const DEFAULT_API_BASE = "https://api.atomgit.com/api/v5";
const DEFAULT_REPOSITORY = "t8y2/dbx";
const DEFAULT_CONCURRENCY = 3;
function parseArgs(argv) {
const args = {
@@ -15,6 +16,7 @@ function parseArgs(argv) {
githubReleasePath: "",
assetsDir: "",
skipExistingAssets: true,
concurrency: Number.parseInt(process.env.ATOMGIT_UPLOAD_CONCURRENCY || `${DEFAULT_CONCURRENCY}`, 10),
};
for (let i = 0; i < argv.length; i++) {
@@ -43,6 +45,9 @@ function parseArgs(argv) {
if (!args.repository.includes("/")) {
throw new Error(`Invalid AtomGit repository: ${args.repository}`);
}
if (!Number.isInteger(args.concurrency) || args.concurrency < 1) {
throw new Error("ATOMGIT_UPLOAD_CONCURRENCY must be a positive integer.");
}
return args;
}
@@ -64,15 +69,17 @@ async function main() {
return;
}
console.log(`Syncing ${assets.length} asset(s) to AtomGit release ${tag}.`);
for (const assetPath of assets) {
const pendingAssets = assets.filter((assetPath) => {
const name = basename(assetPath);
if (args.skipExistingAssets && existingAssets.has(name)) {
console.log(`Skipping existing AtomGit asset: ${name}`);
continue;
return false;
}
await uploadAsset(client, tag, assetPath);
}
return true;
});
console.log(`Uploading ${pendingAssets.length} asset(s) with concurrency ${args.concurrency}.`);
await mapWithConcurrency(pendingAssets, args.concurrency, (assetPath) => uploadAsset(client, tag, assetPath));
}
async function ensureRelease(client, githubRelease) {
@@ -114,7 +121,7 @@ async function uploadAsset(client, tag, filePath) {
// AtomGit returns a per-file upload URL plus required upload headers.
// Try raw PUT first and fall back to multipart POST for API-compatible deployments.
const put = runCurl([
const putStatus = await runCurl([
"--fail-with-body",
"--location",
"--show-error",
@@ -131,12 +138,12 @@ async function uploadAsset(client, tag, filePath) {
filePath,
uploadTarget.url,
]);
if (put.status === 0) {
if (putStatus === 0) {
return;
}
console.warn(`PUT upload failed for ${name}; retrying as multipart POST.`);
const post = runCurl([
const postStatus = await runCurl([
"--fail-with-body",
"--location",
"--show-error",
@@ -151,13 +158,28 @@ async function uploadAsset(client, tag, filePath) {
`file=@${filePath};filename=${name};type=${contentTypeHeader}`,
uploadTarget.url,
]);
if (post.status !== 0) {
if (postStatus !== 0) {
throw new Error(`Failed to upload ${name} to AtomGit.`);
}
}
function runCurl(args) {
return spawnSync("curl", args, { stdio: "inherit" });
return new Promise((resolve, reject) => {
const child = spawn("curl", args, { stdio: "inherit" });
child.once("error", reject);
child.once("close", (code) => resolve(code ?? 1));
});
}
async function mapWithConcurrency(items, concurrency, worker) {
let nextIndex = 0;
async function runWorker() {
while (nextIndex < items.length) {
const index = nextIndex++;
await worker(items[index]);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runWorker));
}
function localAssets(dir) {
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
import { createReadStream, readdirSync, readFileSync, statSync } from "node:fs";
import { basename, join } from "node:path";
const DEFAULT_API_BASE = "https://api.cnb.cool";
const DEFAULT_REPOSITORY = "dbxio.com/dbx";
const DEFAULT_CONCURRENCY = 3;
const MAX_ATTEMPTS = 4;
async function main() {
const args = parseArgs(process.argv.slice(2));
const githubRelease = JSON.parse(readFileSync(args.githubReleasePath, "utf8"));
const tag = githubRelease.tagName || githubRelease.tag_name;
if (!tag) throw new Error("GitHub release JSON is missing tagName.");
const client = new CnbClient(args);
const release = await client.ensureRelease(tag, githubRelease);
const existingAssets = new Set((release.assets || []).map((asset) => asset.name));
const assets = localAssets(args.assetsDir).filter((assetPath) => {
const name = basename(assetPath);
if (existingAssets.has(name)) {
console.log(`Skipping existing CNB asset: ${name}`);
return false;
}
return true;
});
console.log(`Uploading ${assets.length} CNB asset(s) with concurrency ${args.concurrency}.`);
await mapWithConcurrency(assets, args.concurrency, (assetPath) => uploadWithRetry(client, release.id, assetPath));
}
function parseArgs(argv) {
const args = {
apiBase: process.env.CNB_API_BASE || DEFAULT_API_BASE,
repository: process.env.CNB_REPOSITORY || DEFAULT_REPOSITORY,
token: process.env.CNB_TOKEN || "",
concurrency: Number.parseInt(process.env.CNB_UPLOAD_CONCURRENCY || `${DEFAULT_CONCURRENCY}`, 10),
githubReleasePath: "",
assetsDir: "",
};
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === "--github-release") args.githubReleasePath = argv[++index];
else if (arg === "--assets-dir") args.assetsDir = argv[++index];
else throw new Error(`Unknown argument: ${arg}`);
}
if (!args.token) throw new Error("CNB_TOKEN is required.");
if (!args.githubReleasePath || !args.assetsDir) {
throw new Error("Usage: sync-cnb-release.mjs --github-release <release.json> --assets-dir <dir>");
}
if (!Number.isInteger(args.concurrency) || args.concurrency < 1) {
throw new Error("CNB_UPLOAD_CONCURRENCY must be a positive integer.");
}
return args;
}
async function uploadWithRetry(client, releaseId, filePath) {
const name = basename(filePath);
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
console.log(`Uploading ${name}, attempt ${attempt}/${MAX_ATTEMPTS}.`);
await client.uploadAsset(releaseId, filePath);
console.log(`Uploaded ${name}.`);
return;
} catch (error) {
if (attempt === MAX_ATTEMPTS) throw error;
const delayMs = 2 ** (attempt - 1) * 5000;
console.warn(`Upload failed for ${name}: ${error.message}; retrying in ${delayMs / 1000}s.`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
class CnbClient {
constructor({ apiBase, repository, token }) {
this.apiBase = apiBase.replace(/\/+$/, "");
this.repository = repository;
this.token = token;
}
async ensureRelease(tag, githubRelease) {
const payload = {
tag_name: tag,
name: githubRelease.name || tag,
body: githubRelease.body || "",
prerelease: Boolean(githubRelease.isPrerelease || githubRelease.prerelease),
target_commitish: githubRelease.targetCommitish || githubRelease.target_commitish || "",
};
const existing = await this.request("GET", `/${this.repository}/-/releases/tags/${encodeURIComponent(tag)}`, null, true);
if (!existing) return this.request("POST", `/${this.repository}/-/releases`, payload);
// Keep release metadata aligned while preserving the existing asset list used for resumable uploads.
await this.request("PATCH", `/${this.repository}/-/releases/${existing.id}`, {
name: payload.name,
body: payload.body,
});
return existing;
}
async uploadAsset(releaseId, filePath) {
const size = statSync(filePath).size;
const target = await this.request("POST", `/${this.repository}/-/releases/${releaseId}/asset-upload-url`, {
asset_name: basename(filePath),
size,
overwrite: false,
});
const uploadResponse = await fetch(target.upload_url, {
method: "PUT",
headers: { "Content-Type": "application/octet-stream", "Content-Length": `${size}` },
body: createReadStream(filePath),
duplex: "half",
});
if (!uploadResponse.ok) {
throw new Error(`CNB object upload failed with ${uploadResponse.status}: ${await uploadResponse.text()}`);
}
const verifyResponse = await fetch(decodeURIComponent(target.verify_url), {
method: "POST",
headers: this.headers(),
});
if (!verifyResponse.ok) {
throw new Error(`CNB upload confirmation failed with ${verifyResponse.status}: ${await verifyResponse.text()}`);
}
}
async request(method, path, body = null, allow404 = false) {
const response = await fetch(`${this.apiBase}${path}`, {
method,
headers: this.headers(body !== null),
body: body === null ? undefined : JSON.stringify(body),
});
if (allow404 && response.status === 404) return null;
if (!response.ok) throw new Error(`CNB API ${method} ${path} failed with ${response.status}: ${await response.text()}`);
return response.status === 204 ? null : response.json();
}
headers(json = false) {
return {
Accept: "application/vnd.cnb.api+json",
Authorization: `Bearer ${this.token}`,
...(json ? { "Content-Type": "application/json" } : {}),
};
}
}
function localAssets(dir) {
return readdirSync(dir)
.map((name) => join(dir, name))
.filter((path) => statSync(path).isFile())
.sort((left, right) => statSync(right).size - statSync(left).size || basename(left).localeCompare(basename(right)));
}
async function mapWithConcurrency(items, concurrency, worker) {
let nextIndex = 0;
async function runWorker() {
while (nextIndex < items.length) {
const index = nextIndex++;
await worker(items[index]);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runWorker));
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+70 -3
View File
@@ -80,7 +80,7 @@ jobs:
8
21
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew shadowJar
- run: ./gradlew shadowJar --parallel
working-directory: agents
- run: python3 scripts/validate_agent_jars.py
working-directory: agents
@@ -174,7 +174,7 @@ jobs:
include:
- jre-key: "21"
java-version: "21"
modules: "java.base,java.sql,java.sql.rowset,java.naming,java.management,java.desktop,java.security.jgss,java.security.sasl,jdk.charsets,java.scripting,java.compiler"
modules: "java.base,java.sql,java.sql.rowset,java.naming,java.management,java.desktop,java.security.jgss,java.security.sasl,jdk.security.auth,jdk.security.jgss,jdk.charsets,jdk.unsupported,java.scripting,java.compiler"
steps:
- uses: actions/setup-java@v4
with:
@@ -385,7 +385,7 @@ jobs:
{
"jres": {
"21": {
"version": "21.0.12",
"version": "21.0.12+kerberos.1",
"platforms": {
${JRE21_PLATFORMS}
}
@@ -551,3 +551,70 @@ jobs:
aws s3 cp "$f" "$R2_BUCKET/$key" --endpoint-url "$R2_ENDPOINT"
echo "Uploaded $(basename $f)"
done
sync-release-to-cnb:
name: Sync agents releases to CNB
needs: release
if: ${{ always() && needs.release.result == 'success' }}
runs-on: ubuntu-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:
tag: ["${{ github.ref_name }}", "agents-latest"]
steps:
- name: Sync release assets to CNB
env:
TAG_NAME: ${{ matrix.tag }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_USERNAME: ${{ github.repository_owner }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CNB_REPOSITORY: dbxio.com/dbx
CNB_USERNAME: cnb
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
run: |
set -euo pipefail
wget -q https://cnb.cool/znb/mpgrm/-/releases/download/v0.1.0/mpgrm_linux_amd64.tar.gz
tar -xf mpgrm_linux_amd64.tar.gz
chmod +x mpgrm
./mpgrm releases sync \
--repo "https://github.com/${GITHUB_REPOSITORY}.git" \
--target-repo "https://cnb.cool/${CNB_REPOSITORY}.git" \
--tags "${TAG_NAME}"
sync-release-to-atomgit:
name: Sync agents releases to AtomGit
needs: release
if: ${{ always() && needs.release.result == 'success' }}
runs-on: ubuntu-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:
tag: ["${{ github.ref_name }}", "agents-latest"]
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Sync release assets to AtomGit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ matrix.tag }}
GITHUB_REPOSITORY: ${{ github.repository }}
ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }}
ATOMGIT_REPOSITORY: t8y2/dbx
run: |
set -euo pipefail
git fetch origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force
git remote add atomgit "https://t8y2:${ATOMGIT_TOKEN}@atomgit.com/t8y2/dbx.git"
git push atomgit "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force
mkdir -p "$RUNNER_TEMP/github-release" "$RUNNER_TEMP/release-assets"
gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
--json tagName,name,body,targetCommitish,isPrerelease,isDraft,assets \
> "$RUNNER_TEMP/github-release/release.json"
gh release download "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
--dir "$RUNNER_TEMP/release-assets" --clobber
node .github/scripts/sync-atomgit-release.mjs \
--github-release "$RUNNER_TEMP/github-release/release.json" \
--assets-dir "$RUNNER_TEMP/release-assets" \
${{ matrix.tag == 'agents-latest' && '--replace-assets' || '' }}
+24 -13
View File
@@ -355,25 +355,34 @@ jobs:
needs: publish
runs-on: ubuntu-latest
steps:
- name: Sync release assets to CNB
- name: Checkout repository
uses: actions/checkout@v5
- name: Download GitHub release assets
env:
TAG_NAME: ${{ github.ref_name }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_USERNAME: ${{ github.repository_owner }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CNB_USERNAME: cnb
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/github-release" "$RUNNER_TEMP/release-assets"
gh release view "$TAG_NAME" \
--repo "$GITHUB_REPOSITORY" \
--json tagName,name,body,targetCommitish,isPrerelease,isDraft,assets \
> "$RUNNER_TEMP/github-release/release.json"
gh release download "$TAG_NAME" \
--repo "$GITHUB_REPOSITORY" \
--dir "$RUNNER_TEMP/release-assets" \
--clobber
wget -q https://cnb.cool/znb/mpgrm/-/releases/download/v0.1.0/mpgrm_linux_amd64.tar.gz
tar -xf mpgrm_linux_amd64.tar.gz
chmod +x mpgrm
./mpgrm releases sync \
--repo "https://github.com/${GITHUB_REPOSITORY}.git" \
--target-repo "https://cnb.cool/${CNB_REPOSITORY}.git" \
--tags "${TAG_NAME}"
- name: Sync release assets to CNB
env:
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
CNB_UPLOAD_CONCURRENCY: "3"
run: |
node .github/scripts/sync-cnb-release.mjs \
--github-release "$RUNNER_TEMP/github-release/release.json" \
--assets-dir "$RUNNER_TEMP/release-assets"
sync-release-to-atomgit:
name: Sync release to AtomGit
@@ -416,6 +425,8 @@ jobs:
env:
ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }}
ATOMGIT_REPOSITORY: "t8y2/dbx"
# AtomGit throttles concurrent large-asset uploads, which can expire their temporary upload URLs.
ATOMGIT_UPLOAD_CONCURRENCY: "1"
run: |
set -euo pipefail
node .github/scripts/sync-atomgit-release.mjs \
+6
View File
@@ -9,6 +9,12 @@ on:
delete:
workflow_dispatch:
# Mirror providers reject concurrent forced updates to the same ref. Serialize all
# branch, tag, and delete events so rapid GitHub pushes cannot race each other.
concurrency:
group: sync-mirrors
cancel-in-progress: false
jobs:
sync-cnb:
name: Sync to CNB
Generated
+5 -2
View File
@@ -1044,6 +1044,7 @@ checksum = "b1a9acfeb1555aa8def91fe8ff208aadaea850c109968ec35ac965edbe7d210b"
dependencies = [
"atoi_simd",
"byteorder",
"chrono",
"codepage",
"encoding_rs",
"fast-float2",
@@ -1844,7 +1845,7 @@ dependencies = [
[[package]]
name = "dbx"
version = "0.5.53"
version = "0.5.56"
dependencies = [
"anyhow",
"axum",
@@ -1857,6 +1858,7 @@ dependencies = [
"duckdb",
"font-kit",
"futures",
"libloading 0.8.9",
"log",
"mongodb",
"objc2",
@@ -1917,6 +1919,7 @@ dependencies = [
"pageant",
"percent-encoding",
"portpicker",
"quick-xml 0.37.5",
"rayon",
"redis",
"regex",
@@ -1943,7 +1946,7 @@ dependencies = [
[[package]]
name = "dbx-web"
version = "0.5.53"
version = "0.5.56"
dependencies = [
"aes-gcm 0.10.3",
"argon2",
+11 -2
View File
@@ -91,7 +91,7 @@
### 60+ Databases, One Tool
MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, 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, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, 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
@@ -213,6 +213,15 @@ scoop install dbx
winget install t8y2.dbx
```
**Flatpak (Linux):**
```bash
flatpak remote-add --if-not-exists flatpark https://dl.flatpark.org/flatpark.flatpakrepo
flatpak install flatpark com.dbxio.dbx
```
Updates then arrive through the regular `flatpak update`. See the [DBX page on FlatPark](https://flatpark.org/apps/com.dbxio.dbx/) for details.
## Self-Hosted (Docker)
DBX provides a web version that can be deployed via Docker. The examples use
@@ -395,7 +404,7 @@ DBX is 20 MB with no runtime dependencies (no Java, no Python). It includes AI a
<details>
<summary><strong>What databases are supported?</strong></summary>
MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, and more. Agent/JDBC-oriented profiles extend support to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and custom JDBC connections. Message queue admin (Pulsar, Kafka, RocketMQ) is also supported.
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, and more. Agent/JDBC-oriented profiles extend support to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and custom JDBC connections. Message queue admin (Pulsar, Kafka, RocketMQ) is also supported.
</details>
<details>
+17 -2
View File
@@ -91,7 +91,7 @@
### 60+ 种数据库,一个工具搞定
MySQL、PostgreSQL、SQLite、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB 和自定义 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、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
### 查询编辑器
@@ -213,6 +213,21 @@ scoop install dbx
winget install t8y2.dbx
```
**Flatpak (Linux):**
```bash
flatpak remote-add --if-not-exists flatpark https://dl.flatpark.org/flatpark.flatpakrepo
flatpak install flatpark com.dbxio.dbx
```
之后通过常规的 `flatpak update` 即可获取更新。详见 [FlatPark 上的 DBX 页面](https://flatpark.org/apps/com.dbxio.dbx/)。
**星火应用商店 / AmberPM (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`。
## 自托管 (Docker)
DBX 提供 Web 版本,可通过 Docker 部署。示例使用 `latest` 标签以拉取当前发布版本。
@@ -389,7 +404,7 @@ DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和
<details>
<summary><strong>支持哪些数据库?</strong></summary>
MySQL、PostgreSQL、SQLite、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo 等。JDBC 方向配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo 等。JDBC 方向配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
</details>
<details>
@@ -20,6 +20,16 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
return connection;
}
@Override
public String getIdentifierQuote() {
try {
String quote = requireConnected().getMetaData().getIdentifierQuoteString();
return quote == null || quote.trim().isEmpty() ? "" : quote.trim();
} catch (Exception ignored) {
return "";
}
}
@Override
public final void connect(ConnectParams params) {
uncheckedVoid(() -> {
@@ -106,7 +116,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
Connection conn = requireConnected();
uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs()));
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
conn,
sql,
schema,
@@ -122,7 +132,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
Connection conn = requireConnected();
uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs()));
return JdbcExecutor.INSTANCE.executePage(
return JdbcExecutor.current().executePage(
conn,
sql,
schema,
@@ -134,19 +144,19 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
@Override
public QueryPageResult fetchQueryPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchPage(sessionId, pageSize);
return JdbcExecutor.current().fetchPage(sessionId, pageSize);
}
@Override
public boolean closeQuerySession(String sessionId) {
return JdbcExecutor.INSTANCE.closeQuerySession(sessionId);
return JdbcExecutor.current().closeQuerySession(sessionId);
}
@Override
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
Connection conn = requireConnected();
uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs()));
return JdbcExecutor.INSTANCE.startTableRead(
return JdbcExecutor.current().startTableRead(
conn,
sql,
schema,
@@ -158,12 +168,12 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
@Override
public QueryPageResult fetchTableReadPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchTableReadPage(sessionId, pageSize);
return JdbcExecutor.current().fetchTableReadPage(sessionId, pageSize);
}
@Override
public boolean closeTableReadSession(String sessionId) {
return JdbcExecutor.INSTANCE.closeTableReadSession(sessionId);
return JdbcExecutor.current().closeTableReadSession(sessionId);
}
@Override
@@ -0,0 +1,27 @@
package com.dbx.agent;
final class AgentExecutionContext {
private static final ThreadLocal<JdbcExecutor> JDBC_EXECUTOR = new ThreadLocal<>();
private AgentExecutionContext() {
}
static JdbcExecutor jdbcExecutor() {
JdbcExecutor executor = JDBC_EXECUTOR.get();
return executor == null ? JdbcExecutor.INSTANCE : executor;
}
static <T> T withJdbcExecutor(JdbcExecutor executor, DatabaseAgent.ThrowingSupplier<T> supplier) throws Exception {
JdbcExecutor previous = JDBC_EXECUTOR.get();
JDBC_EXECUTOR.set(executor);
try {
return supplier.get();
} finally {
if (previous == null) {
JDBC_EXECUTOR.remove();
} else {
JDBC_EXECUTOR.set(previous);
}
}
}
}
@@ -6,11 +6,17 @@ import java.util.List;
public final class AgentProtocol {
public static final int PROTOCOL_VERSION = 1;
public static final int MULTI_SESSION_PROTOCOL_VERSION = 2;
public static final String METHOD_HANDSHAKE = "handshake";
public static final String METHOD_CONNECT = "connect";
public static final String METHOD_OPEN_SESSION = "open_session";
public static final String METHOD_CLOSE_SESSION = "close_session";
public static final String METHOD_VALIDATE_SESSION = "validate_session";
public static final String METHOD_CANCEL_SESSION = "cancel_session";
public static final String METHOD_TEST_CONNECTION = "test_connection";
public static final String METHOD_VALIDATE_CONNECTION = "validate_connection";
public static final String METHOD_CONNECTION_INFO = "connection_info";
public static final String METHOD_LIST_DATABASES = "list_databases";
public static final String METHOD_LIST_SCHEMAS = "list_schemas";
public static final String METHOD_LIST_TABLES = "list_tables";
@@ -43,6 +49,7 @@ public final class AgentProtocol {
* MongoDB read path that returns documents as relaxed Extended JSON for transfer.
*/
public static final String MONGO_METHOD_FIND_DOCUMENTS_EXTENDED_JSON = "find_documents_extended_json";
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_DROP_INDEXES = "drop_indexes";
@@ -66,6 +73,7 @@ public final class AgentProtocol {
public static final String CAPABILITY_TRANSACTION = "transaction";
public static final String CAPABILITY_DDL = "ddl";
public static final String CAPABILITY_KV = "kv";
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
CAPABILITY_CONNECT,
@@ -93,6 +101,7 @@ public final class AgentProtocol {
METHOD_CONNECT,
METHOD_TEST_CONNECTION,
METHOD_VALIDATE_CONNECTION,
METHOD_CONNECTION_INFO,
METHOD_LIST_DATABASES,
METHOD_LIST_SCHEMAS,
METHOD_LIST_TABLES,
@@ -119,11 +128,26 @@ public final class AgentProtocol {
METHOD_SHUTDOWN
));
public static final List<String> MULTI_SESSION_METHODS;
static {
List<String> methods = new java.util.ArrayList<>(COMMON_METHODS);
int insertAt = methods.indexOf(METHOD_CONNECT) + 1;
methods.addAll(insertAt, Arrays.asList(
METHOD_OPEN_SESSION,
METHOD_CLOSE_SESSION,
METHOD_VALIDATE_SESSION,
METHOD_CANCEL_SESSION
));
MULTI_SESSION_METHODS = Collections.unmodifiableList(methods);
}
public static final List<String> MONGO_LEGACY_METHODS = Collections.unmodifiableList(Arrays.asList(
MONGO_METHOD_LIST_DATABASES,
MONGO_METHOD_LIST_COLLECTIONS,
MONGO_METHOD_FIND_DOCUMENTS,
MONGO_METHOD_FIND_DOCUMENTS_EXTENDED_JSON,
MONGO_METHOD_COUNT_DOCUMENTS,
MONGO_METHOD_SERVER_VERSION,
MONGO_METHOD_CREATE_INDEX,
MONGO_METHOD_DROP_INDEXES,
@@ -149,6 +173,12 @@ public final class AgentProtocol {
return new HandshakeResult(PROTOCOL_VERSION, PROTOCOL_VERSION, CAPABILITIES);
}
public static HandshakeResult multiSessionHandshakeResult() {
List<String> capabilities = new java.util.ArrayList<>(CAPABILITIES);
capabilities.add(CAPABILITY_MULTI_SESSION);
return new HandshakeResult(MULTI_SESSION_PROTOCOL_VERSION, MULTI_SESSION_PROTOCOL_VERSION, capabilities);
}
public static final class HandshakeResult {
private final int protocolVersion;
private final int agentProtocolVersion;
@@ -41,52 +41,59 @@ public abstract class BaseDatabaseAgent implements DatabaseAgent {
foreignKeys = Collections.emptyList();
}
return DatabaseAgent.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys);
String tableComment = null;
try {
tableComment = getTableComment(schema, table);
} catch (RuntimeException e) {
// Table comment is optional; DDL generation should still succeed without it.
}
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, Collections.emptyList(), false, false, tableComment);
}
@Override
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
Connection conn = requireConnected();
return JdbcExecutor.INSTANCE.executePage(
return JdbcExecutor.current().executePage(
conn,
sql,
schema,
this::setSchemaSQL,
options,
JdbcExecutor.INSTANCE::defaultResultValue
JdbcExecutor.current()::defaultResultValue
);
}
@Override
public QueryPageResult fetchQueryPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchPage(sessionId, pageSize);
return JdbcExecutor.current().fetchPage(sessionId, pageSize);
}
@Override
public boolean closeQuerySession(String sessionId) {
return JdbcExecutor.INSTANCE.closeQuerySession(sessionId);
return JdbcExecutor.current().closeQuerySession(sessionId);
}
@Override
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
return JdbcExecutor.INSTANCE.startTableRead(
return JdbcExecutor.current().startTableRead(
requireConnected(),
sql,
schema,
this::setSchemaSQL,
options,
JdbcExecutor.INSTANCE::defaultResultValue
JdbcExecutor.current()::defaultResultValue
);
}
@Override
public QueryPageResult fetchTableReadPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchTableReadPage(sessionId, pageSize);
return JdbcExecutor.current().fetchTableReadPage(sessionId, pageSize);
}
@Override
public boolean closeTableReadSession(String sessionId) {
return JdbcExecutor.INSTANCE.closeTableReadSession(sessionId);
return JdbcExecutor.current().closeTableReadSession(sessionId);
}
@Override
@@ -144,7 +144,14 @@ public abstract class ConfiguredJdbcAgent extends AbstractJdbcAgent {
foreignKeys = Collections.emptyList();
}
return DatabaseAgent.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys);
String tableComment = null;
try {
tableComment = getTableComment(schema, table);
} catch (RuntimeException e) {
// Table comment is optional; DDL generation should still succeed without it.
}
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, Collections.emptyList(), false, false, tableComment);
}
@Override
@@ -12,6 +12,7 @@ public final class ConnectParams {
private String password;
private String url_params;
private String connection_string;
private boolean port_explicit;
private boolean mysql_compat_mode;
private String jdbc_driver_class;
private List<String> jdbc_driver_paths;
@@ -91,6 +92,10 @@ public final class ConnectParams {
return connection_string;
}
public boolean isPort_explicit() {
return port_explicit;
}
public void setHost(String host) {
this.host = host;
}
@@ -119,6 +124,10 @@ public final class ConnectParams {
this.connection_string = connection_string;
}
public void setPort_explicit(boolean port_explicit) {
this.port_explicit = port_explicit;
}
public boolean isMysql_compat_mode() {
return mysql_compat_mode;
}
@@ -203,6 +212,7 @@ public final class ConnectParams {
&& Objects.equals(password, that.password)
&& Objects.equals(url_params, that.url_params)
&& Objects.equals(connection_string, that.connection_string)
&& port_explicit == that.port_explicit
&& mysql_compat_mode == that.mysql_compat_mode
&& Objects.equals(jdbc_driver_class, that.jdbc_driver_class)
&& Objects.equals(jdbc_driver_paths, that.jdbc_driver_paths)
@@ -217,7 +227,7 @@ public final class ConnectParams {
@Override
public int hashCode() {
return Objects.hash(host, port, database, username, password, url_params, connection_string,
mysql_compat_mode, jdbc_driver_class, jdbc_driver_paths, ssl, ca_cert_path, client_cert_path, client_key_path, gbase_server, informix_server);
port_explicit, mysql_compat_mode, jdbc_driver_class, jdbc_driver_paths, ssl, ca_cert_path, client_cert_path, client_key_path, gbase_server, informix_server);
}
@Override
@@ -229,6 +239,7 @@ public final class ConnectParams {
+ ", password=" + password
+ ", url_params=" + url_params
+ ", connection_string=" + connection_string
+ ", port_explicit=" + port_explicit
+ ", mysql_compat_mode=" + mysql_compat_mode
+ ", jdbc_driver_class=" + jdbc_driver_class
+ ", jdbc_driver_paths=" + jdbc_driver_paths
@@ -13,6 +13,10 @@ public interface DatabaseAgent {
boolean testConnection(ConnectParams params);
default String getIdentifierQuote() {
return "";
}
List<DatabaseInfo> listDatabases();
List<String> listSchemas();
@@ -78,7 +82,51 @@ public interface DatabaseAgent {
foreignKeys = Collections.emptyList();
}
return buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys);
String tableComment = null;
try {
tableComment = getTableComment(schema, table);
} catch (RuntimeException e) {
// Table comment is optional; DDL generation should still succeed without it.
}
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, Collections.emptyList(), false, false, tableComment);
}
/**
* Returns the comment/description for a table, or null if not available.
* Default implementation looks up the comment from listTables results.
* Subclasses can override for more efficient queries.
*/
default String getTableComment(String schema, String table) {
try {
String caseInsensitiveComment = null;
int caseInsensitiveMatches = 0;
for (TableInfo info : listTables(schema)) {
if (!info.getName().equalsIgnoreCase(table)) {
continue;
}
if (info.getName().equals(table)) {
return nonBlankComment(info.getComment());
}
caseInsensitiveMatches++;
caseInsensitiveComment = nonBlankComment(info.getComment());
}
// Case-insensitive databases may normalize metadata names, but quoted
// mixed-case objects must never borrow a sibling table's comment.
if (caseInsensitiveMatches == 1) {
return caseInsensitiveComment;
}
} catch (RuntimeException e) {
// Ignore; table comment is optional.
}
return null;
}
static String nonBlankComment(String comment) {
if (comment != null && !comment.trim().isEmpty()) {
return comment;
}
return null;
}
List<IndexInfo> listIndexes(String schema, String table);
@@ -102,22 +150,22 @@ public interface DatabaseAgent {
if (conn == null) {
throw new IllegalStateException("Not connected");
}
return JdbcExecutor.INSTANCE.executePage(
return AgentExecutionContext.jdbcExecutor().executePage(
conn,
sql,
schema,
this::setSchemaSQL,
options,
JdbcExecutor.INSTANCE::defaultResultValue
AgentExecutionContext.jdbcExecutor()::defaultResultValue
);
}
default QueryPageResult fetchQueryPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchPage(sessionId, pageSize);
return AgentExecutionContext.jdbcExecutor().fetchPage(sessionId, pageSize);
}
default boolean closeQuerySession(String sessionId) {
return JdbcExecutor.INSTANCE.closeQuerySession(sessionId);
return AgentExecutionContext.jdbcExecutor().closeQuerySession(sessionId);
}
default QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
@@ -125,22 +173,22 @@ public interface DatabaseAgent {
if (conn == null) {
throw new IllegalStateException("Not connected");
}
return JdbcExecutor.INSTANCE.startTableRead(
return AgentExecutionContext.jdbcExecutor().startTableRead(
conn,
sql,
schema,
this::setSchemaSQL,
options,
JdbcExecutor.INSTANCE::defaultResultValue
AgentExecutionContext.jdbcExecutor()::defaultResultValue
);
}
default QueryPageResult fetchTableReadPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchTableReadPage(sessionId, pageSize);
return AgentExecutionContext.jdbcExecutor().fetchTableReadPage(sessionId, pageSize);
}
default boolean closeTableReadSession(String sessionId) {
return JdbcExecutor.INSTANCE.closeTableReadSession(sessionId);
return AgentExecutionContext.jdbcExecutor().closeTableReadSession(sessionId);
}
/**
@@ -16,7 +16,7 @@ public final class DdlBuilder {
List<IndexInfo> indexes,
List<ForeignKeyInfo> foreignKeys
) {
return buildTableDdl(schema, table, columns, indexes, foreignKeys, false, false);
return buildTableDdl(schema, table, columns, indexes, foreignKeys, Collections.emptyList(), false, false, null);
}
public static String buildTableDdl(
@@ -27,7 +27,7 @@ public final class DdlBuilder {
List<ForeignKeyInfo> foreignKeys,
boolean useBacktick
) {
return buildTableDdl(schema, table, columns, indexes, foreignKeys, useBacktick, false);
return buildTableDdl(schema, table, columns, indexes, foreignKeys, Collections.emptyList(), useBacktick, false, null);
}
public static String buildTableDdl(
@@ -47,7 +47,8 @@ public final class DdlBuilder {
foreignKeys,
Collections.emptyList(),
useBacktick,
includeColumnComments
includeColumnComments,
null
);
}
@@ -60,6 +61,20 @@ public final class DdlBuilder {
List<CheckConstraintInfo> checkConstraints,
boolean useBacktick,
boolean includeColumnComments
) {
return buildTableDdl(schema, table, columns, indexes, foreignKeys, checkConstraints, useBacktick, includeColumnComments, null);
}
public static String buildTableDdl(
String schema,
String table,
List<ColumnInfo> columns,
List<IndexInfo> indexes,
List<ForeignKeyInfo> foreignKeys,
List<CheckConstraintInfo> checkConstraints,
boolean useBacktick,
boolean includeColumnComments,
String tableComment
) {
String tableRef = qualifiedName(schema, table, useBacktick);
List<String> columnLines = new ArrayList<>();
@@ -114,6 +129,14 @@ public final class DdlBuilder {
ddl.append(join(columnLines, ",\n"));
ddl.append("\n);\n");
if (notBlank(tableComment)) {
ddl.append("\nCOMMENT ON TABLE ");
ddl.append(tableRef);
ddl.append(" IS '");
ddl.append(sqlStringBody(tableComment));
ddl.append("';");
}
if (includeColumnComments) {
for (ColumnInfo column : columns) {
if (!notBlank(column.getComment())) {
@@ -24,8 +24,13 @@ public final class JdbcExecutor {
private final ConcurrentHashMap<String, QuerySession> sessions = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, QuerySession> tableReadSessions = new ConcurrentHashMap<>();
private final java.util.Set<Statement> activeStatements = ConcurrentHashMap.newKeySet();
private JdbcExecutor() {
public JdbcExecutor() {
}
public static JdbcExecutor current() {
return AgentExecutionContext.jdbcExecutor();
}
public QueryResult execute(Connection conn, String sql, String schema, Function<String, String> setSchemaSql) {
@@ -72,6 +77,8 @@ public final class JdbcExecutor {
applySchema(conn, schema, setSchemaSql);
try (Statement stmt = conn.createStatement()) {
activeStatements.add(stmt);
try {
int effectiveMaxRows = Math.max(maxRows, 1);
stmt.setMaxRows(effectiveMaxRows + 1);
applyQueryTimeout(stmt, timeoutSecs);
@@ -96,6 +103,9 @@ public final class JdbcExecutor {
elapsed,
false
);
} finally {
activeStatements.remove(stmt);
}
}
});
}
@@ -199,6 +209,7 @@ public final class JdbcExecutor {
applySchema(conn, schema, setSchemaSql);
Statement stmt = conn.createStatement();
activeStatements.add(stmt);
try {
applyQueryTimeout(stmt, options.getTimeoutSecs());
if (options.getFetchSize() != null && options.getFetchSize() > 0) {
@@ -210,6 +221,7 @@ public final class JdbcExecutor {
long elapsed = System.currentTimeMillis() - start;
if (!hasResultSet) {
int updateCount = stmt.getUpdateCount();
activeStatements.remove(stmt);
stmt.close();
return new QueryPageResult(
Collections.emptyList(),
@@ -249,6 +261,7 @@ public final class JdbcExecutor {
targetSessions.put(sessionId, session);
return readSessionPage(targetSessions, session, options.getPageSize(), elapsed);
} catch (Exception e) {
activeStatements.remove(stmt);
try {
stmt.close();
} catch (Exception ignored) {
@@ -284,6 +297,20 @@ public final class JdbcExecutor {
closeAllSessions(tableReadSessions);
}
public void cancelActiveStatements() {
for (Statement statement : activeStatements) {
try {
statement.cancel();
} catch (SQLException ignored) {
}
}
// Paged result sets can remain idle between fetch requests, so no
// Statement is executing when cancellation arrives. Close those
// session-owned cursors as part of the same cancellation boundary.
closeAllQuerySessions();
closeAllTableReadSessions();
}
public int expireIdleQuerySessions() {
return expireIdleQuerySessions(System.currentTimeMillis(), QUERY_SESSION_IDLE_TIMEOUT_MILLIS);
}
@@ -504,6 +531,7 @@ public final class JdbcExecutor {
return false;
}
synchronized (session) {
activeStatements.remove(session.statement);
try {
session.resultSet.close();
} catch (Exception ignored) {
@@ -23,6 +23,7 @@ public final class JsonRpcServer {
private static final long CONNECTION_VALIDATION_INTERVAL_MILLIS = 5_000L;
private final DatabaseAgent agent;
private final JdbcExecutor jdbcExecutor;
private final Gson gson = new GsonBuilder()
// JDBC DECIMAL/NUMERIC values can exceed JavaScript Number precision after JSON-RPC parsing.
.registerTypeAdapter(
@@ -39,6 +40,7 @@ public final class JsonRpcServer {
public JsonRpcServer(DatabaseAgent agent) {
this.agent = agent;
this.jdbcExecutor = new JdbcExecutor();
}
public void run() {
@@ -84,6 +86,14 @@ public final class JsonRpcServer {
}
}
Object dispatchForRuntime(String method, JsonObject params) throws Exception {
return AgentExecutionContext.withJdbcExecutor(jdbcExecutor, () -> dispatch(method, params));
}
void cancelActiveStatements() {
jdbcExecutor.cancelActiveStatements();
}
private Object dispatch(String method, JsonObject params) throws Exception {
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
return AgentProtocol.handshakeResult();
@@ -115,6 +125,9 @@ public final class JsonRpcServer {
return Collections.singletonMap("ok", true);
}
ensureLiveConnection(method);
if (AgentProtocol.METHOD_CONNECTION_INFO.equals(method)) {
return Collections.singletonMap("identifierQuote", agent.getIdentifierQuote());
}
if (AgentProtocol.METHOD_LIST_DATABASES.equals(method)) {
return agent.listDatabases();
}
@@ -243,18 +256,17 @@ public final class JsonRpcServer {
return agent.executeBatch(statements, stringOrNull(params, "schema"));
}
if (AgentProtocol.METHOD_DISCONNECT.equals(method)) {
JdbcExecutor.INSTANCE.closeAllQuerySessions();
JdbcExecutor.INSTANCE.closeAllTableReadSessions();
jdbcExecutor.closeAllQuerySessions();
jdbcExecutor.closeAllTableReadSessions();
agent.disconnect();
lastConnectParams = null;
return Collections.singletonMap("ok", true);
}
if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
JdbcExecutor.INSTANCE.closeAllQuerySessions();
JdbcExecutor.INSTANCE.closeAllTableReadSessions();
jdbcExecutor.closeAllQuerySessions();
jdbcExecutor.closeAllTableReadSessions();
agent.disconnect();
lastConnectParams = null;
System.exit(0);
return Collections.singletonMap("ok", true);
}
throw new IllegalArgumentException("Unknown method: " + method);
@@ -300,8 +312,8 @@ public final class JsonRpcServer {
return;
}
JdbcExecutor.INSTANCE.closeAllQuerySessions();
JdbcExecutor.INSTANCE.closeAllTableReadSessions();
jdbcExecutor.closeAllQuerySessions();
jdbcExecutor.closeAllTableReadSessions();
try {
agent.disconnect();
} catch (Exception ignored) {
@@ -0,0 +1,180 @@
package com.dbx.agent;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
public final class MultiSessionJsonRpcServer {
private static final String LEGACY_SESSION_ID = "__legacy__";
private static final int MAX_SESSIONS = 256;
private final Supplier<? extends DatabaseAgent> agentFactory;
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
private final ExecutorService requests = Executors.newCachedThreadPool();
private final Gson gson = new Gson();
private final Object outputLock = new Object();
public MultiSessionJsonRpcServer(Supplier<? extends DatabaseAgent> agentFactory) {
this.agentFactory = agentFactory;
}
public void run() {
synchronized (outputLock) {
System.out.println("{\"ready\":true}");
System.out.flush();
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) {
String line;
while ((line = reader.readLine()) != null) {
JsonObject request = JsonParser.parseString(line).getAsJsonObject();
String method = request.get("method").getAsString();
if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
writeResponse(handleRequest(request));
closeAllSessions();
requests.shutdown();
return;
}
requests.submit(() -> writeResponse(handleRequest(request)));
}
} catch (Exception e) {
closeAllSessions();
throw new RuntimeException(e);
}
}
String handleRequest(String line) {
return gson.toJson(handleRequest(JsonParser.parseString(line).getAsJsonObject()));
}
private JsonObject handleRequest(JsonObject request) {
JsonElement id = request.get("id");
String method = request.get("method").getAsString();
JsonObject params = request.has("params") && request.get("params").isJsonObject()
? request.getAsJsonObject("params")
: new JsonObject();
JsonObject response = new JsonObject();
response.addProperty("jsonrpc", "2.0");
response.add("id", id);
try {
Object result;
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
result = AgentProtocol.multiSessionHandshakeResult();
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
result = openSession(requiredSessionId(params), params);
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
result = closeSession(requiredSessionId(params));
} else if (AgentProtocol.METHOD_VALIDATE_SESSION.equals(method)) {
result = session(requiredSessionId(params)).handle("validate_connection", params);
} else if (AgentProtocol.METHOD_CANCEL_SESSION.equals(method)) {
session(requiredSessionId(params)).cancel();
result = Collections.singletonMap("ok", true);
} else if (AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) {
result = new JsonRpcServer(agentFactory.get()).dispatchForRuntime(method, params);
} else if (AgentProtocol.METHOD_CONNECT.equals(method)) {
closeSession(LEGACY_SESSION_ID);
result = openSession(LEGACY_SESSION_ID, params);
} else if (AgentProtocol.METHOD_DISCONNECT.equals(method)) {
result = closeSession(LEGACY_SESSION_ID);
} else if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
result = Collections.singletonMap("ok", true);
} else {
String sessionId = params.has("agentSessionId") ? params.get("agentSessionId").getAsString() : LEGACY_SESSION_ID;
result = session(sessionId).handle(method, params);
}
response.add("result", gson.toJsonTree(result));
} catch (Throwable error) {
JsonObject rpcError = new JsonObject();
rpcError.addProperty("code", -1);
rpcError.addProperty("message", error.getMessage() == null ? error.toString() : error.getMessage());
response.add("error", rpcError);
}
return response;
}
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);
}
Session session = new Session(new JsonRpcServer(agentFactory.get()));
Session existing = sessions.putIfAbsent(sessionId, session);
if (existing != null) {
throw new IllegalStateException("Agent session already exists: " + sessionId);
}
try {
return session.handle(AgentProtocol.METHOD_CONNECT, params);
} catch (Exception error) {
sessions.remove(sessionId, session);
session.close();
throw error;
}
}
private Object closeSession(String sessionId) {
Session session = sessions.remove(sessionId);
if (session != null) {
session.close();
}
return Collections.singletonMap("ok", true);
}
private Session session(String sessionId) {
Session session = sessions.get(sessionId);
if (session == null) {
throw new IllegalStateException("Agent session not found: " + sessionId);
}
return session;
}
private void closeAllSessions() {
for (String sessionId : sessions.keySet()) {
closeSession(sessionId);
}
}
private static String requiredSessionId(JsonObject params) {
if (!params.has("agentSessionId") || params.get("agentSessionId").getAsString().trim().isEmpty()) {
throw new IllegalArgumentException("agentSessionId is required");
}
return params.get("agentSessionId").getAsString();
}
private void writeResponse(JsonObject response) {
synchronized (outputLock) {
System.out.println(gson.toJson(response));
System.out.flush();
}
}
private static final class Session {
private final JsonRpcServer server;
private Session(JsonRpcServer server) {
this.server = server;
}
private synchronized Object handle(String method, JsonObject params) throws Exception {
return server.dispatchForRuntime(method, params);
}
private synchronized void close() {
try {
server.dispatchForRuntime(AgentProtocol.METHOD_DISCONNECT, new JsonObject());
} catch (Exception ignored) {
}
}
private void cancel() {
server.cancelActiveStatements();
}
}
}
@@ -28,9 +28,14 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
return mysqlCompatMode;
}
@Override
public String getIdentifierQuote() {
return mysqlCompatMode ? "`" : super.getIdentifierQuote();
}
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
requireConnected(),
sql,
schema,
@@ -44,7 +49,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
@Override
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
return JdbcExecutor.INSTANCE.executePage(
return JdbcExecutor.current().executePage(
requireConnected(),
sql,
schema,
@@ -56,7 +61,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
@Override
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
return JdbcExecutor.INSTANCE.startTableRead(
return JdbcExecutor.current().startTableRead(
requireConnected(),
sql,
schema,
@@ -254,6 +259,13 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
checkConstraints = Collections.emptyList();
}
String tableComment = null;
try {
tableComment = getTableComment(schema, table);
} catch (RuntimeException e) {
// Table comment is optional; DDL generation should still succeed without it.
}
return DdlBuilder.buildTableDdl(
schema,
table,
@@ -262,10 +274,34 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
foreignKeys,
checkConstraints,
mysqlCompatMode,
true
true,
tableComment
);
}
@Override
public String getTableComment(String schema, String table) {
return unchecked(() -> {
try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement(
"SELECT obj_description(c.oid) AS table_comment " +
"FROM pg_catalog.pg_class c " +
"JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " +
"WHERE n.nspname = ? AND c.relname = ? AND c.relkind IN ('r','m','f','p') " +
"LIMIT 1"
)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
String comment = rs.getString("table_comment");
return (comment != null && !comment.trim().isEmpty()) ? comment : null;
}
}
}
return null;
});
}
protected List<CheckConstraintInfo> listCheckConstraints(String schema, String table) {
return unchecked(() -> {
List<CheckConstraintInfo> result = new ArrayList<>();
@@ -39,6 +39,7 @@
"connect",
"test_connection",
"validate_connection",
"connection_info",
"list_databases",
"list_schemas",
"list_tables",
@@ -69,6 +70,7 @@
"list_collections",
"find_documents",
"find_documents_extended_json",
"count_documents",
"server_version",
"create_index",
"drop_indexes",
@@ -0,0 +1,99 @@
{
"protocolVersion": 2,
"handshakeMethod": "handshake",
"handshakeResponseFields": [
"protocolVersion",
"agentProtocolVersion",
"capabilities"
],
"allCapabilities": [
"connect",
"test_connection",
"metadata",
"query",
"paged_query",
"transaction",
"ddl",
"kv",
"multi_session"
],
"capabilities": [
"connect",
"test_connection",
"metadata",
"query",
"paged_query",
"transaction",
"ddl",
"multi_session"
],
"defaultSqlCapabilities": [
"connect",
"test_connection",
"metadata",
"query",
"paged_query",
"transaction",
"ddl",
"multi_session"
],
"commonMethods": [
"handshake",
"connect",
"open_session",
"close_session",
"validate_session",
"cancel_session",
"test_connection",
"validate_connection",
"connection_info",
"list_databases",
"list_schemas",
"list_tables",
"list_objects",
"list_data_types",
"completion_assistant_search_v1",
"get_object_source",
"get_table_ddl",
"get_columns",
"list_indexes",
"list_foreign_keys",
"list_triggers",
"execute_query",
"execute_query_page",
"fetch_query_page",
"close_query_session",
"start_table_read",
"fetch_table_read_page",
"close_table_read_session",
"get_explain_info",
"execute_batch",
"execute_transaction",
"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"
],
"kvMethods": [
"kv_list_prefix",
"kv_get",
"kv_put",
"kv_delete"
],
"sessionField": "agentSessionId",
"cursorSessionField": "sessionId"
}
@@ -18,6 +18,9 @@ import java.util.Arrays;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -37,7 +40,7 @@ class CommonJavaCompatibilityTest {
@Test
void agentProtocolMatchesContractResource() {
JsonObject contract = protocolContract();
JsonObject contract = protocolContract("/agent-protocol-v1.json");
assertEquals(AgentProtocol.PROTOCOL_VERSION, contract.get("protocolVersion").getAsInt());
assertEquals(AgentProtocol.METHOD_HANDSHAKE, contract.get("handshakeMethod").getAsString());
@@ -53,6 +56,14 @@ class CommonJavaCompatibilityTest {
assertEquals(AgentProtocol.KV_METHODS, strings(contract.getAsJsonArray("kvMethods")));
}
@Test
void multiSessionAgentProtocolMatchesV2ContractResource() {
JsonObject contract = protocolContract("/agent-protocol-v2.json");
assertEquals(AgentProtocol.MULTI_SESSION_PROTOCOL_VERSION, contract.get("protocolVersion").getAsInt());
assertEquals(AgentProtocol.MULTI_SESSION_METHODS, strings(contract.getAsJsonArray("commonMethods")));
}
@Test
void jsonRpcServerExposesProtocolHandshake() {
JsonRpcServer server = new JsonRpcServer(new MinimalAgent());
@@ -72,6 +83,99 @@ class CommonJavaCompatibilityTest {
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), "metadata"));
}
@Test
void multiSessionServerCreatesAndClosesIndependentAgents() {
java.util.List<TrackingAgent> created = new java.util.ArrayList<>();
MultiSessionJsonRpcServer server = new MultiSessionJsonRpcServer(() -> {
TrackingAgent agent = new TrackingAgent();
created.add(agent);
return agent;
});
JsonObject handshake = JsonParser.parseString(server.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"handshake\",\"params\":{}}"
)).getAsJsonObject().getAsJsonObject("result");
assertEquals(2, handshake.get("protocolVersion").getAsInt());
assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "multi_session"));
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\"}}");
assertEquals(2, created.size());
assertEquals(1, created.get(0).connectCount);
assertEquals(1, created.get(1).connectCount);
server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"close_session\",\"params\":{\"agentSessionId\":\"a\"}}");
assertEquals(1, created.get(0).disconnectCount);
assertEquals(0, created.get(1).disconnectCount);
}
@Test
void multiSessionCancellationOnlyCancelsTargetSession() throws Exception {
List<CancelTrackingAgent> created = Collections.synchronizedList(new ArrayList<>());
MultiSessionJsonRpcServer server = new MultiSessionJsonRpcServer(() -> {
CancelTrackingAgent agent = new CancelTrackingAgent();
created.add(agent);
return agent;
});
server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"a\"}}");
server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"b\"}}");
Thread queryA = new Thread(() -> server.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"execute_query\",\"params\":{\"agentSessionId\":\"a\",\"sql\":\"SELECT 1\"}}"
));
Thread queryB = new Thread(() -> server.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"execute_query\",\"params\":{\"agentSessionId\":\"b\",\"sql\":\"SELECT 1\"}}"
));
queryA.start();
queryB.start();
assertTrue(created.get(0).statementStarted.await(5, TimeUnit.SECONDS));
assertTrue(created.get(1).statementStarted.await(5, TimeUnit.SECONDS));
server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"cancel_session\",\"params\":{\"agentSessionId\":\"a\"}}");
assertTrue(created.get(0).canceled.get());
assertFalse(created.get(1).canceled.get());
created.get(1).release.countDown();
queryA.join(5_000L);
queryB.join(5_000L);
assertFalse(queryA.isAlive());
assertFalse(queryB.isAlive());
}
@Test
void cancelActiveStatementsClosesOnlyThatExecutorPagedSessions() {
JdbcExecutor target = new JdbcExecutor();
JdbcExecutor other = new JdbcExecutor();
List<String> targetCalls = new ArrayList<>();
List<String> otherCalls = new ArrayList<>();
QueryPageResult targetPage = target.executePage(
pagedConnection(targetCalls),
"SELECT 1",
null,
ignored -> null,
new QueryPageOptions(1, null, 10, 0),
target::defaultResultValue
);
QueryPageResult otherPage = other.executePage(
pagedConnection(otherCalls),
"SELECT 1",
null,
ignored -> null,
new QueryPageOptions(1, null, 10, 0),
other::defaultResultValue
);
target.cancelActiveStatements();
assertThrows(IllegalArgumentException.class, () -> target.fetchPage(targetPage.getSession_id(), 1));
assertNotNull(other.fetchPage(otherPage.getSession_id(), 1));
assertTrue(targetCalls.contains("resultSet.close"));
assertTrue(targetCalls.contains("statement.close"));
assertFalse(otherCalls.contains("resultSet.close"));
}
@Test
void jsonRpcServerSerializesArbitraryPrecisionNumbersAsStrings() {
JsonRpcServer server = new JsonRpcServer(new PreciseNumberAgent());
@@ -336,6 +440,30 @@ class CommonJavaCompatibilityTest {
assertEquals("order_view", objects.get(0).getName());
}
@Test
void tableCommentPrefersExactNameAndPreservesWhitespace() {
DatabaseAgent agent = new TableCommentAgent(Arrays.asList(
new TableInfo("users", "TABLE", "lowercase comment"),
new TableInfo("Users", "TABLE", " exact comment ")
));
assertEquals(" exact comment ", agent.getTableComment("public", "Users"));
}
@Test
void tableCommentUsesOnlyUniqueCaseInsensitiveFallback() {
DatabaseAgent unique = new TableCommentAgent(Collections.singletonList(
new TableInfo("USERS", "TABLE", " normalized comment ")
));
DatabaseAgent ambiguous = new TableCommentAgent(Arrays.asList(
new TableInfo("Users", "TABLE", "first"),
new TableInfo("USERS", "TABLE", "second")
));
assertEquals(" normalized comment ", unique.getTableComment("public", "users"));
assertEquals(null, ambiguous.getTableComment("public", "users"));
}
@Test
void executesTransactionsOneByOneWhenJdbcDriverDoesNotSupportTransactions() {
List<String> calls = new ArrayList<>();
@@ -464,6 +592,82 @@ class CommonJavaCompatibilityTest {
}
}
private static final class TrackingAgent extends MinimalAgent {
private int connectCount;
private int disconnectCount;
@Override
public void connect(ConnectParams params) {
connectCount += 1;
}
@Override
public void disconnect() {
disconnectCount += 1;
}
}
private static final class CancelTrackingAgent extends MinimalAgent {
private final CountDownLatch statementStarted = new CountDownLatch(1);
private final CountDownLatch release = new CountDownLatch(1);
private final AtomicBoolean canceled = new AtomicBoolean();
private final Connection connection = proxy(Connection.class, (method, args) -> {
if ("createStatement".equals(method.getName())) {
return proxy(java.sql.Statement.class, (statementMethod, statementArgs) -> {
if ("execute".equals(statementMethod.getName())) {
statementStarted.countDown();
try {
release.await(5, TimeUnit.SECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new RuntimeException(interrupted);
}
return false;
}
if ("cancel".equals(statementMethod.getName())) {
canceled.set(true);
release.countDown();
return null;
}
return defaultValue(statementMethod.getReturnType());
});
}
return defaultValue(method.getReturnType());
});
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
return JdbcExecutor.current().execute(
connection,
sql,
schema,
ignored -> null,
options.getMaxRows(),
options.getFetchSize(),
options.getTimeoutSecs(),
JdbcExecutor.current()::defaultResultValue
);
}
@Override
public Connection getConnection() {
return connection;
}
}
private static final class TableCommentAgent extends MinimalAgent {
private final List<TableInfo> tables;
private TableCommentAgent(List<TableInfo> tables) {
this.tables = tables;
}
@Override
public List<TableInfo> listTables(String schema) {
return tables;
}
}
private static final class TransactionAgent extends MinimalAgent {
private final Connection connection;
@@ -710,6 +914,41 @@ class CommonJavaCompatibilityTest {
});
}
private static Connection pagedConnection(List<String> calls) {
java.sql.ResultSetMetaData metadata = proxy(java.sql.ResultSetMetaData.class, (method, args) -> {
if ("getColumnCount".equals(method.getName())) return 1;
if ("getColumnLabel".equals(method.getName())) return "value";
if ("getColumnType".equals(method.getName())) return java.sql.Types.INTEGER;
if ("getColumnTypeName".equals(method.getName())) return "integer";
return defaultValue(method.getReturnType());
});
int[] row = {0};
java.sql.ResultSet resultSet = proxy(java.sql.ResultSet.class, (method, args) -> {
if ("getMetaData".equals(method.getName())) return metadata;
if ("next".equals(method.getName())) return ++row[0] <= 3;
if ("getInt".equals(method.getName())) return row[0];
if ("wasNull".equals(method.getName())) return false;
if ("close".equals(method.getName())) {
calls.add("resultSet.close");
return null;
}
return defaultValue(method.getReturnType());
});
java.sql.Statement statement = proxy(java.sql.Statement.class, (method, args) -> {
if ("execute".equals(method.getName())) return true;
if ("getResultSet".equals(method.getName())) return resultSet;
if ("close".equals(method.getName())) {
calls.add("statement.close");
return null;
}
return defaultValue(method.getReturnType());
});
return proxy(Connection.class, (method, args) -> {
if ("createStatement".equals(method.getName())) return statement;
return defaultValue(method.getReturnType());
});
}
private static <T> T proxy(Class<T> type, MethodHandler handler) {
InvocationHandler invocationHandler = new InvocationHandler() {
@Override
@@ -742,10 +981,10 @@ class CommonJavaCompatibilityTest {
return false;
}
private static JsonObject protocolContract() {
InputStream stream = CommonJavaCompatibilityTest.class.getResourceAsStream("/agent-protocol-v1.json");
private static JsonObject protocolContract(String resourcePath) {
InputStream stream = CommonJavaCompatibilityTest.class.getResourceAsStream(resourcePath);
if (stream == null) {
throw new AssertionError("agent-protocol-v1.json resource missing");
throw new AssertionError(resourcePath + " resource missing");
}
return JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8)).getAsJsonObject();
}
+34
View File
@@ -0,0 +1,34 @@
# 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.
## Session lifecycle
- `open_session` creates one logical database session. Parameters contain the normal connection fields plus `agentSessionId`.
- Every connection-scoped RPC contains `agentSessionId`.
- `validate_session` validates and, where supported, reconnects only that session.
- `cancel_session` cancels active statements and cursor fetches for only that session; other sessions in the runtime continue normally.
- `close_session` closes the session connection, query cursors, and table-read cursors without affecting other sessions.
- `shutdown` closes all sessions and terminates the runtime.
`agentSessionId` identifies a logical database connection. Existing `sessionId` fields remain pagination cursor identifiers and must not be used as logical connection identifiers.
## Concurrency
Requests for different sessions may execute concurrently. Requests for the same session are serialized because connection state, transactions, schema changes, and driver connections are not generally safe for concurrent use. JSON-RPC responses may be returned out of order and are correlated by request `id`.
## Runtime compatibility
Runtime reuse keys include the Agent driver key, executable or JAR path, launch arguments, working directory, JRE selection, JVM options, classpath-affecting options, and native executable version boundary. Host, account, schema, and credentials belong to sessions and are not part of the runtime key.
Etcd and ZooKeeper retain the legacy path because they use the key-value Agent protocol rather than the SQL session contract. Older Agent binaries and JARs also remain on the legacy path.
## Resource limits and recovery
A runtime accepts at most 256 logical sessions. Closing the final session starts a 30-second grace period before the process exits, preventing rapid tab open/close cycles from repeatedly starting a runtime. Process EOF fails all pending requests; the failed runtime is removed from reuse and recreated on demand. Connection validation and reconnect operate on a single logical session.
## Driver author guidance
Use `MultiSessionJsonRpcServer(YourAgent::new)` for Java SQL Agents so each logical session receives a new `DatabaseAgent` and JDBC connection. Do not store connection, statement, cursor, transaction, or schema state in static mutable fields. Use the session execution context for paged query resources. Native Agents must provide equivalent per-session state and synchronized stdout writes.
The Xugu native Agent keeps one database connection per logical session plus one shared control connection per database endpoint. Because `go-xugu-driver` does not interrupt network reads through `context.Context`, cancellation records the server-side session ID and calls `DBMS_DBA.KILL_SESSION_TRANS` through the shared control connection.
+1
View File
@@ -1,5 +1,6 @@
dependencies {
implementation 'io.github.spannm:ucanaccess:5.1.5'
implementation 'io.github.spannm:jackcess-encrypt:5.1.0'
}
tasks.named('shadowJar') {
@@ -8,7 +8,7 @@ import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.QueryResult;
import com.dbx.agent.TableInfo;
@@ -30,6 +30,7 @@ import java.util.Map;
import java.util.Set;
public final class AccessAgent extends BaseDatabaseAgent {
private static final String ENCRYPTED_ACCESS_OPENER = EncryptedAccessOpener.class.getName();
private Connection connection;
private String databaseFile = "";
@@ -216,7 +217,7 @@ public final class AccessAgent extends BaseDatabaseAgent {
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
requireConnected(),
sql,
schema,
@@ -224,7 +225,7 @@ public final class AccessAgent extends BaseDatabaseAgent {
options.getMaxRows(),
options.getFetchSize(),
options.getTimeoutSecs(),
JdbcExecutor.INSTANCE::defaultResultValue
JdbcExecutor.current()::defaultResultValue
);
}
@@ -285,6 +286,11 @@ public final class AccessAgent extends BaseDatabaseAgent {
}
rawUrl = appendUCanAccessParams(rawUrl, params.getUrl_params());
// The codec opener is safe for regular files and enables encrypted Access files when a password is supplied.
if (!containsIgnoreCase(rawUrl, "jackcessOpener=")) {
rawUrl += (rawUrl.endsWith(";") ? "" : ";") + "jackcessOpener=" + ENCRYPTED_ACCESS_OPENER;
}
if (!createIfMissing || Files.exists(Path.of(databasePath(params)))) {
return rawUrl;
}
@@ -361,6 +367,6 @@ public final class AccessAgent extends BaseDatabaseAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new AccessAgent()).run();
new MultiSessionJsonRpcServer(AccessAgent::new).run();
}
}
@@ -0,0 +1,20 @@
package com.dbx.agent.access;
import io.github.spannm.jackcess.Database;
import io.github.spannm.jackcess.DatabaseBuilder;
import io.github.spannm.jackcess.encrypt.CryptCodecProvider;
import java.io.File;
import java.io.IOException;
import net.ucanaccess.jdbc.IJackcessOpenerInterface;
public final class EncryptedAccessOpener implements IJackcessOpenerInterface {
@Override
public Database open(File file, String password) throws IOException {
DatabaseBuilder builder = new DatabaseBuilder(file);
// UCanAccess flushes changes at transaction boundaries, so Jackcess auto-sync is unnecessary overhead.
builder.withAutoSync(false);
builder.withCodecProvider(new CryptCodecProvider(password));
builder.withReadOnly(false);
return builder.open();
}
}
@@ -6,6 +6,7 @@ import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.QueryPageOptions;
import com.dbx.agent.test.JdbcConnectedAgentTest;
import java.nio.file.Path;
import java.nio.file.Files;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -66,7 +67,7 @@ class AccessAgentTest extends JdbcConnectedAgentTest {
ConnectParams params = new ConnectParams("", 0, file.toString(), "", "", "memory=false;ignoreCase=true", "", false);
Assertions.assertEquals(
"jdbc:ucanaccess://" + file + ";memory=false;ignoreCase=true;newDatabaseVersion=V2010",
"jdbc:ucanaccess://" + file + ";memory=false;ignoreCase=true;jackcessOpener=com.dbx.agent.access.EncryptedAccessOpener;newDatabaseVersion=V2010",
AccessAgent.jdbcUrl(params, true)
);
}
@@ -77,11 +78,52 @@ class AccessAgentTest extends JdbcConnectedAgentTest {
ConnectParams params = new ConnectParams("", 0, "jdbc:ucanaccess://" + file + ";", "", "", ";memory=false", "", false);
Assertions.assertEquals(
"jdbc:ucanaccess://" + file + ";memory=false;newDatabaseVersion=V2010",
"jdbc:ucanaccess://" + file + ";memory=false;jackcessOpener=com.dbx.agent.access.EncryptedAccessOpener;newDatabaseVersion=V2010",
AccessAgent.jdbcUrl(params, true)
);
}
@Test
void preservesCustomJackcessOpener() {
Path file = tempDir.resolve("custom-opener.accdb");
ConnectParams params = new ConnectParams(
"", 0, file.toString(), "", "", "jackcessOpener=example.CustomOpener", "", false
);
Assertions.assertEquals(
"jdbc:ucanaccess://" + file + ";jackcessOpener=example.CustomOpener;newDatabaseVersion=V2010",
AccessAgent.jdbcUrl(params, true)
);
}
@Test
void connectsToEncryptedAccessFile() throws Exception {
Path source = Path.of(getClass().getResource("/db2007-enc.accdb").toURI());
Path file = tempDir.resolve("encrypted.accdb");
Files.copy(source, file);
AccessAgent agent = new AccessAgent();
try {
agent.connect(new ConnectParams("", 0, file.toString(), "", "Test123", "", "", false));
Assertions.assertFalse(agent.listTables("").isEmpty());
} finally {
agent.disconnect();
}
}
@Test
void rejectsIncorrectEncryptedAccessPassword() throws Exception {
Path source = Path.of(getClass().getResource("/db2007-enc.accdb").toURI());
Path file = tempDir.resolve("encrypted-wrong-password.accdb");
Files.copy(source, file);
AccessAgent agent = new AccessAgent();
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () ->
agent.connect(new ConnectParams("", 0, file.toString(), "", "wrong", "", "", false))
);
Assertions.assertTrue(error.getMessage().contains("Incorrect password provided"));
}
@Test
void supportsLimitOffsetSqlAndCursorResultPages() {
withAgent("pages", agent -> {
@@ -7,7 +7,7 @@ import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.MetadataSqlSupport;
import com.dbx.agent.TableInfo;
@@ -229,6 +229,6 @@ public final class BigQueryAgent extends AbstractJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new BigQueryAgent()).run();
new MultiSessionJsonRpcServer(BigQueryAgent::new).run();
}
}
@@ -7,7 +7,7 @@ import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
import java.sql.PreparedStatement;
@@ -189,6 +189,6 @@ public final class CassandraAgent extends AbstractJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new CassandraAgent()).run();
new MultiSessionJsonRpcServer(CassandraAgent::new).run();
}
}
@@ -9,7 +9,7 @@ import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.ObjectSource;
@@ -598,7 +598,7 @@ public final class DamengAgent extends BaseDatabaseAgent {
default -> throw new IllegalArgumentException("Unsupported object type: " + objectType);
};
String source;
String sql = "SELECT DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, dbmsType);
stmt.setString(2, name);
@@ -614,18 +614,22 @@ public final class DamengAgent extends BaseDatabaseAgent {
@Override
public String getTableDdl(String schema, String table) {
return unchecked(() -> {
String sql = "SELECT DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
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()) {
String ddl = appendTableAndColumnComments(coalesce(readTextColumn(rs, 1)), schema, table);
return appendIndependentIndexDdl(ddl, schema, table);
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);
});
}
@@ -635,7 +639,7 @@ public final class DamengAgent extends BaseDatabaseAgent {
return unchecked(() -> {
Set<String> pkColumns = new java.util.HashSet<>();
String pkSql = """
SELECT cols.COLUMN_NAME FROM ALL_CONS_COLUMNS cols
SELECT /*+ PARALLEL(1) */ cols.COLUMN_NAME FROM ALL_CONS_COLUMNS cols
JOIN ALL_CONSTRAINTS cons ON cols.CONSTRAINT_NAME = cons.CONSTRAINT_NAME AND cols.OWNER = cons.OWNER
WHERE cons.CONSTRAINT_TYPE = 'P' AND cons.OWNER = ? AND cons.TABLE_NAME = ?
""".stripIndent().trim();
@@ -654,7 +658,7 @@ public final class DamengAgent extends BaseDatabaseAgent {
// DATA_DEFAULT is a LONG column — it must be selected first and read first
// in JDBC, otherwise the data is truncated.
String colSql = """
SELECT c.DATA_DEFAULT,
SELECT /*+ PARALLEL(1) */ c.DATA_DEFAULT,
c.COLUMN_NAME,
c.DATA_TYPE,
c.NULLABLE,
@@ -662,6 +666,7 @@ public final class DamengAgent extends BaseDatabaseAgent {
c.DATA_SCALE,
c.DATA_LENGTH,
c.CHAR_LENGTH,
c.CHAR_USED,
cc.COMMENTS
FROM ALL_TAB_COLUMNS c
LEFT JOIN ALL_COL_COMMENTS cc
@@ -684,7 +689,8 @@ public final class DamengAgent extends BaseDatabaseAgent {
Integer numScale = intObject(rs, "DATA_SCALE");
Integer dataLen = intObject(rs, "DATA_LENGTH");
Integer charLen = intObject(rs, "CHAR_LENGTH");
String dataType = formatDataType(baseType, numPrec, numScale, dataLen, charLen);
String charUsed = rs.getString("CHAR_USED");
String dataType = formatDataType(baseType, numPrec, numScale, dataLen, charLen, charUsed);
result.add(new ColumnInfo(
name,
@@ -709,7 +715,7 @@ public final class DamengAgent extends BaseDatabaseAgent {
private Set<String> identityColumns(String schema, String table) {
Set<String> result = new java.util.HashSet<>();
String sql = """
SELECT c.NAME
SELECT /*+ PARALLEL(1) */ c.NAME
FROM SYS.SYSCOLUMNS c
JOIN SYS.SYSOBJECTS t ON c.ID = t.ID
JOIN SYS.SYSOBJECTS s ON t.SCHID = s.ID
@@ -737,7 +743,7 @@ public final class DamengAgent extends BaseDatabaseAgent {
return unchecked(() -> {
List<IndexInfo> result = new ArrayList<>();
String sql = """
SELECT i.INDEX_NAME,
SELECT /*+ PARALLEL(1) */ i.INDEX_NAME,
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS,
i.UNIQUENESS,
CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK,
@@ -828,7 +834,7 @@ public final class DamengAgent extends BaseDatabaseAgent {
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
requireConnected(),
sql,
schema,
@@ -842,7 +848,7 @@ public final class DamengAgent extends BaseDatabaseAgent {
@Override
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
return JdbcExecutor.INSTANCE.executePage(
return JdbcExecutor.current().executePage(
requireConnected(),
sql,
schema,
@@ -854,7 +860,7 @@ public final class DamengAgent extends BaseDatabaseAgent {
@Override
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
return JdbcExecutor.INSTANCE.startTableRead(
return JdbcExecutor.current().startTableRead(
requireConnected(),
sql,
schema,
@@ -961,9 +967,20 @@ public final class DamengAgent extends BaseDatabaseAgent {
return "jdbc:dm://" + params.getHost() + ":" + params.getPort() + suffix;
}
private static String formatDataType(String base, Integer numPrec, Integer numScale, Integer dataLen, Integer charLen) {
private static String formatDataType(
String base,
Integer numPrec,
Integer numScale,
Integer dataLen,
Integer charLen,
String charUsed
) {
return switch (base.toUpperCase(Locale.ROOT)) {
case "VARCHAR2", "NVARCHAR2", "VARCHAR", "CHAR", "NCHAR" -> {
case "VARCHAR2", "VARCHAR", "CHAR" -> {
Integer length = characterLength(dataLen, charLen, charUsed);
yield length != null ? base + "(" + length + characterLengthUnit(charUsed) + ")" : base;
}
case "NVARCHAR2", "NCHAR" -> {
Integer length = charLen != null ? charLen : dataLen;
yield length != null ? base + "(" + length + ")" : base;
}
@@ -978,6 +995,25 @@ public final class DamengAgent extends BaseDatabaseAgent {
};
}
private static Integer characterLength(Integer dataLen, Integer charLen, String charUsed) {
String normalized = charUsed == null ? "" : charUsed.trim().toUpperCase(Locale.ROOT);
if ("B".equals(normalized) || "BYTE".equals(normalized)) {
return dataLen != null ? dataLen : charLen;
}
return charLen != null ? charLen : dataLen;
}
private static String characterLengthUnit(String charUsed) {
if (charUsed == null) {
return "";
}
return switch (charUsed.trim().toUpperCase(Locale.ROOT)) {
case "B", "BYTE" -> " BYTE";
case "C", "CHAR" -> " CHAR";
default -> "";
};
}
private static Integer intObject(ResultSet rs, String column) throws Exception {
Object value = rs.getObject(column);
return value == null ? null : ((Number) value).intValue();
@@ -1031,18 +1067,18 @@ public final class DamengAgent extends BaseDatabaseAgent {
Map<String, String> comments = new HashMap<>();
queryColumnComments(
comments,
"SELECT COLUMN_NAME, COMMENTS FROM USER_COL_COMMENTS WHERE TABLE_NAME = ?",
"SELECT /*+ PARALLEL(1) */ COLUMN_NAME, COMMENTS FROM USER_COL_COMMENTS WHERE TABLE_NAME = ?",
table
);
queryColumnComments(
comments,
"SELECT COLNAME, COMMENT$ FROM SYS.SYSCOLUMNCOMMENTS WHERE SCHNAME = ? AND TVNAME = ?",
"SELECT /*+ PARALLEL(1) */ COLNAME, COMMENT$ FROM SYS.SYSCOLUMNCOMMENTS WHERE SCHNAME = ? AND TVNAME = ?",
schema,
table
);
queryColumnComments(
comments,
"SELECT COLUMN_NAME, COMMENTS FROM ALL_COL_COMMENTS WHERE UPPER(OWNER) = UPPER(?) AND UPPER(TABLE_NAME) = UPPER(?)",
"SELECT /*+ PARALLEL(1) */ COLUMN_NAME, COMMENTS FROM ALL_COL_COMMENTS WHERE UPPER(OWNER) = UPPER(?) AND UPPER(TABLE_NAME) = UPPER(?)",
schema,
table
);
@@ -1111,7 +1147,7 @@ public final class DamengAgent extends BaseDatabaseAgent {
List<IndexInfo> result = new ArrayList<>();
// Primary-key and unique-constraint backing indexes are already represented in table DDL.
String sql = """
SELECT i.INDEX_NAME,
SELECT /*+ PARALLEL(1) */ i.INDEX_NAME,
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS,
i.UNIQUENESS,
i.INDEX_TYPE
@@ -1180,7 +1216,7 @@ public final class DamengAgent extends BaseDatabaseAgent {
private String tableComment(String schema, String table) throws Exception {
String sql = """
SELECT COMMENTS
SELECT /*+ PARALLEL(1) */ COMMENTS
FROM ALL_TAB_COMMENTS
WHERE OWNER = ? AND TABLE_NAME = ?
""".stripIndent().trim();
@@ -1322,6 +1358,6 @@ public final class DamengAgent extends BaseDatabaseAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new DamengAgent()).run();
new MultiSessionJsonRpcServer(DamengAgent::new).run();
}
}
@@ -34,6 +34,22 @@ class DamengAgentMetadataTest {
.findFirst()
.orElseThrow();
Assertions.assertTrue(columnsSql.contains("LEFT JOIN ALL_COL_COMMENTS"), columnsSql);
Assertions.assertTrue(columnsSql.contains("c.CHAR_USED"), columnsSql);
Assertions.assertTrue(columnsSql.startsWith("SELECT /*+ PARALLEL(1) */"), columnsSql);
}
@Test
void disablesParallelExecutionForIndexMetadataQuery() {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
agent.listIndexes("APP", "USERS");
String indexesSql = JdbcMetadataSqlFake.statements.stream()
.filter(sql -> sql.contains("ALL_INDEXES"))
.findFirst()
.orElseThrow();
Assertions.assertTrue(indexesSql.startsWith("SELECT /*+ PARALLEL(1) */"), indexesSql);
}
@Test
@@ -191,6 +207,30 @@ class DamengAgentMetadataTest {
Assertions.assertEquals("identity", columns.get(0).getExtra());
}
@Test
void preservesCharacterLengthUnitsFromMetadata() {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, metadataConnectionForColumns(List.of(
Arrays.asList("BYTE_VALUE", "VARCHAR2", "Y", null, null, 256, 64, "B", "byte length"),
Arrays.asList("CHAR_VALUE", "VARCHAR", "Y", null, null, 256, 64, "C", "character length"),
Arrays.asList("FIXED_VALUE", "CHAR", "Y", null, null, 40, 10, "C", "fixed length"),
Arrays.asList("DEFAULT_VALUE", "VARCHAR2", "Y", null, null, 80, 20, null, "default length"),
Arrays.asList("WORD_VALUE", "VARCHAR2", "Y", null, null, 128, 32, "BYTE", "word byte length"),
Arrays.asList("NATIONAL_VALUE", "NVARCHAR2", "Y", null, null, 80, 20, "C", "national length"),
Arrays.asList("NATIONAL_FIXED", "NCHAR", "Y", null, null, 40, 10, "B", "national fixed length")
)));
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
Assertions.assertEquals("VARCHAR2(256 BYTE)", columns.get(0).getData_type());
Assertions.assertEquals("VARCHAR(64 CHAR)", columns.get(1).getData_type());
Assertions.assertEquals("CHAR(10 CHAR)", columns.get(2).getData_type());
Assertions.assertEquals("VARCHAR2(20)", columns.get(3).getData_type());
Assertions.assertEquals("VARCHAR2(128 BYTE)", columns.get(4).getData_type());
Assertions.assertEquals("NVARCHAR2(20)", columns.get(5).getData_type());
Assertions.assertEquals("NCHAR(10)", columns.get(6).getData_type());
}
@Test
void appendsTableAndColumnCommentsToTableDdl() {
DamengAgent agent = new DamengAgent();
@@ -203,6 +243,43 @@ class DamengAgentMetadataTest {
Assertions.assertTrue(ddl.contains("COMMENT ON COLUMN \"APP\".\"USERS\".\"ID\" IS 'id comment';"), ddl);
}
@Test
void closesDbmsMetadataResultBeforeLoadingSupplementalDdlMetadata() {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, metadataConnection());
Assertions.assertDoesNotThrow(() -> agent.getTableDdl("APP", "USERS"));
}
@Test
void disablesParallelExecutionForTableDdlMetadataQueries() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, metadataConnection(
"id comment",
null,
false,
List.of(),
sqls
));
agent.getTableDdl("APP", "USERS");
List<String> ddlMetadataSql = sqls.stream()
.filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")
|| sql.contains("ALL_TAB_COLUMNS")
|| sql.contains("ALL_CONS_COLUMNS")
|| sql.contains("SYS.SYSCOLUMNS")
|| sql.contains("ALL_TAB_COMMENTS")
|| sql.contains("ALL_INDEXES"))
.toList();
Assertions.assertFalse(ddlMetadataSql.isEmpty());
Assertions.assertTrue(
ddlMetadataSql.stream().allMatch(sql -> sql.startsWith("SELECT /*+ PARALLEL(1) */")),
String.join("\n", ddlMetadataSql)
);
}
@Test
void readsFullTableDdlFromCharacterStreamWhenGetStringIsTruncated() {
DamengAgent agent = new DamengAgent();
@@ -316,15 +393,51 @@ class DamengAgentMetadataTest {
List<String> sqls,
String dbmsMetadataDdl
) {
return metadataConnection(
allColumnComment,
fallbackColumnComment,
includeMaterializedView,
independentIndexes,
sqls,
dbmsMetadataDdl,
defaultColumnMetadataRows(allColumnComment)
);
}
private static Connection metadataConnectionForColumns(List<List<Object>> columnRows) {
return metadataConnection(
"id comment",
null,
false,
List.of(),
null,
"CREATE TABLE \"APP\".\"USERS\" (\n \"ID\" NUMBER\n);",
columnRows
);
}
private static Connection metadataConnection(
String allColumnComment,
String fallbackColumnComment,
boolean includeMaterializedView,
List<List<Object>> independentIndexes,
List<String> sqls,
String dbmsMetadataDdl,
List<List<Object>> columnRows
) {
boolean[] dbmsMetadataResultOpen = {false};
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
if ("prepareStatement".equals(name)) {
String sql = (String) args[0];
if (dbmsMetadataResultOpen[0]) {
throw new AssertionError("Supplemental metadata query started before DBMS_METADATA ResultSet closed: " + sql);
}
if (sqls != null) {
sqls.add(sql);
}
if (sql.contains("DBMS_METADATA.GET_DDL")) {
return dbmsMetadataStatement(dbmsMetadataDdl);
return dbmsMetadataStatement(dbmsMetadataDdl, dbmsMetadataResultOpen);
}
if (sql.contains("SYS.SYSOBJECTS") && sql.contains("TYPE$ = 'SCH'")) {
return metadataStatement(List.of(List.of("APP"), List.of("EMPTY_SCHEMA")));
@@ -335,7 +448,7 @@ class DamengAgentMetadataTest {
if (sql.contains("SYS.SYSCOLUMNS")) {
return metadataStatement(List.of(List.of("ID")));
}
if (sql.startsWith("SELECT COMMENTS")) {
if (sql.contains("SELECT /*+ PARALLEL(1) */ COMMENTS")) {
return metadataStatement(List.of(List.of("用户示例表")));
}
if (sql.contains("USER_COL_COMMENTS")) {
@@ -384,16 +497,7 @@ class DamengAgentMetadataTest {
return metadataStatement(List.of());
}
if (sql.contains("ALL_TAB_COLUMNS")) {
return metadataStatement(List.of(Arrays.asList(
"ID",
"NUMBER",
"N",
Integer.valueOf(10),
Integer.valueOf(0),
Integer.valueOf(22),
Integer.valueOf(10),
allColumnComment
)));
return metadataStatement(columnRows);
}
}
if ("close".equals(name)) {
@@ -406,11 +510,25 @@ class DamengAgentMetadataTest {
});
}
private static List<List<Object>> defaultColumnMetadataRows(String allColumnComment) {
return List.of(Arrays.asList(
"ID",
"NUMBER",
"N",
Integer.valueOf(10),
Integer.valueOf(0),
Integer.valueOf(22),
Integer.valueOf(10),
null,
allColumnComment
));
}
private static List<Object> indexRow(String name, String columns, String uniqueness, String indexType) {
return List.of(name, columns, uniqueness, indexType);
}
private static PreparedStatement dbmsMetadataStatement(String ddl) {
private static PreparedStatement dbmsMetadataStatement(String ddl, boolean[] resultOpen) {
List<String> params = new ArrayList<>();
return proxy(PreparedStatement.class, (method, args) -> {
String name = method.getName();
@@ -419,7 +537,11 @@ class DamengAgentMetadataTest {
if ("INDEX".equals(objectType)) {
throw new AssertionError("Dameng table DDL should generate index DDL from metadata");
}
return metadataResultSet(List.of(List.of(new LongText(ddl, ddl.substring(0, Math.min(ddl.length(), 64))))));
resultOpen[0] = true;
return metadataResultSet(
List.of(List.of(new LongText(ddl, ddl.substring(0, Math.min(ddl.length(), 64))))),
() -> resultOpen[0] = false
);
}
if ("setString".equals(name)) {
int index = ((Integer) args[0]) - 1;
@@ -486,6 +608,10 @@ class DamengAgentMetadataTest {
}
private static ResultSet metadataResultSet(List<List<Object>> rows) {
return metadataResultSet(rows, () -> {});
}
private static ResultSet metadataResultSet(List<List<Object>> rows, Runnable onClose) {
int[] index = {-1};
return proxy(ResultSet.class, (method, args) -> {
String name = method.getName();
@@ -506,6 +632,7 @@ class DamengAgentMetadataTest {
case "DATA_TYPE" -> string(rows, index[0], 1);
case "NULLABLE" -> string(rows, index[0], 2);
case "DATA_DEFAULT" -> null;
case "CHAR_USED" -> string(rows, index[0], 7);
case "COMMENTS", "COMMENT$" -> string(rows, index[0], rows.get(index[0]).size() - 1);
case "COLNAME" -> string(rows, index[0], 0);
default -> null;
@@ -527,6 +654,7 @@ class DamengAgentMetadataTest {
};
}
if ("close".equals(name)) {
onClose.run();
return null;
}
return defaultValue(method.getReturnType());
@@ -2,7 +2,7 @@ package com.dbx.agent.databend;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.ObjectSource;
@@ -294,6 +294,6 @@ public final class DatabendAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new DatabendAgent()).run();
new MultiSessionJsonRpcServer(DatabendAgent::new).run();
}
}
@@ -3,7 +3,7 @@ package com.dbx.agent.databricks;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
public final class DatabricksAgent extends ConfiguredJdbcAgent {
public static final JdbcAgentProfile DATABRICKS_PROFILE = new DatabricksProfile();
@@ -13,7 +13,7 @@ public final class DatabricksAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new DatabricksAgent()).run();
new MultiSessionJsonRpcServer(DatabricksAgent::new).run();
}
private static final class DatabricksProfile extends JdbcAgentProfile {
@@ -9,7 +9,7 @@ import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.MetadataSqlSupport;
import com.dbx.agent.ObjectInfo;
@@ -387,7 +387,7 @@ public final class Db2Agent extends BaseDatabaseAgent {
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
requireConnected(),
sql,
schema,
@@ -510,6 +510,6 @@ public final class Db2Agent extends BaseDatabaseAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new Db2Agent()).run();
new MultiSessionJsonRpcServer(Db2Agent::new).run();
}
}
@@ -2,7 +2,7 @@ package com.dbx.agent.exasol;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
public final class ExasolAgent extends ConfiguredJdbcAgent {
public static final JdbcAgentProfile EXASOL_PROFILE = new JdbcAgentProfile(
@@ -17,6 +17,6 @@ public final class ExasolAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new ExasolAgent()).run();
new MultiSessionJsonRpcServer(ExasolAgent::new).run();
}
}
@@ -2,7 +2,7 @@ package com.dbx.agent.firebird;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
public final class FirebirdAgent extends ConfiguredJdbcAgent {
public static final JdbcAgentProfile FIREBIRD_PROFILE = new JdbcAgentProfile(
@@ -17,6 +17,6 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new FirebirdAgent()).run();
new MultiSessionJsonRpcServer(FirebirdAgent::new).run();
}
}
@@ -4,7 +4,7 @@ import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.ObjectSource;
@@ -340,6 +340,6 @@ public final class Gbase8aAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new Gbase8aAgent()).run();
new MultiSessionJsonRpcServer(Gbase8aAgent::new).run();
}
}
@@ -6,7 +6,7 @@ import com.dbx.agent.ColumnInfo;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectSource;
@@ -376,7 +376,7 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new Gbase8sAgent()).run();
new MultiSessionJsonRpcServer(Gbase8sAgent::new).run();
}
private static int port(ConnectParams params) {
@@ -9,7 +9,7 @@ import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.MetadataSqlSupport;
import com.dbx.agent.ObjectInfo;
@@ -406,7 +406,7 @@ public final class GoldendbAgent extends BaseDatabaseAgent {
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
requireConnected(),
sql,
schema,
@@ -550,6 +550,6 @@ public final class GoldendbAgent extends BaseDatabaseAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new GoldendbAgent()).run();
new MultiSessionJsonRpcServer(GoldendbAgent::new).run();
}
}
+13
View File
@@ -0,0 +1,13 @@
sourceSets {
main.java.srcDirs = ['src/main/java', '../h2/src/main/java']
}
dependencies {
implementation 'com.h2database:h2:2.1.214'
}
tasks.named('shadowJar') {
manifest {
attributes('Agent-Label': 'H2 2.1 Legacy', 'Main-Class': 'com.dbx.agent.h2legacy.H2LegacyAgent')
}
}
@@ -0,0 +1,10 @@
package com.dbx.agent.h2legacy;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.h2.H2Agent;
public final class H2LegacyAgent extends H2Agent {
public static void main(String[] args) {
new MultiSessionJsonRpcServer(H2LegacyAgent::new).run();
}
}
@@ -8,7 +8,7 @@ import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.MetadataSqlSupport;
import com.dbx.agent.ObjectInfo;
@@ -28,7 +28,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
public final class H2Agent extends AbstractJdbcAgent {
public class H2Agent extends AbstractJdbcAgent {
private String databaseName = "";
@Override
@@ -414,7 +414,7 @@ public final class H2Agent extends AbstractJdbcAgent {
@Override
protected Object resultValue(ResultSet rs, int index, int sqlType) {
return unchecked(() -> JdbcExecutor.INSTANCE.defaultResultValue(rs, index, sqlType));
return unchecked(() -> JdbcExecutor.current().defaultResultValue(rs, index, sqlType));
}
@Override
@@ -511,6 +511,6 @@ public final class H2Agent extends AbstractJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new H2Agent()).run();
new MultiSessionJsonRpcServer(H2Agent::new).run();
}
}
@@ -1,6 +1,6 @@
package com.dbx.agent.highgo;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.PostgresLikeAgent;
import com.dbx.agent.PostgresLikeAgentProfile;
@@ -15,6 +15,6 @@ public final class HighgoAgent extends PostgresLikeAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new HighgoAgent()).run();
new MultiSessionJsonRpcServer(HighgoAgent::new).run();
}
}
@@ -7,7 +7,7 @@ import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
import com.dbx.agent.ExecuteQueryOptions;
@@ -269,6 +269,6 @@ public final class HiveAgent extends AbstractJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new HiveAgent()).run();
new MultiSessionJsonRpcServer(HiveAgent::new).run();
}
}
@@ -8,7 +8,7 @@ import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.MetadataSqlSupport;
import com.dbx.agent.ObjectInfo;
@@ -459,7 +459,7 @@ public final class InformixAgent extends BaseDatabaseAgent {
case "ROLLBACK WORK" -> "ROLLBACK";
default -> sql;
};
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
requireConnected(),
normalizedSql,
schema,
@@ -551,6 +551,6 @@ public final class InformixAgent extends BaseDatabaseAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new InformixAgent()).run();
new MultiSessionJsonRpcServer(InformixAgent::new).run();
}
}
@@ -3,7 +3,7 @@ package com.dbx.agent.iotdb;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
@@ -41,6 +41,6 @@ public final class IoTDBAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new IoTDBAgent()).run();
new MultiSessionJsonRpcServer(IoTDBAgent::new).run();
}
}
@@ -3,7 +3,7 @@ package com.dbx.agent.iris;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.StandardJdbcMetadata;
import java.sql.Connection;
@@ -154,6 +154,6 @@ public final class IrisAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new IrisAgent()).run();
new MultiSessionJsonRpcServer(IrisAgent::new).run();
}
}
@@ -6,7 +6,7 @@ import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.MetadataSqlSupport;
import com.dbx.agent.ObjectInfo;
@@ -44,6 +44,7 @@ public final class KingbaseAgent extends PostgresLikeAgent {
private static final String KINGBASE_VIEW_SCHEMA = "CAST(v.schemaname AS varchar(256))";
private static final String KINGBASE_MATVIEW_NAME = "CAST(mv.matviewname AS varchar(256))";
private static final String KINGBASE_MATVIEW_SCHEMA = "CAST(mv.schemaname AS varchar(256))";
private boolean postgresCatalogMode;
public static final PostgresLikeAgentProfile KINGBASE_PROFILE = new PostgresLikeAgentProfile(
"com.kingbase8.Driver",
@@ -56,13 +57,40 @@ public final class KingbaseAgent extends PostgresLikeAgent {
@Override
protected void afterConnect(ConnectParams params, Connection connection) {
postgresCatalogMode = false;
setMysqlCompatMode(params.isMysql_compat_mode());
if (params.isMysql_compat_mode()) {
return;
}
postgresCatalogMode = !catalogExists(connection, "sys_catalog.sys_namespace")
&& catalogExists(connection, "pg_catalog.pg_namespace");
if (!postgresCatalogMode && mysqlSqlModeExists(connection)) {
setMysqlCompatMode(true);
}
}
private static boolean mysqlSqlModeExists(Connection connection) {
try (Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery("SELECT 1 FROM sys_settings WHERE LOWER(name) = 'sql_mode'")) {
return rs.next();
} catch (Exception ignored) {
return false;
}
}
private static boolean catalogExists(Connection connection, String catalog) {
try (Statement stmt = connection.createStatement();
ResultSet ignored = stmt.executeQuery("SELECT 1 FROM " + catalog + " WHERE 1 = 0")) {
return true;
} catch (Exception ignored) {
// Kingbase compatibility modes expose different catalog families.
return false;
}
}
@Override
public List<DatabaseInfo> listDatabases() {
if (postgresCatalogMode) return super.listDatabases();
return unchecked(() -> {
if (isMysqlCompatMode()) {
List<DatabaseInfo> result = queryDatabases("SELECT current_database() AS database_name");
@@ -96,6 +124,7 @@ public final class KingbaseAgent extends PostgresLikeAgent {
@Override
public List<String> listSchemas() {
if (postgresCatalogMode) return super.listSchemas();
return unchecked(() -> {
List<String> result = new ArrayList<>();
String sql = isMysqlCompatMode()
@@ -122,6 +151,7 @@ public final class KingbaseAgent extends PostgresLikeAgent {
@Override
public List<TableInfo> listTables(String schema) {
if (postgresCatalogMode) return super.listTables(schema);
if (isMysqlCompatMode()) {
return listTables(schema, "table_type IN ('BASE TABLE', 'VIEW')");
}
@@ -130,6 +160,7 @@ public final class KingbaseAgent extends PostgresLikeAgent {
@Override
public List<TableInfo> listTables(String schema, MetadataListConstraints constraints) {
if (postgresCatalogMode) return super.listTables(schema, constraints);
MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints);
if (isUnconstrained(normalized)) {
return listTables(schema);
@@ -199,6 +230,7 @@ public final class KingbaseAgent extends PostgresLikeAgent {
@Override
public List<ObjectInfo> listObjects(String schema) {
if (postgresCatalogMode) return super.listObjects(schema);
return unchecked(() -> {
String effectiveSchema = effectiveSchema(schema);
List<ObjectInfo> result = new ArrayList<>();
@@ -235,6 +267,7 @@ public final class KingbaseAgent extends PostgresLikeAgent {
@Override
public List<ObjectInfo> listObjects(String schema, MetadataListConstraints constraints) {
if (postgresCatalogMode) return super.listObjects(schema, constraints);
MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints);
if (isUnconstrained(normalized)) {
return listObjects(schema);
@@ -299,6 +332,7 @@ public final class KingbaseAgent extends PostgresLikeAgent {
@Override
public ObjectSource getObjectSource(String schema, String name, String objectType) {
if (postgresCatalogMode) return super.getObjectSource(schema, name, objectType);
if ("FUNCTION".equalsIgnoreCase(objectType) || "PROCEDURE".equalsIgnoreCase(objectType)) {
return routineSource(schema, name, objectType);
}
@@ -354,6 +388,7 @@ public final class KingbaseAgent extends PostgresLikeAgent {
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
if (postgresCatalogMode) return super.getColumns(schema, table);
return unchecked(() -> {
Set<String> primaryKeys = primaryKeys(schema, table);
if (!isMysqlCompatMode()) {
@@ -444,6 +479,7 @@ public final class KingbaseAgent extends PostgresLikeAgent {
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
if (postgresCatalogMode) return super.listIndexes(schema, table);
return unchecked(() -> {
Map<String, CatalogIndexBuilder> indexes = new LinkedHashMap<>();
String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " +
@@ -487,6 +523,7 @@ public final class KingbaseAgent extends PostgresLikeAgent {
@Override
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
if (postgresCatalogMode) return super.listForeignKeys(schema, table);
return unchecked(() -> {
List<ForeignKeyInfo> result = new ArrayList<>();
String sql = "SELECT fk.constraint_name, fk.column_name, pk.table_name AS ref_table, pk.column_name AS ref_column " +
@@ -525,11 +562,13 @@ public final class KingbaseAgent extends PostgresLikeAgent {
@Override
public List<TriggerInfo> listTriggers(String schema, String table) {
if (postgresCatalogMode) return super.listTriggers(schema, table);
return Collections.emptyList();
}
@Override
public String setSchemaSQL(String schema) {
if (postgresCatalogMode) return super.setSchemaSQL(schema);
// Kingbase searches sys_catalog implicitly before user schemas unless it
// is listed explicitly. Put it after the selected schema so business
// tables named like system tables (for example sys_config) win.
@@ -807,6 +846,6 @@ public final class KingbaseAgent extends PostgresLikeAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new KingbaseAgent()).run();
new MultiSessionJsonRpcServer(KingbaseAgent::new).run();
}
}
@@ -1,6 +1,7 @@
package com.dbx.agent.kingbase;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.DatabaseAgent;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.IndexInfo;
@@ -115,6 +116,43 @@ class KingbaseAgentTest extends JdbcFakeExecutionBehaviorTest {
Assertions.assertFalse(sql.get(0).contains("SYS%"), sql.get(0));
}
@Test
void postgresCompatModeUsesPostgresCatalogForMetadata() throws Exception {
List<String> sql = new ArrayList<>();
KingbaseAgent agent = new KingbaseAgent();
Connection connection = postgresCatalogConnection(sql, resultSet(
new String[]{"schema_name"},
new Object[][]{{"public"}}
));
Method afterConnect = KingbaseAgent.class.getDeclaredMethod("afterConnect", ConnectParams.class, Connection.class);
afterConnect.setAccessible(true);
afterConnect.invoke(agent, new ConnectParams(), connection);
TestSupport.setPrivateConnection(agent, connection);
Assertions.assertEquals(List.of("public"), agent.listSchemas());
Assertions.assertEquals("SELECT 1 FROM sys_catalog.sys_namespace WHERE 1 = 0", sql.get(0));
Assertions.assertEquals("SELECT 1 FROM pg_catalog.pg_namespace WHERE 1 = 0", sql.get(1));
Assertions.assertTrue(sql.get(2).contains("FROM pg_catalog.pg_namespace"), sql.get(2));
Assertions.assertEquals("SET search_path TO \"app\"", agent.setSchemaSQL("app"));
}
@Test
void detectsMysqlCompatModeFromServerSqlMode() throws Exception {
List<String> sql = new ArrayList<>();
KingbaseAgent agent = new KingbaseAgent();
Connection connection = mysqlCompatConnection(sql);
Method afterConnect = KingbaseAgent.class.getDeclaredMethod("afterConnect", ConnectParams.class, Connection.class);
afterConnect.setAccessible(true);
afterConnect.invoke(agent, new ConnectParams(), connection);
Assertions.assertTrue(agent.isMysqlCompatMode());
Assertions.assertEquals("`", agent.getIdentifierQuote());
Assertions.assertEquals("SELECT 1 FROM sys_catalog.sys_namespace WHERE 1 = 0", sql.get(0));
Assertions.assertEquals("SELECT 1 FROM sys_settings WHERE LOWER(name) = 'sql_mode'", sql.get(1));
}
@Test
void mysqlCompatListTablesUsesInformationSchema() {
List<String> sql = new ArrayList<>();
@@ -462,6 +500,54 @@ class KingbaseAgentTest extends JdbcFakeExecutionBehaviorTest {
});
}
private static Connection postgresCatalogConnection(List<String> sql, ResultSet metadataResult) {
return proxy(Connection.class, (method, args) -> {
if ("createStatement".equals(method.getName())) {
return proxy(Statement.class, (statementMethod, statementArgs) -> {
if ("executeQuery".equals(statementMethod.getName())) {
String query = String.valueOf(statementArgs[0]);
sql.add(query);
if (query.contains("sys_catalog.sys_namespace")) {
throw new SQLException("relation does not exist: sys_catalog.sys_namespace");
}
return resultSet(new String[]{"probe"}, new Object[][]{});
}
return defaultValue(statementMethod.getReturnType());
});
}
if ("prepareStatement".equals(method.getName())) {
String query = String.valueOf(args[0]);
sql.add(query);
return proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
if ("executeQuery".equals(statementMethod.getName())) return metadataResult;
return defaultValue(statementMethod.getReturnType());
});
}
if ("isClosed".equals(method.getName())) return false;
return defaultValue(method.getReturnType());
});
}
private static Connection mysqlCompatConnection(List<String> sql) {
return proxy(Connection.class, (method, args) -> {
if ("createStatement".equals(method.getName())) {
return proxy(Statement.class, (statementMethod, statementArgs) -> {
if ("executeQuery".equals(statementMethod.getName())) {
String query = String.valueOf(statementArgs[0]);
sql.add(query);
if (query.contains("sys_settings")) {
return resultSet(new String[]{"probe"}, new Object[][]{{1}});
}
return resultSet(new String[]{"probe"}, new Object[][]{});
}
return defaultValue(statementMethod.getReturnType());
});
}
if ("isClosed".equals(method.getName())) return false;
return defaultValue(method.getReturnType());
});
}
private static ResultSet resultSet(String[] columns, Object[][] rows) {
int[] index = {-1};
return proxy(ResultSet.class, (method, args) -> {
@@ -6,7 +6,7 @@ import com.dbx.agent.ConnectParams;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
import java.sql.ResultSet;
@@ -139,6 +139,6 @@ public final class KylinAgent extends AbstractJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new KylinAgent()).run();
new MultiSessionJsonRpcServer(KylinAgent::new).run();
}
}
@@ -13,6 +13,7 @@ import com.mongodb.MongoClientSettings;
import com.mongodb.ServerAddress;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.model.UpdateOptions;
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.InputStream;
@@ -41,6 +42,9 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
@@ -58,7 +62,10 @@ public final class MongoAgent {
private static final JsonWriterSettings EXTENDED_JSON_SETTINGS = JsonWriterSettings.builder()
.outputMode(JsonMode.RELAXED)
.build();
private static MongoClient client;
private static final String LEGACY_SESSION_ID = "__legacy__";
private static final int MAX_SESSIONS = 256;
private static final ThreadLocal<MongoClient> CURRENT_CLIENT = new ThreadLocal<>();
private static MongoClient legacyClient;
private MongoAgent() {
}
@@ -109,7 +116,7 @@ public final class MongoAgent {
return builder;
}
private static Object connect(JsonObject params) {
private static MongoClient openClient(JsonObject params) {
JsonObject connObj = params.has("connection") && params.get("connection").isJsonObject()
? params.getAsJsonObject("connection")
: params;
@@ -117,11 +124,19 @@ public final class MongoAgent {
MongoClientSettings.Builder builder = configureBuilder(connObj);
if (client != null) {
MongoClient client = MongoClients.create(builder.build());
try {
client.getDatabase(database).runCommand(new Document("ping", 1));
} catch (RuntimeException error) {
client.close();
throw error;
}
client = MongoClients.create(builder.build());
client.getDatabase(database).runCommand(new Document("ping", 1));
return client;
}
private static Object connect(JsonObject params) {
closeLegacyClient();
legacyClient = openClient(params);
return Collections.singletonMap("ok", true);
}
@@ -428,6 +443,29 @@ public final class MongoAgent {
return result;
}
private static Object countDocuments(JsonObject params) {
MongoClient c = requireClient();
String database = params.get("database").getAsString();
String collection = params.get("collection").getAsString();
Document filterDoc = documentOrNull(params, "filter");
if (filterDoc == null) {
filterDoc = new Document();
}
boolean accurate = !params.has("accurate") || params.get("accurate").getAsBoolean();
if (accurate) {
return c.getDatabase(database).getCollection(collection).countDocuments(filterDoc);
}
// MongoDB 3.4 count() needs the legacy command to avoid the slow countDocuments path.
Document result = c.getDatabase(database).runCommand(new Document("count", collection).append("query", filterDoc));
Object n = result.get("n");
if (n instanceof Number number) {
return number.longValue();
}
return 0L;
}
private static Object serverVersion(JsonObject params) {
MongoClient c = requireClient();
String database = defaultString(stringOrNull(params, "database"), "admin");
@@ -625,15 +663,47 @@ public final class MongoAgent {
String filterJson = params.get("filter_json").getAsString();
String updateJson = params.get("update_json").getAsString();
boolean many = params.get("many").getAsBoolean();
String optionsJson = params.has("options_json") && !params.get("options_json").isJsonNull()
? params.get("options_json").getAsString()
: null;
var col = c.getDatabase(database).getCollection(collection);
Document filter = documentForWrite(filterJson);
Document update = documentForWrite(updateJson);
requireBulkUpdateOperatorDocument(update);
var result = many ? col.updateMany(filter, update) : col.updateOne(filter, update);
UpdateOptions options = updateOptionsForWrite(optionsJson);
var result = many ? col.updateMany(filter, update, options) : col.updateOne(filter, update, options);
return Collections.singletonMap("modified_count", result.getModifiedCount());
}
static UpdateOptions updateOptionsForWrite(String optionsJson) {
UpdateOptions result = new UpdateOptions();
if (optionsJson == null || optionsJson.trim().isEmpty()) {
return result;
}
Document options = Document.parse(optionsJson);
for (String key : options.keySet()) {
if (!"arrayFilters".equals(key)) {
throw new IllegalArgumentException("Unsupported update option: " + key);
}
}
Object rawFilters = options.get("arrayFilters");
if (rawFilters == null) {
return result;
}
if (!(rawFilters instanceof List<?>)) {
throw new IllegalArgumentException("arrayFilters must be an array");
}
List<Document> filters = new ArrayList<>();
for (Object filter : (List<?>) rawFilters) {
if (!(filter instanceof Document)) {
throw new IllegalArgumentException("Each arrayFilters entry must be an object");
}
filters.add((Document) filter);
}
return result.arrayFilters(filters);
}
static Document documentForWrite(String docJson) {
Document doc = Document.parse(docJson);
convertMongoShellDates(doc);
@@ -813,6 +883,7 @@ public final class MongoAgent {
case AgentProtocol.METHOD_LIST_INDEXES -> listIndexes(params);
case AgentProtocol.MONGO_METHOD_FIND_DOCUMENTS -> findDocuments(params);
case AgentProtocol.MONGO_METHOD_FIND_DOCUMENTS_EXTENDED_JSON -> findDocumentsExtendedJson(params);
case AgentProtocol.MONGO_METHOD_COUNT_DOCUMENTS -> countDocuments(params);
case AgentProtocol.MONGO_METHOD_SERVER_VERSION -> serverVersion(params);
case AgentProtocol.MONGO_METHOD_CREATE_INDEX -> createIndex(params);
case AgentProtocol.MONGO_METHOD_DROP_INDEXES -> dropIndexes(params);
@@ -823,10 +894,7 @@ public final class MongoAgent {
case AgentProtocol.MONGO_METHOD_DELETE_DOCUMENT -> deleteDocument(params);
case AgentProtocol.MONGO_METHOD_DELETE_DOCUMENTS -> deleteDocuments(params);
case AgentProtocol.METHOD_DISCONNECT, AgentProtocol.METHOD_SHUTDOWN -> {
if (client != null) {
client.close();
client = null;
}
closeLegacyClient();
if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
System.exit(0);
}
@@ -837,12 +905,23 @@ public final class MongoAgent {
}
private static MongoClient requireClient() {
MongoClient client = CURRENT_CLIENT.get();
if (client == null) {
client = legacyClient;
}
if (client == null) {
throw new IllegalStateException("Not connected");
}
return client;
}
private static void closeLegacyClient() {
if (legacyClient != null) {
legacyClient.close();
legacyClient = null;
}
}
private static String coalesce(String value) {
return value == null ? "" : value;
}
@@ -879,16 +958,149 @@ public final class MongoAgent {
public static void main(String[] args) throws Exception {
System.out.println("{\"ready\":true}");
System.out.flush();
new RuntimeServer().run();
}
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
String line = reader.readLine();
if (line == null) {
break;
private static final class RuntimeServer {
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
private final ExecutorService requests = Executors.newCachedThreadPool();
private final Object outputLock = new Object();
private void run() throws Exception {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String line;
while ((line = reader.readLine()) != null) {
String request = line;
requests.submit(() -> writeResponse(handleRuntimeRequest(request)));
}
closeAllSessions();
requests.shutdownNow();
}
System.out.println(handleRequest(line));
System.out.flush();
private String handleRuntimeRequest(String line) {
JsonObject req = JsonParser.parseString(line).getAsJsonObject();
JsonElement id = req.get("id");
String method = req.get("method").getAsString();
JsonObject params = req.has("params") && req.get("params").isJsonObject()
? req.getAsJsonObject("params")
: new JsonObject();
JsonObject response = new JsonObject();
response.addProperty("jsonrpc", "2.0");
response.add("id", id);
try {
Object result;
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
result = AgentProtocol.multiSessionHandshakeResult();
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
result = openSession(requiredSessionId(params), params);
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
result = closeSession(requiredSessionId(params));
} else if (AgentProtocol.METHOD_VALIDATE_SESSION.equals(method)) {
result = session(requiredSessionId(params)).validate(params);
} else if (AgentProtocol.METHOD_CANCEL_SESSION.equals(method)) {
// The legacy synchronous MongoDB driver has no safe per-operation cancel API.
result = Collections.singletonMap("ok", true);
} else if (AgentProtocol.METHOD_CONNECT.equals(method)) {
closeSession(LEGACY_SESSION_ID);
result = openSession(LEGACY_SESSION_ID, params);
} else if (AgentProtocol.METHOD_DISCONNECT.equals(method)) {
result = closeSession(LEGACY_SESSION_ID);
} else if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
closeAllSessions();
result = Collections.singletonMap("ok", true);
} else {
String sessionId = params.has("agentSessionId")
? params.get("agentSessionId").getAsString()
: LEGACY_SESSION_ID;
result = session(sessionId).handle(method, params);
}
response.add("result", GSON.toJsonTree(result));
} catch (Exception error) {
JsonObject rpcError = new JsonObject();
rpcError.addProperty("code", -1);
rpcError.addProperty("message", error.getMessage() == null ? "Unknown error" : error.getMessage());
response.add("error", rpcError);
}
return GSON.toJson(response);
}
private Object openSession(String sessionId, JsonObject params) {
if (sessions.size() >= MAX_SESSIONS && !sessions.containsKey(sessionId)) {
throw new IllegalStateException("Agent session limit reached: " + MAX_SESSIONS);
}
Session created = new Session(openClient(params));
Session existing = sessions.putIfAbsent(sessionId, created);
if (existing != null) {
created.close();
throw new IllegalStateException("Agent session already exists: " + sessionId);
}
return Collections.singletonMap("ok", true);
}
private Object closeSession(String sessionId) {
Session removed = sessions.remove(sessionId);
if (removed != null) {
removed.close();
}
return Collections.singletonMap("ok", true);
}
private Session session(String sessionId) {
Session session = sessions.get(sessionId);
if (session == null) {
throw new IllegalStateException("Agent session not found: " + sessionId);
}
return session;
}
private void closeAllSessions() {
for (String sessionId : sessions.keySet()) {
closeSession(sessionId);
}
}
private static String requiredSessionId(JsonObject params) {
if (!params.has("agentSessionId") || params.get("agentSessionId").getAsString().trim().isEmpty()) {
throw new IllegalArgumentException("agentSessionId is required");
}
return params.get("agentSessionId").getAsString();
}
private void writeResponse(String response) {
synchronized (outputLock) {
System.out.println(response);
System.out.flush();
}
}
}
private static final class Session {
private final MongoClient client;
private Session(MongoClient client) {
this.client = client;
}
private synchronized Object handle(String method, JsonObject params) {
CURRENT_CLIENT.set(client);
try {
return dispatch(method, params);
} finally {
CURRENT_CLIENT.remove();
}
}
private Object validate(JsonObject params) {
JsonObject connection = params.has("connection") && params.get("connection").isJsonObject()
? params.getAsJsonObject("connection")
: params;
String database = defaultString(stringOrNull(connection, "database"), "admin");
client.getDatabase(database).runCommand(new Document("ping", 1));
return Collections.singletonMap("ok", true);
}
private synchronized void close() {
client.close();
}
}
}
@@ -12,6 +12,7 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mongodb.MongoClientSettings;
import com.mongodb.client.model.UpdateOptions;
import java.io.FileInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -97,6 +98,16 @@ class MongoAgentTest {
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_METADATA));
}
@Test
void legacyJsonRpcHandshakeRemainsProtocolV1() {
JsonObject result = JsonParser.parseString(MongoAgent.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"handshake\",\"params\":{}}"
)).getAsJsonObject().getAsJsonObject("result");
assertEquals(1, result.get("protocolVersion").getAsInt());
assertFalse(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MULTI_SESSION));
}
@Test
void listIndexesMethodIsRecognizedOverJsonRpc() {
String response = MongoAgent.handleRequest(
@@ -109,6 +120,18 @@ class MongoAgentTest {
assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method"));
}
@Test
void countDocumentsMethodIsRecognizedOverJsonRpc() {
String response = MongoAgent.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":15,\"method\":\"count_documents\","
+ "\"params\":{\"database\":\"app\",\"collection\":\"orders\",\"filter\":\"{}\"}}");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertEquals(15, json.get("id").getAsInt());
assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString());
assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method"));
}
@Test
void parsesOptionalDocumentParameters() {
JsonObject params = new JsonObject();
@@ -202,6 +225,25 @@ class MongoAgentTest {
assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method"));
}
@Test
void parsesArrayFiltersUpdateOption() {
UpdateOptions options = MongoAgent.updateOptionsForWrite(
"{\"arrayFilters\":[{\"item.id\":322678}]}"
);
assertEquals(1, options.getArrayFilters().size());
assertEquals(322678, ((Document) options.getArrayFilters().get(0)).getInteger("item.id"));
}
@Test
void rejectsUnsupportedUpdateOptions() {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> MongoAgent.updateOptionsForWrite("{\"upsert\":true}")
);
assertEquals("Unsupported update option: upsert", error.getMessage());
}
@Test
void deleteDocumentsMethodIsRecognizedOverJsonRpc() {
String response = MongoAgent.handleRequest(
@@ -8,7 +8,7 @@ import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.QueryResult;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TransactionExecutor;
@@ -279,7 +279,7 @@ public class Neo4jAgent extends BaseDatabaseAgent {
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
requireConnected(),
sql,
schema,
@@ -345,6 +345,6 @@ public class Neo4jAgent extends BaseDatabaseAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new Neo4jAgent()).run();
new MultiSessionJsonRpcServer(Neo4jAgent::new).run();
}
}
@@ -9,7 +9,7 @@ import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.TableInfo;
@@ -364,7 +364,33 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
foreignKeys = Collections.emptyList();
}
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, false, true);
String tableComment = null;
try {
tableComment = getTableComment(schema, table);
} catch (RuntimeException e) {
// Table comment is optional; DDL generation should still succeed without it.
}
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, java.util.Collections.emptyList(), false, true, tableComment);
}
@Override
public String getTableComment(String schema, String table) {
return unchecked(() -> {
String owner = normalizeSchema(schema);
String sql = "SELECT COMMENTS FROM ALL_TAB_COMMENTS WHERE OWNER = ? AND TABLE_NAME = ? AND TABLE_TYPE = 'TABLE'";
try (var stmt = requireConnection().prepareStatement(sql)) {
stmt.setString(1, owner);
stmt.setString(2, table);
try (var rs = stmt.executeQuery()) {
if (rs.next()) {
String comment = rs.getString("COMMENTS");
return (comment != null && !comment.trim().isEmpty()) ? comment : null;
}
}
}
return null;
});
}
@Override
@@ -618,6 +644,6 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new OceanBaseOracleAgent()).run();
new MultiSessionJsonRpcServer(OceanBaseOracleAgent::new).run();
}
}
@@ -197,6 +197,10 @@ class OceanBaseOracleAgentTest {
new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"},
new Object[][]{}
),
resultSet(
new String[]{"COMMENTS"},
new Object[][]{{null}}
),
columnResultSet(new Object[][]{
{"CREATED_AT", "TIMESTAMP", "N", null, null, null, null, "SYSDATE", "Created timestamp", 0},
{"INTERNAL_NOTE", "VARCHAR2", "Y", null, null, 100, 100, null, " ", 0}
+305 -30
View File
@@ -15,6 +15,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
_ "github.com/sijms/go-ora/v2"
@@ -25,8 +26,11 @@ import (
)
const protocolVersion = 1
const multiSessionProtocolVersion = 2
const defaultMaxRows = 1000
const oracleCharsetZHS32GB18030 = 854
const legacyAgentSessionID = "__legacy__"
const maxAgentSessions = 256
var (
oraclePlSQLBlockStartRegexp = regexp.MustCompile(`(?is)^\s*(?:DECLARE|BEGIN|CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:EDITIONABLE|NONEDITIONABLE)\s+)?(?:FUNCTION|PROCEDURE|TRIGGER|PACKAGE(?:\s+BODY)?|TYPE(?:\s+BODY)?))\b`)
@@ -317,11 +321,28 @@ type server struct {
tableReadSessions map[string]*querySession
nextSessionID int64
nextTableReadSessionID int64
activeCancelMu sync.Mutex
activeCancel context.CancelFunc
activeRows map[*sql.Rows]context.CancelFunc
}
type agentSession struct {
server *server
mu sync.Mutex
}
type runtimeServer struct {
mu sync.RWMutex
sessions map[string]*agentSession
}
func main() {
s := newServer()
runtime := newRuntimeServer()
encoder := json.NewEncoder(os.Stdout)
var encoderMu sync.Mutex
var requests sync.WaitGroup
shutdown := make(chan struct{})
var shutdownOnce sync.Once
fmt.Fprintln(os.Stdout, `{"ready":true}`)
scanner := bufio.NewScanner(os.Stdin)
@@ -331,22 +352,230 @@ func main() {
if line == "" {
continue
}
resp, shutdown := s.handleLine(line)
if err := encoder.Encode(resp); err != nil {
fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err)
var requestEnvelope request
if json.Unmarshal([]byte(line), &requestEnvelope) == nil && requestEnvelope.Method == "shutdown" {
requests.Wait()
resp, _ := runtime.handleLine(line)
encoderMu.Lock()
err := encoder.Encode(resp)
encoderMu.Unlock()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err)
}
return
}
if shutdown {
requests.Add(1)
go func() {
defer requests.Done()
resp, shouldShutdown := runtime.handleLine(line)
encoderMu.Lock()
err := encoder.Encode(resp)
encoderMu.Unlock()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err)
shutdownOnce.Do(func() { close(shutdown) })
return
}
if shouldShutdown {
shutdownOnce.Do(func() { close(shutdown) })
}
}()
select {
case <-shutdown:
requests.Wait()
return
default:
}
}
requests.Wait()
if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) {
fmt.Fprintf(os.Stderr, "failed to read stdin: %v\n", err)
}
}
func newRuntimeServer() *runtimeServer {
return &runtimeServer{sessions: map[string]*agentSession{}}
}
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, 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 map[string]any{
"protocolVersion": multiSessionProtocolVersion,
"agentProtocolVersion": multiSessionProtocolVersion,
"capabilities": []string{"connect", "test_connection", "metadata", "query", "ddl", "multi_session"},
}, false, nil
case "open_session":
agentSessionID := stringParam(params, "agentSessionId")
if agentSessionID == "" {
return nil, false, errors.New("agentSessionId is required")
}
var connectParams connectParams
if err := decodeParams(params, &connectParams); err != nil {
return nil, false, err
}
return map[string]bool{"ok": true}, false, r.openSession(agentSessionID, connectParams)
case "close_session":
return map[string]bool{"ok": true}, false, r.closeSession(stringParam(params, "agentSessionId"))
case "validate_session":
agentSessionID := stringParam(params, "agentSessionId")
session, err := r.session(agentSessionID)
if err != nil {
return nil, false, err
}
session.mu.Lock()
defer session.mu.Unlock()
if _, _, err := session.server.dispatch("validate_connection", params); err == nil {
return map[string]bool{"ok": true}, false, nil
}
// Reconnect only this logical session. Other sessions in the runtime keep
// their connections, transactions, cursors, and in-flight requests.
if err := session.server.connect(session.server.params); err != nil {
return nil, false, err
}
return map[string]bool{"ok": true}, false, nil
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":
return newServer().dispatch(method, params)
case "shutdown":
return map[string]bool{"ok": true}, true, r.closeAllSessions()
case "connect":
var connectParams connectParams
if err := decodeParams(params, &connectParams); err != nil {
return nil, false, err
}
return map[string]bool{"ok": true}, false, r.replaceSession(legacyAgentSessionID, connectParams)
case "disconnect":
return map[string]bool{"ok": true}, false, r.closeSession(legacyAgentSessionID)
default:
agentSessionID := stringParam(params, "agentSessionId")
if agentSessionID == "" {
agentSessionID = legacyAgentSessionID
}
return r.withSession(agentSessionID, method, params)
}
}
func (r *runtimeServer) withSession(agentSessionID, method string, params map[string]json.RawMessage) (any, bool, error) {
session, err := r.session(agentSessionID)
if err != nil {
return nil, false, err
}
// Oracle connection state, transactions, and cursors are session-scoped;
// serialize one session while allowing separate sessions to run in parallel.
session.mu.Lock()
defer session.mu.Unlock()
return session.server.dispatch(method, params)
}
func (r *runtimeServer) openSession(agentSessionID string, params connectParams) error {
r.mu.Lock()
if _, exists := r.sessions[agentSessionID]; exists {
r.mu.Unlock()
return fmt.Errorf("agent session already exists: %s", agentSessionID)
}
if len(r.sessions) >= maxAgentSessions {
r.mu.Unlock()
return fmt.Errorf("agent session limit reached: %d", maxAgentSessions)
}
session := &agentSession{server: newServer()}
r.sessions[agentSessionID] = session
r.mu.Unlock()
// Reserve the id under the registry lock, then connect outside it so unrelated
// sessions can establish database connections concurrently.
session.mu.Lock()
err := session.server.connect(params)
session.mu.Unlock()
if err != nil {
r.mu.Lock()
if r.sessions[agentSessionID] == session {
delete(r.sessions, agentSessionID)
}
r.mu.Unlock()
return err
}
return nil
}
func (r *runtimeServer) replaceSession(agentSessionID string, params connectParams) error {
_ = r.closeSession(agentSessionID)
return r.openSession(agentSessionID, params)
}
func (r *runtimeServer) session(agentSessionID string) (*agentSession, error) {
if agentSessionID == "" {
return nil, errors.New("agentSessionId is required")
}
r.mu.RLock()
session := r.sessions[agentSessionID]
r.mu.RUnlock()
if session == nil {
return nil, fmt.Errorf("agent session not found: %s", agentSessionID)
}
return session, nil
}
func (r *runtimeServer) closeSession(agentSessionID string) error {
if agentSessionID == "" {
return errors.New("agentSessionId is required")
}
r.mu.Lock()
session := r.sessions[agentSessionID]
delete(r.sessions, agentSessionID)
r.mu.Unlock()
if session == nil {
return nil
}
session.mu.Lock()
defer session.mu.Unlock()
return session.server.disconnect()
}
func (r *runtimeServer) closeAllSessions() error {
r.mu.Lock()
sessions := r.sessions
r.sessions = map[string]*agentSession{}
r.mu.Unlock()
var firstErr error
for _, session := range sessions {
session.mu.Lock()
err := session.server.disconnect()
session.mu.Unlock()
if firstErr == nil && err != nil {
firstErr = err
}
}
return firstErr
}
func newServer() *server {
return &server{sessions: map[string]*querySession{}, tableReadSessions: map[string]*querySession{}}
return &server{
sessions: map[string]*querySession{},
tableReadSessions: map[string]*querySession{},
activeRows: map[*sql.Rows]context.CancelFunc{},
}
}
func (s *server) handleLine(line string) (response, bool) {
@@ -389,6 +618,11 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
}
defer db.Close()
return map[string]bool{"ok": true}, false, nil
case "validate_connection":
if s.db == nil {
return nil, false, errors.New("not connected")
}
return map[string]bool{"ok": true}, false, pingDB(s.db, 5*time.Second)
case "list_databases":
result, err := s.listDatabases()
return result, false, err
@@ -750,7 +984,7 @@ func (s *server) listDatabases() ([]databaseInfo, error) {
}
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []databaseInfo
for rows.Next() {
var name string
@@ -810,7 +1044,7 @@ func (s *server) listDatabasesFiltered(visibleSchemas []string) ([]databaseInfo,
}
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []databaseInfo
for rows.Next() {
var name string
@@ -1054,7 +1288,7 @@ func (s *server) listTables(schema string, constraints metadataListConstraints)
}
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []tableInfo
for rows.Next() {
var item tableInfo
@@ -1082,7 +1316,7 @@ func (s *server) listObjects(schema string, constraints metadataListConstraints)
}
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []objectInfo
for rows.Next() {
var item objectInfo
@@ -1124,7 +1358,7 @@ ORDER BY c.COLUMN_ID`, []any{schema, table})
if err != nil {
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []columnInfo
for rows.Next() {
var item columnInfo
@@ -1165,7 +1399,7 @@ ORDER BY COLUMN_ID`, []any{schema, table})
if err != nil {
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []oracleColumnMeta
for rows.Next() {
var item oracleColumnMeta
@@ -1201,7 +1435,7 @@ ORDER BY i.INDEX_NAME, ic.COLUMN_POSITION`, []any{schema, table})
if err != nil {
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
byName := map[string]*indexInfo{}
order := []string{}
@@ -1259,7 +1493,7 @@ ORDER BY ac.CONSTRAINT_NAME, acc.POSITION`, []any{schema, table})
if err != nil {
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []foreignKeyInfo
for rows.Next() {
var item foreignKeyInfo
@@ -1285,7 +1519,7 @@ ORDER BY TRIGGER_NAME`, []any{schema, table})
if err != nil {
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []triggerInfo
for rows.Next() {
var item triggerInfo
@@ -1329,7 +1563,7 @@ ORDER BY LINE`, []any{schema, strings.ToUpper(name), upperType})
if err != nil {
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var builder strings.Builder
for rows.Next() {
var line string
@@ -1748,7 +1982,7 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes
}
columns, err := rows.Columns()
if err != nil {
rows.Close()
s.closeRows(rows)
return queryPageResult{}, err
}
columnTypes := columnTypeNames(rows)
@@ -1760,14 +1994,14 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes
result, err := readQuerySessionPage(session, pageSize)
result.ExecutionTimeMS = time.Since(start).Milliseconds()
if err != nil {
rows.Close()
s.closeRows(rows)
return queryPageResult{}, err
}
if result.HasMore {
sessionID := s.storeQuerySession(session)
result.SessionID = &sessionID
} else {
rows.Close()
s.closeRows(rows)
}
return result, nil
}
@@ -1814,7 +2048,7 @@ func (s *server) startTableRead(opts queryOptions, pageSize int) (queryPageResul
}
columns, err := rows.Columns()
if err != nil {
rows.Close()
s.closeRows(rows)
return queryPageResult{}, err
}
columnTypes := columnTypeNames(rows)
@@ -1826,14 +2060,14 @@ func (s *server) startTableRead(opts queryOptions, pageSize int) (queryPageResul
result, err := readQuerySessionPage(session, pageSize)
result.ExecutionTimeMS = time.Since(start).Milliseconds()
if err != nil {
rows.Close()
s.closeRows(rows)
return queryPageResult{}, err
}
if result.HasMore {
sessionID := s.storeTableReadSession(session)
result.SessionID = &sessionID
} else {
rows.Close()
s.closeRows(rows)
}
return result, nil
}
@@ -1868,7 +2102,7 @@ func (s *server) closeQuerySession(sessionID string) bool {
if session == nil {
return false
}
session.rows.Close()
s.closeRows(session.rows)
delete(s.sessions, sessionID)
return true
}
@@ -1878,7 +2112,7 @@ func (s *server) closeTableReadSession(sessionID string) bool {
if session == nil {
return false
}
session.rows.Close()
s.closeRows(session.rows)
delete(s.tableReadSessions, sessionID)
return true
}
@@ -1964,7 +2198,7 @@ func (s *server) executeSelect(sqlText string, maxRows int) (queryResult, error)
if err != nil {
return queryResult{}, err
}
defer rows.Close()
defer s.closeRows(rows)
columns, err := rows.Columns()
if err != nil {
return queryResult{}, err
@@ -2029,7 +2263,7 @@ func (s *server) queryRowsWithXMLTypeRewriteIfNeeded(sqlText string) (*sql.Rows,
}
rewritten, err := s.rewriteXMLTypeSelectSQL(sqlText)
if err != nil {
rows.Close()
s.closeRows(rows)
return nil, err
}
if rewritten == sqlText {
@@ -2037,7 +2271,7 @@ func (s *server) queryRowsWithXMLTypeRewriteIfNeeded(sqlText string) (*sql.Rows,
}
// Only pay the ALL_TAB_COLUMNS rewrite cost when the result metadata shows
// XMLTYPE. Ordinary Oracle queries should not run dictionary probes first.
rows.Close()
s.closeRows(rows)
return s.queryRows(rewritten, nil)
}
@@ -2671,10 +2905,51 @@ func (s *server) queryRows(sqlText string, args []any) (*sql.Rows, error) {
if err != nil {
return nil, err
}
if len(args) == 0 {
return db.Query(sqlText)
ctx, cancel := context.WithCancel(context.Background())
s.activeCancelMu.Lock()
s.activeCancel = cancel
s.activeCancelMu.Unlock()
rows, queryErr := db.QueryContext(ctx, sqlText, args...)
s.activeCancelMu.Lock()
s.activeCancel = nil
if queryErr != nil {
cancel()
} else {
// Keep the context alive for paged reads; database/sql may continue
// fetching from the driver until Rows is closed or exhausted.
s.activeRows[rows] = cancel
}
return db.Query(sqlText, args...)
s.activeCancelMu.Unlock()
return rows, queryErr
}
func (s *server) cancelActiveQuery() {
s.activeCancelMu.Lock()
cancels := make([]context.CancelFunc, 0, len(s.activeRows)+1)
if s.activeCancel != nil {
cancels = append(cancels, s.activeCancel)
}
for _, cancel := range s.activeRows {
cancels = append(cancels, cancel)
}
s.activeCancelMu.Unlock()
for _, cancel := range cancels {
cancel()
}
}
func (s *server) closeRows(rows *sql.Rows) error {
if rows == nil {
return nil
}
s.activeCancelMu.Lock()
cancel := s.activeRows[rows]
delete(s.activeRows, rows)
s.activeCancelMu.Unlock()
if cancel != nil {
cancel()
}
return rows.Close()
}
func decodeParams(params map[string]json.RawMessage, target any) error {
+82
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
@@ -49,6 +50,87 @@ func TestHandshakeResponse(t *testing.T) {
}
}
func TestRuntimeHandshakeAdvertisesMultiSessionProtocol(t *testing.T) {
runtime := newRuntimeServer()
resp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":7,"method":"handshake","params":{"appVersion":"dev"}}`)
if shutdown || resp.Error != nil {
t.Fatalf("unexpected handshake response: shutdown=%v error=%v", shutdown, resp.Error)
}
data, err := json.Marshal(resp.Result)
if err != nil {
t.Fatal(err)
}
var result struct {
ProtocolVersion int `json:"protocolVersion"`
AgentProtocolVersion int `json:"agentProtocolVersion"`
Capabilities []string `json:"capabilities"`
}
if err := json.Unmarshal(data, &result); err != nil {
t.Fatal(err)
}
if result.ProtocolVersion != 2 || result.AgentProtocolVersion != 2 {
t.Fatalf("unexpected protocol versions: %+v", result)
}
if !contains(result.Capabilities, "multi_session") {
t.Fatalf("expected multi_session capability, got %v", result.Capabilities)
}
}
func TestRuntimeMissingAgentSessionDoesNotUseQueryCursorSessionID(t *testing.T) {
runtime := newRuntimeServer()
resp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":8,"method":"fetch_query_page","params":{"sessionId":"cursor-1","pageSize":10}}`)
if shutdown {
t.Fatal("fetch_query_page should not shut down the runtime")
}
if resp.Error == nil || !strings.Contains(resp.Error.Message, legacyAgentSessionID) {
t.Fatalf("expected missing legacy agent session error, got %#v", resp.Error)
}
}
func TestRuntimeCloseOneSessionKeepsOtherSessionRegistered(t *testing.T) {
runtime := newRuntimeServer()
runtime.sessions["a"] = &agentSession{server: newServer()}
runtime.sessions["b"] = &agentSession{server: newServer()}
if err := runtime.closeSession("a"); err != nil {
t.Fatal(err)
}
if _, err := runtime.session("a"); err == nil {
t.Fatal("closed session should be removed")
}
if _, err := runtime.session("b"); err != nil {
t.Fatalf("other session should remain registered: %v", err)
}
}
func TestRuntimeCancelSessionOnlyCancelsTargetSession(t *testing.T) {
runtime := newRuntimeServer()
serverA := newServer()
serverB := newServer()
ctxA, cancelA := context.WithCancel(context.Background())
ctxB, cancelB := context.WithCancel(context.Background())
serverA.activeCancel = cancelA
serverB.activeCancel = cancelB
runtime.sessions["a"] = &agentSession{server: serverA}
runtime.sessions["b"] = &agentSession{server: serverB}
resp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":9,"method":"cancel_session","params":{"agentSessionId":"a"}}`)
if shutdown || resp.Error != nil {
t.Fatalf("unexpected cancel response: shutdown=%v error=%v", shutdown, resp.Error)
}
select {
case <-ctxA.Done():
default:
t.Fatal("target session was not canceled")
}
select {
case <-ctxB.Done():
t.Fatal("canceling session a should not cancel session b")
default:
}
cancelB()
}
func TestCloseMissingQuerySessionReturnsFalse(t *testing.T) {
s := newServer()
resp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":8,"method":"close_query_session","params":{"sessionId":"missing"}}`)
@@ -2,7 +2,7 @@ package com.dbx.agent.oscar;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
public final class OscarAgent extends ConfiguredJdbcAgent {
public static final JdbcAgentProfile OSCAR_PROFILE = new JdbcAgentProfile(
@@ -16,6 +16,6 @@ public final class OscarAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new OscarAgent()).run();
new MultiSessionJsonRpcServer(OscarAgent::new).run();
}
}
@@ -2,7 +2,7 @@ package com.dbx.agent.saphana;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import java.util.Arrays;
import java.util.Collections;
@@ -21,6 +21,6 @@ public final class SapHanaAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new SapHanaAgent()).run();
new MultiSessionJsonRpcServer(SapHanaAgent::new).run();
}
}
@@ -7,7 +7,7 @@ import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.MetadataSqlSupport;
import com.dbx.agent.ObjectInfo;
@@ -376,6 +376,6 @@ public final class SnowflakeAgent extends AbstractJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new SnowflakeAgent()).run();
new MultiSessionJsonRpcServer(SnowflakeAgent::new).run();
}
}
@@ -7,7 +7,7 @@ import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.TableInfo;
import com.dbx.agent.TriggerInfo;
import com.dbx.agent.ExecuteQueryOptions;
@@ -336,6 +336,6 @@ public final class SparkAgent extends AbstractJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new SparkAgent()).run();
new MultiSessionJsonRpcServer(SparkAgent::new).run();
}
}
@@ -2,16 +2,22 @@ package com.dbx.agent.sqlserverlegacy;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.DdlBuilder;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import java.security.Security;
import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
@@ -29,6 +35,10 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
"RC4",
"DES",
"MD5WITHRSA",
// Legacy SQL Server TLS 1.0 endpoints commonly rely on static RSA cipher
// suites and RSA/SHA-1 handshake signatures disabled by newer JREs.
"TLS_RSA_*",
"RSA_PKCS1_SHA1 USAGE HANDSHAKESIGNATURE",
"DH KEYSIZE < 1024",
"RSA KEYSIZE < 1024"
);
@@ -68,6 +78,83 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
}
}
@Override
public String getTableComment(String schema, String table) {
return unchecked(() -> {
try (PreparedStatement statement = requireConnection().prepareStatement(tableCommentSql())) {
statement.setString(1, schema);
statement.setString(2, table);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
String comment = resultSet.getString("table_comment");
return comment != null && !comment.trim().isEmpty() ? comment : null;
}
}
}
return null;
});
}
@Override
public String getTableDdl(String schema, String table) {
List<IndexInfo> indexes;
try {
indexes = listIndexes(schema, table);
} catch (RuntimeException error) {
indexes = Collections.emptyList();
}
List<ForeignKeyInfo> foreignKeys;
try {
foreignKeys = listForeignKeys(schema, table);
} catch (RuntimeException error) {
foreignKeys = Collections.emptyList();
}
String tableComment = null;
try {
tableComment = getTableComment(schema, table);
} catch (RuntimeException error) {
// Extended properties are optional; base DDL must remain available.
}
String ddl = DdlBuilder.buildTableDdl(
schema,
table,
getColumns(schema, table),
indexes,
foreignKeys,
Collections.emptyList(),
false,
false,
null
);
return appendTableCommentDdl(ddl, schema, table, tableComment);
}
static String tableCommentSql() {
return "SELECT CAST(ep.value AS nvarchar(max)) AS table_comment "
+ "FROM sys.extended_properties ep "
+ "JOIN sys.tables t ON t.object_id = ep.major_id "
+ "JOIN sys.schemas s ON s.schema_id = t.schema_id "
+ "WHERE ep.class = 1 AND ep.minor_id = 0 AND ep.name = N'MS_Description' "
+ "AND s.name = ? AND t.name = ?";
}
static String appendTableCommentDdl(String ddl, String schema, String table, String comment) {
if (comment == null || comment.trim().isEmpty()) {
return ddl;
}
return ddl
+ "\nEXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=" + sqlServerString(comment)
+ ", @level0type=N'SCHEMA', @level0name=" + sqlServerString(schema)
+ ", @level1type=N'TABLE', @level1name=" + sqlServerString(table) + ";";
}
private static String sqlServerString(String value) {
return "N'" + value.replace("'", "''") + "'";
}
static String legacyTlsUrl(ConnectParams params) {
Map<String, String> properties = baseConnectionProperties(params);
properties.put("encrypt", "true");
@@ -101,6 +188,9 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
+ ", jdbc=" + jdbcDriverVersion()
+ ", sslProtocol=TLSv1"
+ ", tlsV1Disabled=" + isDisabled(disabledAlgorithms, "TLSV1")
+ ", tlsRsaDisabled=" + isDisabled(disabledAlgorithms, "TLS_RSA_*")
+ ", rsaPkcs1Sha1HandshakeDisabled="
+ isDisabled(disabledAlgorithms, "RSA_PKCS1_SHA1 USAGE HANDSHAKESIGNATURE")
+ ", 3desDisabled=" + isDisabled(disabledAlgorithms, "3DES_EDE_CBC")
+ ", rc4Disabled=" + isDisabled(disabledAlgorithms, "RC4");
}
@@ -156,9 +246,10 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
}
String host = normalizedSqlServerHost(params.getHost());
boolean usesNamedInstance = usesNamedInstance(host, params.getPort(), params.isPort_explicit());
StringBuilder url = new StringBuilder("jdbc:sqlserver://")
.append(host);
if (!usesNamedInstance(host)) {
.append(usesNamedInstance ? host : serverHost(host));
if (!usesNamedInstance) {
int port = params.getPort() > 0 ? params.getPort() : PROFILE.getDefaultPort();
url.append(":").append(port);
}
@@ -183,9 +274,17 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
return server + "\\" + instance;
}
private static boolean usesNamedInstance(String host) {
private static boolean usesNamedInstance(String host, int port, boolean portExplicit) {
int separator = host.indexOf('\\');
return separator > 0 && separator < host.length() - 1;
return separator > 0 && separator < host.length() - 1 && (port <= 0 || (port == PROFILE.getDefaultPort() && !portExplicit));
}
private static String serverHost(String host) {
int separator = host.indexOf('\\');
if (separator > 0 && separator < host.length() - 1) {
return host.substring(0, separator).trim();
}
return host;
}
private static String sanitizeSqlServerUrl(String value) {
@@ -259,6 +358,6 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) throws Exception {
new JsonRpcServer(new SqlServerLegacyAgent()).run();
new MultiSessionJsonRpcServer(SqlServerLegacyAgent::new).run();
}
}
@@ -13,7 +13,10 @@ class SqlServerLegacyAgentTest {
String key = "jdk.tls.disabledAlgorithms";
String original = Security.getProperty(key);
try {
Security.setProperty(key, "TLSv1, TLSv1.1, 3DES_EDE_CBC, EC keySize < 224");
Security.setProperty(
key,
"TLSv1, TLSv1.1, TLS_RSA_*, rsa_pkcs1_sha1 usage HandshakeSignature, 3DES_EDE_CBC, EC keySize < 224"
);
new SqlServerLegacyAgent();
@@ -21,6 +24,8 @@ class SqlServerLegacyAgentTest {
String diagnostics = SqlServerLegacyAgent.legacyTlsDiagnostics();
Assertions.assertTrue(diagnostics.contains("sslProtocol=TLSv1"));
Assertions.assertTrue(diagnostics.contains("tlsV1Disabled=false"));
Assertions.assertTrue(diagnostics.contains("tlsRsaDisabled=false"));
Assertions.assertTrue(diagnostics.contains("rsaPkcs1Sha1HandshakeDisabled=false"));
Assertions.assertTrue(diagnostics.contains("3desDisabled=false"));
Assertions.assertTrue(diagnostics.contains("rc4Disabled=false"));
} finally {
@@ -79,6 +84,45 @@ class SqlServerLegacyAgentTest {
);
}
@Test
void legacyTlsUrlUsesExplicitPortInsteadOfNamedInstanceResolution() {
ConnectParams params = new ConnectParams(
"db.example.com\\SQLEXPRESS",
40030,
"appdb",
"sa",
"secret",
"applicationName=dbx",
"",
false
);
Assertions.assertEquals(
"jdbc:sqlserver://db.example.com:40030;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1",
SqlServerLegacyAgent.legacyTlsUrl(params)
);
}
@Test
void legacyTlsUrlUsesExplicitDefaultPortInsteadOfNamedInstanceResolution() {
ConnectParams params = new ConnectParams(
"db.example.com\\SQLEXPRESS",
1433,
"appdb",
"sa",
"secret",
"applicationName=dbx",
"",
false
);
params.setPort_explicit(true);
Assertions.assertEquals(
"jdbc:sqlserver://db.example.com:1433;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1",
SqlServerLegacyAgent.legacyTlsUrl(params)
);
}
@Test
void legacyTlsUrlNormalizesExplicitConnectionString() {
ConnectParams params = new ConnectParams(
@@ -101,11 +145,50 @@ class SqlServerLegacyAgentTest {
@Test
void relaxedDisabledAlgorithmsRemovesOnlyLegacyTlsEntries() {
String current =
"SSLv3, TLSv1, TLSv1.1, DTLSv1.0, RC4, DES, MD5withRSA, DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL";
"SSLv3, TLSv1, TLSv1.1, DTLSv1.0, RC4, DES, MD5withRSA, TLS_RSA_*, "
+ "rsa_pkcs1_sha1 usage HandshakeSignature, ecdsa_sha1 usage HandshakeSignature, "
+ "dsa_sha1 usage HandshakeSignature, DH keySize < 1024, EC keySize < 224, "
+ "3DES_EDE_CBC, anon, NULL";
Assertions.assertEquals(
"SSLv3, EC keySize < 224, anon, NULL",
"SSLv3, ecdsa_sha1 usage HandshakeSignature, dsa_sha1 usage HandshakeSignature, "
+ "EC keySize < 224, anon, NULL",
SqlServerLegacyAgent.relaxedDisabledAlgorithms(current)
);
}
@Test
void tableCommentQueryReadsSqlServerExtendedProperty() {
String sql = SqlServerLegacyAgent.tableCommentSql();
Assertions.assertTrue(sql.contains("sys.extended_properties"));
Assertions.assertTrue(sql.contains("ep.minor_id = 0"));
Assertions.assertTrue(sql.contains("ep.name = N'MS_Description'"));
Assertions.assertTrue(sql.contains("s.name = ? AND t.name = ?"));
}
@Test
void tableCommentDdlUsesExtendedPropertyAndPreservesWhitespace() {
String ddl = SqlServerLegacyAgent.appendTableCommentDdl(
"CREATE TABLE [dbo].[Users] ([id] int);\n",
"dbo",
"Users",
" Owner's table "
);
Assertions.assertTrue(ddl.contains("EXEC sys.sp_addextendedproperty"));
Assertions.assertTrue(ddl.contains("@value=N' Owner''s table '"));
Assertions.assertTrue(ddl.contains("@level0name=N'dbo'"));
Assertions.assertTrue(ddl.contains("@level1name=N'Users'"));
}
@Test
void tableCommentDdlIgnoresWhitespaceOnlyComment() {
String baseDdl = "CREATE TABLE [dbo].[Users] ([id] int);\n";
Assertions.assertEquals(
baseDdl,
SqlServerLegacyAgent.appendTableCommentDdl(baseDdl, "dbo", "Users", " ")
);
}
}
@@ -9,7 +9,7 @@ import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.MetadataSqlSupport;
import com.dbx.agent.ObjectInfo;
@@ -407,7 +407,7 @@ public final class SundbAgent extends BaseDatabaseAgent {
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
requireConnected(),
sql,
schema,
@@ -525,6 +525,6 @@ public final class SundbAgent extends BaseDatabaseAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new SundbAgent()).run();
new MultiSessionJsonRpcServer(SundbAgent::new).run();
}
}
@@ -8,7 +8,7 @@ import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcExecutor;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.MetadataSqlSupport;
import com.dbx.agent.ObjectInfo;
@@ -51,6 +51,8 @@ public final class TDengineAgent extends BaseDatabaseAgent {
Pattern.compile("(?i)^(decimal|numeric)\\(\\d+,\\s*(\\d+)\\)");
private static final Pattern CHARACTER_LENGTH_PATTERN =
Pattern.compile("(?i)^(binary|nchar|varchar|varbinary)\\((\\d+)\\)");
private static final Pattern COMPOSITE_KEY_PATTERN =
Pattern.compile("(?i)\\bCOMPOSITE\\s+KEY\\b");
private Connection connection;
@@ -189,7 +191,7 @@ public final class TDengineAgent extends BaseDatabaseAgent {
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
requireConnected(),
sql,
schema,
@@ -203,7 +205,7 @@ public final class TDengineAgent extends BaseDatabaseAgent {
@Override
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
return JdbcExecutor.INSTANCE.executePage(
return JdbcExecutor.current().executePage(
requireConnected(),
sql,
schema,
@@ -215,7 +217,7 @@ public final class TDengineAgent extends BaseDatabaseAgent {
@Override
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
return JdbcExecutor.INSTANCE.startTableRead(
return JdbcExecutor.current().startTableRead(
requireConnected(),
sql,
schema,
@@ -355,7 +357,7 @@ public final class TDengineAgent extends BaseDatabaseAgent {
return groupName.toLowerCase(Locale.ROOT);
}
private static List<ColumnInfo> readDescribeColumns(ResultSet rs) throws Exception {
static List<ColumnInfo> readDescribeColumns(ResultSet rs) throws Exception {
List<ColumnInfo> result = new ArrayList<>();
int ordinal = 0;
while (rs.next()) {
@@ -364,12 +366,13 @@ public final class TDengineAgent extends BaseDatabaseAgent {
String dataType = coalesce(rs.getString(2));
String note = optionalString(rs, 4);
boolean isTag = note != null && note.toUpperCase(Locale.ROOT).contains("TAG");
boolean isPrimaryKey = !isTag && (ordinal == 1 || (note != null && COMPOSITE_KEY_PATTERN.matcher(note).find()));
result.add(new ColumnInfo(
name,
dataType,
ordinal != 1,
!isPrimaryKey,
null,
ordinal == 1 && !isTag,
isPrimaryKey,
note,
isTag ? "TAG" : null,
parseNumericPrecision(dataType),
@@ -494,7 +497,7 @@ public final class TDengineAgent extends BaseDatabaseAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new TDengineAgent()).run();
new MultiSessionJsonRpcServer(TDengineAgent::new).run();
}
}
@@ -1,6 +1,6 @@
package com.dbx.agent.tdengine;
import com.dbx.agent.test.TestSupport;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.DatabaseAgent;
import com.dbx.agent.ExecuteQueryOptions;
@@ -9,6 +9,7 @@ import com.dbx.agent.TableInfo;
import com.dbx.agent.test.JdbcAgentFake;
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
import com.dbx.agent.test.JdbcMetadataSqlFake;
import com.dbx.agent.test.TestSupport;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
@@ -165,6 +166,26 @@ class TDengineAgentMetadataTest {
Assertions.assertEquals("meters", tables.get(1).getParent_name());
}
@Test
void marksTimestampAndCompositeKeyDescribeColumnsAsPrimaryKeys() throws Exception {
ResultSet resultSet = describeResultSet(new String[][] {
{"ts", "TIMESTAMP", "8", ""},
{"seq", "INT", "4", "COMPOSITE KEY"},
{"voltage", "FLOAT", "4", ""},
{"site", "VARCHAR(32)", "32", "TAG"}
});
List<ColumnInfo> columns = TDengineAgent.readDescribeColumns(resultSet);
Assertions.assertTrue(columns.get(0).getIs_primary_key());
Assertions.assertFalse(columns.get(0).getIs_nullable());
Assertions.assertTrue(columns.get(1).getIs_primary_key());
Assertions.assertFalse(columns.get(1).getIs_nullable());
Assertions.assertFalse(columns.get(2).getIs_primary_key());
Assertions.assertFalse(columns.get(3).getIs_primary_key());
Assertions.assertEquals("TAG", columns.get(3).getExtra());
}
@Test
void doesNotExposeDatabasesAsSchemas() {
TDengineAgent agent = new TDengineAgent();
@@ -302,4 +323,25 @@ class TDengineAgentMetadataTest {
Assertions.assertEquals("jdbc:TAOS-RS://127.0.0.1:6041/db?timezone=UTC#anchor", sanitized);
}
private static ResultSet describeResultSet(String[][] rows) {
int[] rowIndex = {-1};
return (ResultSet) Proxy.newProxyInstance(
ResultSet.class.getClassLoader(),
new Class<?>[] {ResultSet.class},
(proxy, method, args) -> {
if ("next".equals(method.getName())) {
rowIndex[0] += 1;
return rowIndex[0] < rows.length;
}
if ("getString".equals(method.getName())) {
return rows[rowIndex[0]][((Integer) args[0]) - 1];
}
if ("close".equals(method.getName())) {
return null;
}
throw new UnsupportedOperationException(method.getName());
}
);
}
}
@@ -2,7 +2,7 @@ package com.dbx.agent.teradata;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
public final class TeradataAgent extends ConfiguredJdbcAgent {
public static final JdbcAgentProfile TERADATA_PROFILE = new JdbcAgentProfile(
@@ -16,6 +16,6 @@ public final class TeradataAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new TeradataAgent()).run();
new MultiSessionJsonRpcServer(TeradataAgent::new).run();
}
}
@@ -7,7 +7,7 @@ import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.JdbcIdentifiers;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.MetadataSqlSupport;
import com.dbx.agent.TableInfo;
@@ -340,6 +340,6 @@ public final class TrinoAgent extends AbstractJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new TrinoAgent()).run();
new MultiSessionJsonRpcServer(TrinoAgent::new).run();
}
}
@@ -1,6 +1,6 @@
package com.dbx.agent.vastbase;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
import com.dbx.agent.ObjectSource;
import com.dbx.agent.PostgresLikeAgent;
import com.dbx.agent.PostgresLikeAgentProfile;
@@ -16,7 +16,7 @@ public final class VastbaseAgent extends PostgresLikeAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new VastbaseAgent()).run();
new MultiSessionJsonRpcServer(VastbaseAgent::new).run();
}
@Override
@@ -2,7 +2,7 @@ package com.dbx.agent.vertica;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
public final class VerticaAgent extends ConfiguredJdbcAgent {
public static final JdbcAgentProfile VERTICA_PROFILE = new JdbcAgentProfile(
@@ -16,6 +16,6 @@ public final class VerticaAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new VerticaAgent()).run();
new MultiSessionJsonRpcServer(VerticaAgent::new).run();
}
}
+513 -44
View File
@@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
@@ -13,14 +14,18 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
_ "gitee.com/XuguDB/go-xugu-driver"
)
const protocolVersion = 1
const multiSessionProtocolVersion = 2
const defaultMaxRows = 1000
const defaultXuguPort = 5138
const legacyAgentSessionID = "__legacy__"
const maxAgentSessions = 256
const xuguListDatabasesSQL = `
SELECT DB_NAME
FROM ALL_DATABASES
@@ -259,15 +264,43 @@ type triggerInfo struct {
}
type server struct {
db *sql.DB
params connectParams
sessions map[string]*querySession
nextSessionID int64
db *sql.DB
cancelDB *sql.DB
ownsCancelDB bool
params connectParams
nodeID int
databaseSessionID int64
sessions map[string]*querySession
nextSessionID int64
activeCancelMu sync.Mutex
activeCancel context.CancelFunc
activeRows map[*sql.Rows]context.CancelFunc
}
type agentSession struct {
server *server
controlKey string
mu sync.Mutex
}
type sharedControl struct {
db *sql.DB
refs int
}
type runtimeServer struct {
mu sync.RWMutex
sessions map[string]*agentSession
connectMu sync.Mutex
controlMu sync.Mutex
controls map[string]*sharedControl
}
func main() {
s := newServer()
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)
@@ -277,22 +310,260 @@ func main() {
if line == "" {
continue
}
resp, shutdown := s.handleLine(line)
if err := encoder.Encode(resp); err != nil {
fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err)
return
}
if shutdown {
var requestEnvelope request
if json.Unmarshal([]byte(line), &requestEnvelope) == nil && requestEnvelope.Method == "shutdown" {
requests.Wait()
resp, _ := runtime.handleLine(line)
encoderMu.Lock()
err := encoder.Encode(resp)
encoderMu.Unlock()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err)
}
return
}
requests.Add(1)
go func(line string) {
defer requests.Done()
resp, _ := runtime.handleLine(line)
encoderMu.Lock()
err := encoder.Encode(resp)
encoderMu.Unlock()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err)
}
}(line)
}
requests.Wait()
if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) {
fmt.Fprintf(os.Stderr, "failed to read stdin: %v\n", err)
}
}
func newServer() *server {
return &server{sessions: map[string]*querySession{}}
return &server{sessions: map[string]*querySession{}, activeRows: map[*sql.Rows]context.CancelFunc{}}
}
func newRuntimeServer() *runtimeServer {
return &runtimeServer{sessions: map[string]*agentSession{}, controls: map[string]*sharedControl{}}
}
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, 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 map[string]any{
"protocolVersion": multiSessionProtocolVersion,
"agentProtocolVersion": multiSessionProtocolVersion,
"capabilities": []string{"connect", "test_connection", "metadata", "query", "ddl", "multi_session"},
}, false, nil
case "open_session":
agentSessionID := stringParam(params, "agentSessionId")
if agentSessionID == "" {
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(agentSessionID, cp)
case "close_session":
return map[string]bool{"ok": true}, false, r.closeSession(stringParam(params, "agentSessionId"))
case "validate_session":
agentSessionID := stringParam(params, "agentSessionId")
session, err := r.session(agentSessionID)
if err != nil {
return nil, false, err
}
session.mu.Lock()
defer session.mu.Unlock()
if err := session.server.validateConnection(); err == nil {
return map[string]bool{"ok": true}, false, nil
}
r.connectMu.Lock()
err = session.server.connectWithControl(session.server.params, session.server.cancelDB, false)
r.connectMu.Unlock()
return map[string]bool{"ok": true}, false, err
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":
return newServer().dispatch(method, params)
case "shutdown":
return map[string]bool{"ok": true}, true, r.closeAllSessions()
case "connect":
var cp connectParams
if err := decodeParams(params, &cp); err != nil {
return nil, false, err
}
return map[string]bool{"ok": true}, false, r.replaceSession(legacyAgentSessionID, cp)
case "disconnect":
return map[string]bool{"ok": true}, false, r.closeSession(legacyAgentSessionID)
default:
agentSessionID := stringParam(params, "agentSessionId")
if agentSessionID == "" {
agentSessionID = legacyAgentSessionID
}
return r.withSession(agentSessionID, method, params)
}
}
func (r *runtimeServer) withSession(agentSessionID, method string, params map[string]json.RawMessage) (any, bool, error) {
session, err := r.session(agentSessionID)
if err != nil {
return nil, false, err
}
// Database, schema, transaction, and cursor state are connection-scoped.
session.mu.Lock()
defer session.mu.Unlock()
return session.server.dispatch(method, params)
}
func (r *runtimeServer) openSession(agentSessionID string, params connectParams) error {
r.mu.Lock()
if _, exists := r.sessions[agentSessionID]; exists {
r.mu.Unlock()
return fmt.Errorf("agent session already exists: %s", agentSessionID)
}
if len(r.sessions) >= maxAgentSessions {
r.mu.Unlock()
return fmt.Errorf("agent session limit reached: %d", maxAgentSessions)
}
r.mu.Unlock()
server := newServer()
params.URLParams = appendURLParam(params.URLParams, "APP_NAME", xuguSessionAppName(agentSessionID))
r.connectMu.Lock()
controlKey, controlDB, err := r.acquireControl(params)
if err == nil {
err = server.connectWithControl(params, controlDB, false)
}
r.connectMu.Unlock()
if err != nil {
if controlKey != "" {
r.releaseControl(controlKey)
}
return err
}
session := &agentSession{server: server, controlKey: controlKey}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.sessions[agentSessionID]; exists {
_ = server.disconnect()
r.releaseControl(controlKey)
return fmt.Errorf("agent session already exists: %s", agentSessionID)
}
if len(r.sessions) >= maxAgentSessions {
_ = server.disconnect()
r.releaseControl(controlKey)
return fmt.Errorf("agent session limit reached: %d", maxAgentSessions)
}
r.sessions[agentSessionID] = session
return nil
}
func (r *runtimeServer) replaceSession(agentSessionID string, params connectParams) error {
_ = r.closeSession(agentSessionID)
return r.openSession(agentSessionID, params)
}
func (r *runtimeServer) session(agentSessionID string) (*agentSession, error) {
r.mu.RLock()
session := r.sessions[agentSessionID]
r.mu.RUnlock()
if session == nil {
return nil, fmt.Errorf("agent session not found: %s", agentSessionID)
}
return session, nil
}
func (r *runtimeServer) closeSession(agentSessionID string) error {
r.mu.Lock()
session := r.sessions[agentSessionID]
delete(r.sessions, agentSessionID)
r.mu.Unlock()
if session == nil {
return nil
}
session.mu.Lock()
defer session.mu.Unlock()
err := session.server.disconnect()
r.releaseControl(session.controlKey)
return err
}
func (r *runtimeServer) acquireControl(params connectParams) (string, *sql.DB, error) {
r.controlMu.Lock()
defer r.controlMu.Unlock()
cancelParams := xuguControlParams(params)
key := buildDSN(cancelParams)
if control := r.controls[key]; control != nil {
control.refs++
return key, control.db, nil
}
db, err := openDB(cancelParams)
if err != nil {
return "", nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return "", nil, err
}
r.controls[key] = &sharedControl{db: db, refs: 1}
return key, db, nil
}
func (r *runtimeServer) releaseControl(key string) {
if key == "" {
return
}
r.controlMu.Lock()
defer r.controlMu.Unlock()
control := r.controls[key]
if control == nil {
return
}
control.refs--
if control.refs <= 0 {
_ = control.db.Close()
delete(r.controls, key)
}
}
func (r *runtimeServer) closeAllSessions() error {
r.mu.Lock()
ids := make([]string, 0, len(r.sessions))
for id := range r.sessions {
ids = append(ids, id)
}
r.mu.Unlock()
var firstErr error
for _, id := range ids {
if err := r.closeSession(id); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (s *server) handleLine(line string) (response, bool) {
@@ -340,6 +611,8 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
return nil, false, err
}
return map[string]bool{"ok": true}, false, nil
case "validate_connection":
return map[string]bool{"ok": true}, false, s.validateConnection()
case "list_databases":
result, err := s.listDatabases()
return result, false, err
@@ -409,6 +682,18 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
return result, false, err
case "close_query_session":
return s.closeQuerySession(stringParam(params, "sessionId")), false, nil
case "start_table_read":
var opts queryOptions
if err := decodeParams(params, &opts); err != nil {
return nil, false, err
}
result, err := s.executeQueryPage(opts, intParam(params, "pageSize"))
return result, false, err
case "fetch_table_read_page":
result, err := s.fetchQueryPage(stringParam(params, "sessionId"), intParam(params, "pageSize"))
return result, false, err
case "close_table_read_session":
return s.closeQuerySession(stringParam(params, "sessionId")), false, nil
case "list_indexes":
if err := s.useDatabase(stringParam(params, "database")); err != nil {
return nil, false, err
@@ -451,44 +736,162 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
}
func (s *server) connect(params connectParams) error {
_ = s.disconnect()
db, err := openDB(params)
cancelParams := xuguControlParams(params)
cancelDB, err := openDB(cancelParams)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := cancelDB.PingContext(ctx); err != nil {
cancelDB.Close()
return err
}
if err := s.connectWithControl(params, cancelDB, true); err != nil {
cancelDB.Close()
return err
}
return nil
}
func (s *server) connectWithControl(params connectParams, cancelDB *sql.DB, ownsCancelDB bool) error {
_ = s.disconnect()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
before, err := xuguDatabaseSessions(cancelDB)
if err != nil {
return err
}
db, err := openDB(params)
if err != nil {
return err
}
if err := db.PingContext(ctx); err != nil {
db.Close()
return err
}
after, err := xuguDatabaseSessions(cancelDB)
if err != nil {
db.Close()
return err
}
databaseSession, err := newXuguDatabaseSession(before, after)
if err != nil {
db.Close()
return err
}
s.db = db
s.cancelDB = cancelDB
s.ownsCancelDB = ownsCancelDB
s.params = params
s.nodeID = databaseSession.nodeID
s.databaseSessionID = databaseSession.sessionID
return nil
}
func (s *server) disconnect() error {
s.cancelActiveQuery()
s.closeAllQuerySessions()
if s.ownsCancelDB && s.cancelDB != nil {
_ = s.cancelDB.Close()
}
if s.db == nil {
return nil
}
err := s.db.Close()
s.db = nil
s.cancelDB = nil
s.ownsCancelDB = false
s.nodeID = 0
s.databaseSessionID = 0
return err
}
func (s *server) validateConnection() error {
if s.db == nil {
return errors.New("not connected")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return s.db.PingContext(ctx)
}
func openDB(params connectParams) (*sql.DB, error) {
dsn := buildDSN(params)
db, err := sql.Open("xugu", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(4)
// One logical Agent session must map to exactly one server-side session so
// schema, transaction, cursor, and cancellation state stay deterministic.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(30 * time.Minute)
return db, nil
}
func appendURLParam(raw, key, value string) string {
parts := make([]string, 0, 2)
for _, part := range strings.Split(raw, ";") {
part = strings.TrimSpace(part)
if part != "" && !strings.HasPrefix(strings.ToUpper(part), strings.ToUpper(key)+"=") {
parts = append(parts, part)
}
}
parts = append(parts, key+"="+value)
return strings.Join(parts, ";")
}
func xuguSessionAppName(agentSessionID string) string {
digest := sha256.Sum256([]byte(agentSessionID))
return fmt.Sprintf("DBX_%x", digest[:8])
}
func xuguControlParams(params connectParams) connectParams {
params.Database = "SYSTEM"
params.ConnectionString = ""
params.URLParams = appendURLParam(params.URLParams, "APP_NAME", "DBX_CONTROL")
return params
}
type xuguDatabaseSession struct {
nodeID int
sessionID int64
}
func xuguDatabaseSessions(db *sql.DB) (map[xuguDatabaseSession]struct{}, error) {
rows, err := db.Query("SELECT NODEID, SESSION_ID FROM SYS_SESSIONS")
if err != nil {
return nil, err
}
defer rows.Close()
result := map[xuguDatabaseSession]struct{}{}
for rows.Next() {
var session xuguDatabaseSession
if err := rows.Scan(&session.nodeID, &session.sessionID); err != nil {
return nil, err
}
result[session] = struct{}{}
}
return result, rows.Err()
}
func newXuguDatabaseSession(
before map[xuguDatabaseSession]struct{},
after map[xuguDatabaseSession]struct{},
) (xuguDatabaseSession, error) {
var candidates []xuguDatabaseSession
for session := range after {
if _, existed := before[session]; !existed {
candidates = append(candidates, session)
}
}
if len(candidates) != 1 {
return xuguDatabaseSession{}, fmt.Errorf("failed to identify Xugu server session: found %d new sessions", len(candidates))
}
return candidates[0], nil
}
func buildDSN(params connectParams) string {
connectionString := strings.TrimSpace(params.ConnectionString)
if looksLikeXuguDSN(connectionString) {
@@ -684,7 +1087,7 @@ func (s *server) listDatabases() ([]databaseInfo, error) {
}
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []databaseInfo
for rows.Next() {
var name string
@@ -759,7 +1162,7 @@ func (s *server) listSchemas() ([]string, error) {
}
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []string
for rows.Next() {
var schema string
@@ -784,7 +1187,7 @@ ORDER BY CASE WHEN UPPER(s.SCHEMA_NAME) = UPPER(?) THEN 0 ELSE 1 END, s.SCHEMA_N
}
return "", err
}
defer rows.Close()
defer s.closeRows(rows)
if rows.Next() {
var schema string
if err := rows.Scan(&schema); err != nil {
@@ -816,7 +1219,7 @@ func (s *server) listTables(schema string, constraints metadataListConstraints)
if err != nil {
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []tableInfo
for rows.Next() {
var item tableInfo
@@ -838,7 +1241,7 @@ func (s *server) listObjects(schema string, constraints metadataListConstraints)
if err != nil {
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []objectInfo
for rows.Next() {
var item objectInfo
@@ -1021,7 +1424,7 @@ func (s *server) getColumns(schema, table string) ([]columnInfo, error) {
}
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []columnInfo
for rows.Next() {
var item columnInfo
@@ -1056,7 +1459,7 @@ func (s *server) columnsFromSelect(schema, table string, primaryKeys map[string]
if err != nil {
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
types, err := rows.ColumnTypes()
if err != nil {
return nil, err
@@ -1090,7 +1493,7 @@ func (s *server) primaryKeyColumns(schema, table string) (map[string]bool, error
}
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
result := map[string]bool{}
for rows.Next() {
var define string
@@ -1117,7 +1520,7 @@ func (s *server) listIndexes(schema, table string) ([]indexInfo, error) {
}
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []indexInfo
for rows.Next() {
@@ -1160,7 +1563,7 @@ ORDER BY c.CONS_NAME`, []any{schema, table})
}
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []foreignKeyInfo
for rows.Next() {
var name, define, refTable string
@@ -1199,7 +1602,7 @@ ORDER BY tr.TRIG_NAME`, []any{schema, table})
}
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var result []triggerInfo
for rows.Next() {
var item triggerInfo
@@ -1228,7 +1631,7 @@ func (s *server) getObjectSource(schema, name, objectType string) (map[string]an
if err != nil {
return nil, err
}
defer rows.Close()
defer s.closeRows(rows)
var builder strings.Builder
for rows.Next() {
var line string
@@ -1249,7 +1652,7 @@ func (s *server) getTableDDL(schema, table string) (string, error) {
var ddl string
rows, err := s.queryRows("SELECT TO_CHAR(DBMS_METADATA.GET_DDL('TABLE', ?, ?)) FROM DUAL", []any{strings.ToUpper(table), schema})
if err == nil {
defer rows.Close()
defer s.closeRows(rows)
if rows.Next() {
if scanErr := rows.Scan(&ddl); scanErr == nil && strings.TrimSpace(ddl) != "" {
if err := rows.Err(); err != nil {
@@ -1274,7 +1677,7 @@ func (s *server) getExplainInfo(sqlText string) (string, error) {
if err != nil {
return "", err
}
defer rows.Close()
defer s.closeRows(rows)
columns, err := rows.Columns()
if err != nil {
return "", err
@@ -1303,12 +1706,14 @@ func (s *server) executeTransaction(params map[string]json.RawMessage) (queryRes
if err != nil {
return queryResult{}, err
}
tx, err := db.Begin()
ctx, cancel := s.beginActiveOperation()
defer s.endActiveOperation(cancel)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return queryResult{}, err
}
if strings.TrimSpace(payload.Schema) != "" {
if _, err := tx.Exec("SET SCHEMA " + quoteIdentifier(payload.Schema)); err != nil {
if _, err := tx.ExecContext(ctx, "SET SCHEMA "+quoteIdentifier(payload.Schema)); err != nil {
tx.Rollback()
return queryResult{}, err
}
@@ -1320,7 +1725,7 @@ func (s *server) executeTransaction(params map[string]json.RawMessage) (queryRes
if statement == "" {
continue
}
result, err := tx.Exec(statement)
result, err := tx.ExecContext(ctx, statement)
if err != nil {
tx.Rollback()
return queryResult{}, err
@@ -1370,7 +1775,7 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes
}
columns, err := rows.Columns()
if err != nil {
rows.Close()
s.closeRows(rows)
return queryPageResult{}, err
}
columnTypes := columnTypeNames(rows)
@@ -1382,14 +1787,14 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes
result, err := readQuerySessionPage(session, pageSize)
result.ExecutionTimeMS = time.Since(start).Milliseconds()
if err != nil {
rows.Close()
s.closeRows(rows)
return queryPageResult{}, err
}
if result.HasMore {
sessionID := s.storeQuerySession(session)
result.SessionID = &sessionID
} else {
rows.Close()
s.closeRows(rows)
}
return result, nil
}
@@ -1424,7 +1829,7 @@ func (s *server) closeQuerySession(sessionID string) bool {
if session == nil {
return false
}
session.rows.Close()
s.closeRows(session.rows)
delete(s.sessions, sessionID)
return true
}
@@ -1497,7 +1902,9 @@ func (s *server) executeQuery(opts queryOptions) (queryResult, error) {
if err != nil {
return queryResult{}, err
}
execResult, err := db.Exec(sqlText)
ctx, cancel := s.beginActiveOperation()
defer s.endActiveOperation(cancel)
execResult, err := db.ExecContext(ctx, sqlText)
if err != nil {
return queryResult{}, err
}
@@ -1510,7 +1917,7 @@ func (s *server) executeSelect(sqlText string, maxRows int) (queryResult, error)
if err != nil {
return queryResult{}, err
}
defer rows.Close()
defer s.closeRows(rows)
columns, err := rows.Columns()
if err != nil {
return queryResult{}, err
@@ -1562,7 +1969,9 @@ func (s *server) setSchema(schema string) error {
if err != nil {
return err
}
_, err = db.Exec("SET SCHEMA " + quoteIdentifier(schema))
ctx, cancel := s.beginActiveOperation()
defer s.endActiveOperation(cancel)
_, err = db.ExecContext(ctx, "SET SCHEMA "+quoteIdentifier(schema))
return err
}
@@ -1571,10 +1980,70 @@ func (s *server) queryRows(sqlText string, args []any) (*sql.Rows, error) {
if err != nil {
return nil, err
}
if len(args) == 0 {
return db.Query(sqlText)
ctx, cancel := s.beginActiveOperation()
rows, queryErr := db.QueryContext(ctx, sqlText, args...)
s.activeCancelMu.Lock()
s.activeCancel = nil
if queryErr != nil {
cancel()
} else {
// Paged queries may continue fetching after QueryContext returns.
s.activeRows[rows] = cancel
}
return db.Query(sqlText, args...)
s.activeCancelMu.Unlock()
return rows, queryErr
}
func (s *server) beginActiveOperation() (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
s.activeCancelMu.Lock()
s.activeCancel = cancel
s.activeCancelMu.Unlock()
return ctx, cancel
}
func (s *server) endActiveOperation(cancel context.CancelFunc) {
cancel()
s.activeCancelMu.Lock()
s.activeCancel = nil
s.activeCancelMu.Unlock()
}
func (s *server) cancelActiveQuery() {
s.activeCancelMu.Lock()
cancels := make([]context.CancelFunc, 0, len(s.activeRows)+1)
if s.activeCancel != nil {
cancels = append(cancels, s.activeCancel)
}
for _, cancel := range s.activeRows {
cancels = append(cancels, cancel)
}
s.activeCancelMu.Unlock()
for _, cancel := range cancels {
cancel()
}
if len(cancels) > 0 && s.cancelDB != nil && s.databaseSessionID > 0 {
// go-xugu-driver does not implement QueryerContext/ExecerContext and
// blocks in network reads, so context cancellation alone cannot interrupt
// an in-flight statement. Xugu's control procedure stops the target
// session's current transaction while preserving the connection. Runtime
// sessions share one control connection per database endpoint.
_, _ = s.cancelDB.Exec(fmt.Sprintf("CALL DBMS_DBA.KILL_SESSION_TRANS(%d, %d)", s.nodeID, s.databaseSessionID))
}
}
func (s *server) closeRows(rows *sql.Rows) error {
if rows == nil {
return nil
}
s.activeCancelMu.Lock()
cancel := s.activeRows[rows]
delete(s.activeRows, rows)
s.activeCancelMu.Unlock()
if cancel != nil {
cancel()
}
return rows.Close()
}
func objectSourceQuery(schema, name, objectType string) (string, []any, error) {
@@ -1718,7 +2187,7 @@ func (s *server) tableIndexDDL(schema, table string) (string, error) {
if err != nil {
return "", err
}
defer rows.Close()
defer s.closeRows(rows)
var ddl string
if rows.Next() {
if err := rows.Scan(&ddl); err != nil {
@@ -1741,7 +2210,7 @@ WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)
if err != nil {
return "", err
}
defer rows.Close()
defer s.closeRows(rows)
var comment *string
if rows.Next() {
if err := rows.Scan(&comment); err != nil {
+115
View File
@@ -1,8 +1,10 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"testing"
@@ -46,6 +48,119 @@ func TestHandshakeResponse(t *testing.T) {
}
}
func TestRuntimeHandshakeAdvertisesMultiSessionProtocol(t *testing.T) {
runtime := newRuntimeServer()
resp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":7,"method":"handshake","params":{}}`)
if shutdown || resp.Error != nil {
t.Fatalf("unexpected handshake response: shutdown=%v error=%v", shutdown, resp.Error)
}
data, err := json.Marshal(resp.Result)
if err != nil {
t.Fatal(err)
}
var result struct {
ProtocolVersion int `json:"protocolVersion"`
Capabilities []string `json:"capabilities"`
}
if err := json.Unmarshal(data, &result); err != nil {
t.Fatal(err)
}
if result.ProtocolVersion != multiSessionProtocolVersion || !contains(result.Capabilities, "multi_session") {
t.Fatalf("unexpected runtime handshake: %+v", result)
}
}
func TestRuntimeMissingAgentSessionDoesNotUseQueryCursorSessionID(t *testing.T) {
runtime := newRuntimeServer()
resp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":8,"method":"fetch_query_page","params":{"sessionId":"cursor-1"}}`)
if shutdown {
t.Fatal("fetch_query_page should not shut down the runtime")
}
if resp.Error == nil || !strings.Contains(resp.Error.Message, legacyAgentSessionID) {
t.Fatalf("expected missing legacy agent session error, got %#v", resp.Error)
}
}
func TestRuntimeCloseOneSessionKeepsOtherSessionRegistered(t *testing.T) {
runtime := newRuntimeServer()
runtime.sessions["a"] = &agentSession{server: newServer()}
runtime.sessions["b"] = &agentSession{server: newServer()}
if err := runtime.closeSession("a"); err != nil {
t.Fatal(err)
}
if _, err := runtime.session("a"); err == nil {
t.Fatal("closed session should be removed")
}
if _, err := runtime.session("b"); err != nil {
t.Fatalf("other session should remain registered: %v", err)
}
}
func TestRuntimeCancelSessionOnlyCancelsTargetSession(t *testing.T) {
runtime := newRuntimeServer()
serverA := newServer()
serverB := newServer()
ctxA, cancelA := context.WithCancel(context.Background())
ctxB, cancelB := context.WithCancel(context.Background())
serverA.activeCancel = cancelA
serverB.activeCancel = cancelB
runtime.sessions["a"] = &agentSession{server: serverA}
runtime.sessions["b"] = &agentSession{server: serverB}
resp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":9,"method":"cancel_session","params":{"agentSessionId":"a"}}`)
if shutdown || resp.Error != nil {
t.Fatalf("unexpected cancel response: shutdown=%v error=%v", shutdown, resp.Error)
}
select {
case <-ctxA.Done():
default:
t.Fatal("target session was not canceled")
}
select {
case <-ctxB.Done():
t.Fatal("canceling session a should not cancel session b")
default:
}
cancelB()
}
func TestRuntimeRejectsSessionsBeyondLimit(t *testing.T) {
runtime := newRuntimeServer()
for index := 0; index < maxAgentSessions; index++ {
runtime.sessions[fmt.Sprintf("session-%d", index)] = &agentSession{server: newServer()}
}
err := runtime.openSession("overflow", connectParams{})
if err == nil || !strings.Contains(err.Error(), "session limit") {
t.Fatalf("expected session limit error, got %v", err)
}
}
func TestNewXuguDatabaseSessionFindsOnlyNewSession(t *testing.T) {
existing := xuguDatabaseSession{nodeID: 1, sessionID: 10}
created := xuguDatabaseSession{nodeID: 1, sessionID: 11}
result, err := newXuguDatabaseSession(
map[xuguDatabaseSession]struct{}{existing: {}},
map[xuguDatabaseSession]struct{}{existing: {}, created: {}},
)
if err != nil {
t.Fatal(err)
}
if result != created {
t.Fatalf("unexpected session: %+v", result)
}
}
func TestXuguSessionAppNameIsStableAndDoesNotExposeSessionID(t *testing.T) {
name := xuguSessionAppName("tab-session-secret")
if name != xuguSessionAppName("tab-session-secret") {
t.Fatal("app name should be stable")
}
if strings.Contains(name, "tab-session-secret") || !strings.HasPrefix(name, "DBX_") {
t.Fatalf("unexpected app name: %s", name)
}
}
func TestCloseMissingQuerySessionReturnsFalse(t *testing.T) {
s := newServer()
resp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":8,"method":"close_query_session","params":{"sessionId":"missing"}}`)
@@ -2,7 +2,7 @@ package com.dbx.agent.yashandb;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.MultiSessionJsonRpcServer;
public final class YashandbAgent extends ConfiguredJdbcAgent {
public static final JdbcAgentProfile YASHANDB_PROFILE = new JdbcAgentProfile(
@@ -17,6 +17,6 @@ public final class YashandbAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
new JsonRpcServer(new YashandbAgent()).run();
new MultiSessionJsonRpcServer(YashandbAgent::new).run();
}
}
@@ -386,6 +386,11 @@ public final class ZooKeeperAgent {
private static Map<String, Object> valueObject(byte[] bytes) {
Map<String, Object> value = new LinkedHashMap<>();
// ZooKeeper represents znodes created without a data payload as null.
// Expose them as empty UTF-8 values instead of passing null to decoders.
if (bytes == null) {
bytes = new byte[0];
}
String utf8 = strictUtf8(bytes);
if (utf8 != null) {
value.put("encoding", "utf8");
@@ -4,7 +4,12 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.curator.test.TestingServer;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.ZooDefs;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -175,6 +180,31 @@ final class ZooKeeperAgentTest {
}
}
@Test
void kvGetReadsZnodeCreatedWithoutDataAsEmptyUtf8() throws Exception {
try (TestingServer server = new TestingServer();
CuratorFramework curator = CuratorFrameworkFactory.newClient(
server.getConnectString(),
new ExponentialBackoffRetry(100, 3)
)) {
curator.start();
curator.getZookeeperClient().getZooKeeper().create(
"/empty",
null,
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.PERSISTENT
);
connect(server);
JsonObject get = result(request(3, "kv_get", "{\"key\":\"/empty\"}"));
Assertions.assertTrue(get.get("found").getAsBoolean());
Assertions.assertEquals("utf8", get.getAsJsonObject("value").get("encoding").getAsString());
Assertions.assertEquals("", get.getAsJsonObject("value").get("data").getAsString());
Assertions.assertEquals(0, get.getAsJsonObject("metadata").get("valueSize").getAsInt());
}
}
@Test
void kvPutUpdatesExistingZnode() throws Exception {
try (TestingServer server = new TestingServer()) {
-10
View File
@@ -1,10 +0,0 @@
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.opengauss:opengauss-jdbc:7.0.0-RC3-og'
}
tasks.named('shadowJar') {
manifest {
attributes('Agent-Label': 'openGauss', 'Main-Class': 'com.dbx.agent.opengauss.OpenGaussAgent')
}
}
@@ -1,20 +0,0 @@
package com.dbx.agent.opengauss;
import com.dbx.agent.JsonRpcServer;
import com.dbx.agent.PostgresLikeAgent;
import com.dbx.agent.PostgresLikeAgentProfile;
public final class OpenGaussAgent extends PostgresLikeAgent {
public static final PostgresLikeAgentProfile OPENGAUSS_PROFILE = new PostgresLikeAgentProfile(
"org.opengauss.Driver",
"jdbc:opengauss://{host}:{port}/{database}"
);
public OpenGaussAgent() {
super(OPENGAUSS_PROFILE);
}
public static void main(String[] args) {
new JsonRpcServer(new OpenGaussAgent()).run();
}
}
@@ -1,21 +0,0 @@
package com.dbx.agent.opengauss;
import com.dbx.agent.ConnectParams;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class OpenGaussAgentTest {
@Test
void buildsOpenGaussUrl() {
ConnectParams params = new ConnectParams();
params.setHost("db.example.com");
params.setPort(15432);
params.setDatabase("postgres");
assertEquals(
"jdbc:opengauss://db.example.com:15432/postgres",
OpenGaussAgent.OPENGAUSS_PROFILE.buildUrl(params)
);
}
}
+9
View File
@@ -17,6 +17,7 @@ NATIVE_ONLY_AGENT_MODULES = {
"xugu": "drivers/xugu",
}
JDBC_ARCHITECTURE_ALLOWLIST = {
"h2-legacy": "reuses the H2 agent implementation with an isolated legacy driver version",
"access": "custom Access metadata and URL behavior pending migration",
"dameng": "custom Dameng metadata and DDL pending migration",
"db2": "custom DB2 metadata pending migration",
@@ -235,6 +236,14 @@ def validate_release_runtime_keys(root: Path) -> list[str]:
rf'"{DEFAULT_AGENT_JRE_KEY}":\s*\{{\s*"version":\s*"{DEFAULT_AGENT_JRE_KEY}\.',
f"registry must publish Java {DEFAULT_AGENT_JRE_KEY} under JRE key {DEFAULT_AGENT_JRE_KEY}",
),
(
r"jdk\.security\.auth",
"release workflow JRE must include jdk.security.auth for Kafka Kerberos LoginModule support",
),
(
r"jdk\.security\.jgss",
"release workflow JRE must include jdk.security.jgss for Kafka GSSAPI SASL support",
),
(
r"legacy-placeholder\.jar",
"native-only registry entries must publish a legacy jar placeholder for older DBX clients",
+3
View File
@@ -318,6 +318,7 @@ class ValidateAgentsTest(unittest.TestCase):
include:
- jre-key: "21"
java-version: "21"
modules: "java.base,jdk.security.auth,jdk.security.jgss"
detect_jre_key() {
case "$name" in
*) echo "21" ;;
@@ -372,6 +373,8 @@ class ValidateAgentsTest(unittest.TestCase):
"release workflow must build the default JRE with key 21",
"agents must use JRE key 21",
"registry must publish Java 21 under JRE key 21",
"release workflow JRE must include jdk.security.auth for Kafka Kerberos LoginModule support",
"release workflow JRE must include jdk.security.jgss for Kafka GSSAPI SASL support",
"native-only registry entries must publish a legacy jar placeholder for older DBX clients",
"release workflow must not build Java 21 under JRE key 17",
"agents must not use JRE key 17",
+1 -1
View File
@@ -4,7 +4,7 @@ def infrastructureModules = ['common', 'test-support']
def driverModules = [
'access', 'dameng', 'kingbase', 'vastbase', 'goldendb', 'databend', 'databricks', 'saphana',
'teradata', 'vertica', 'firebird', 'exasol', 'oceanbase-oracle', 'gbase8a', 'gbase8s',
'bigquery', 'kylin', 'sundb', 'h2', 'snowflake', 'trino', 'hive', 'spark',
'bigquery', 'kylin', 'sundb', 'h2', 'h2-legacy', 'snowflake', 'trino', 'hive', 'spark',
'db2', 'informix', 'neo4j', 'cassandra', 'mongodb', 'highgo', 'tdengine', 'yashandb', 'oscar',
'iris', 'iotdb', 'etcd', 'zookeeper', 'kafka', 'sqlserver-legacy'
]
+7 -6
View File
@@ -11,11 +11,12 @@
"gbase8a": "0.1.25",
"gbase8s": "0.1.19",
"goldendb": "0.1.30",
"h2": "0.1.30",
"h2": "0.1.31",
"h2-legacy": "0.1.0",
"hive": "0.1.30",
"spark": "0.1.1",
"spark": "0.1.2",
"informix": "0.1.31",
"kingbase": "0.1.30",
"kingbase": "0.1.31",
"kylin": "0.1.30",
"neo4j": "0.1.30",
"oceanbase-oracle": "0.1.25",
@@ -24,11 +25,11 @@
"saphana": "0.1.25",
"sundb": "0.1.30",
"teradata": "0.1.25",
"tdengine": "0.1.28",
"tdengine": "0.1.29",
"trino": "0.1.30",
"vastbase": "0.1.30",
"vertica": "0.1.25",
"mongodb": "0.1.29",
"mongodb": "0.1.31",
"highgo": "0.1.28",
"yashandb": "0.1.26",
"oscar": "0.1.5",
@@ -38,5 +39,5 @@
"etcd": "0.1.18",
"zookeeper": "0.1.8",
"kafka": "0.1.3",
"sqlserver-legacy": "0.1.0"
"sqlserver-legacy": "0.1.1"
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="54 3 50 23">
<path fill="#f48120" d="M88.1 24c.3-1 .2-2-.3-2.6-.5-.6-1.2-1-2.1-1.1l-17.4-.2c-.1 0-.2-.1-.3-.1-.1-.1-.1-.2 0-.3.1-.2.2-.3.4-.3l17.5-.2c2.1-.1 4.3-1.8 5.1-3.8l1-2.6c0-.1.1-.2 0-.3-1.1-5.1-5.7-8.9-11.1-8.9-5 0-9.3 3.2-10.8 7.7-1-.7-2.2-1.1-3.6-1-2.4.2-4.3 2.2-4.6 4.6-.1.6 0 1.2.1 1.8-3.9.1-7.1 3.3-7.1 7.3 0 .4 0 .7.1 1.1 0 .2.2.3.3.3h32.1c.2 0 .4-.1.4-.3l.3-1.1z" />
<path fill="#faad3f" d="M93.6 12.8h-.5c-.1 0-.2.1-.3.2l-.7 2.4c-.3 1-.2 2 .3 2.6.5.6 1.2 1 2.1 1.1l3.7.2c.1 0 .2.1.3.1.1.1.1.2 0 .3-.1.2-.2.3-.4.3l-3.8.2c-2.1.1-4.3 1.8-5.1 3.8l-.2.9c-.1.1 0 .3.2.3h13.2c.2 0 .3-.1.3-.3.2-.8.4-1.7.4-2.6 0-5.2-4.3-9.5-9.5-9.5" />
</svg>

After

Width:  |  Height:  |  Size: 704 B

+112 -22
View File
@@ -38,7 +38,7 @@ import * as api from "@/lib/backend/api";
import { connectionRedactedNameLabel } from "@/lib/connection/connectionPresentation";
import { quickConnectionOpenTarget } from "@/lib/connection/connectionOpenTarget";
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { findTreeNodeById, resolveNewQueryTarget } from "@/lib/sql/newQueryContext";
import { findTreeNodeById, resolveNewQueryTarget, resolveNewQueryInitialSql } from "@/lib/sql/newQueryContext";
import { buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
import { resolveExecutableSql, resolveExecutableSqlWithBackend, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import { uuid } from "@/lib/common/utils";
@@ -73,6 +73,8 @@ import { isPreviewTab } from "@/lib/tabs/tabPresentation";
import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities";
import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy";
import { buildAppendedEditorSql } from "@/lib/ai/aiSqlAppend";
import { assessProductionSql } from "@/lib/database/productionSafety";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis";
import { countAvailableAgentDriverUpdates, type AgentDriverUpdateBadgeState } from "@/lib/connection/agentDriverUpdateBadge";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
@@ -239,16 +241,32 @@ function promptActiveDatabaseSelection() {
toast(t("editor.selectDatabaseRequired"), 2500);
}
const { dangerSql, pendingDangerSql, showDangerDialog, suppressDangerConfirm, tryExecute, doExecute, cancelActiveExecution, tryExplain, onDangerConfirm, showSqlParameterDialog, sqlParameterSourceSql, sqlParameterNames, sqlParameterDatabaseType, onSqlParametersConfirm, explainMode } =
useSqlExecution({
activeTab,
activeConnection,
executableSql,
resolveExecutableSql: resolveActiveExecutableSql,
activeOutputView,
blockDangerousRedisCommands,
onMissingDatabase: promptActiveDatabaseSelection,
});
const {
dangerSql,
pendingDangerSql,
showDangerDialog,
suppressDangerConfirm,
tryExecute,
doExecute,
cancelActiveExecution,
tryExplain,
onDangerConfirm,
showSqlParameterDialog,
sqlParameterSourceSql,
sqlParameterNames,
sqlParameterDatabaseType,
sqlParameterEnabledSyntaxes,
onSqlParametersConfirm,
explainMode,
} = useSqlExecution({
activeTab,
activeConnection,
executableSql,
resolveExecutableSql: resolveActiveExecutableSql,
activeOutputView,
blockDangerousRedisCommands,
onMissingDatabase: promptActiveDatabaseSelection,
});
function requestActiveEditorExecute() {
if (contentAreaRef.value?.requestQueryEditorExecute?.()) return;
@@ -311,9 +329,12 @@ function closeDriverStorePage() {
const toolbarAgentDriverUpdateCount = computed(() => (updateNotificationsEnabled.value ? agentDriverUpdateCount.value : 0));
const toolbarHasUpdateAvailable = computed(() => updateNotificationsEnabled.value && hasUpdateAvailable.value);
const hasSqlFileConnections = computed(() => connectionStore.connections.some((c) => supportsSqlFileExecution(c.db_type)));
const queryEditorDdlDatabaseType = computed(() => {
if (!queryEditorDdlTarget.value?.connectionId) return undefined;
return effectiveDatabaseTypeForConnection(connectionStore.getConfig(queryEditorDdlTarget.value.connectionId));
});
const queryEditorDdlDialect = computed(() => {
if (!queryEditorDdlTarget.value?.connectionId) return "mysql";
return codeMirrorSqlDialect(effectiveDatabaseTypeForConnection(connectionStore.getConfig(queryEditorDdlTarget.value.connectionId)));
return codeMirrorSqlDialect(queryEditorDdlDatabaseType.value);
});
const connectionStats = computed(() => ({
total: connectionStore.connections.length,
@@ -666,9 +687,15 @@ async function saveExternalSqlPath(tab: QueryTab, options: { closeAfterSave?: bo
async function saveTabForCloseAll(tabId: string): Promise<boolean> {
const tab = queryStore.tabs.find((t) => t.id === tabId);
if (!tab || !canSaveSqlTab(tab)) return true;
if (!tab) return true;
queryStore.activeTabId = tabId;
if (tab.mode === "structure") {
await nextTick();
return (await contentAreaRef.value?.applyTableStructureChanges?.()) === true;
}
if (!canSaveSqlTab(tab)) return true;
if (tab.objectSource) return saveActiveObjectSource(tab);
if (await saveExternalSqlPath(tab)) return !queryStore.isTabDirty(tab);
@@ -714,7 +741,18 @@ async function handleSaveAllPendingTabClose() {
async function handleSaveTab(tabId: string) {
const tab = queryStore.tabs.find((t) => t.id === tabId);
if (!tab || !canSaveSqlTab(tab)) return;
if (!tab) return;
if (tab.mode === "structure") {
queryStore.activeTabId = tabId;
await nextTick();
if (await contentAreaRef.value?.applyTableStructureChanges?.()) {
completePendingTabSave(tabId);
} else {
queryStore.resumeCloseConfirm();
}
return;
}
if (!canSaveSqlTab(tab)) return;
const closeAfterSave = pendingAppCloseAction.value === null;
pendingSaveShouldCloseTab.value = closeAfterSave;
if (tab.objectSource) {
@@ -798,7 +836,22 @@ async function saveActiveObjectSource(tab: QueryTab): Promise<boolean> {
name: source.name,
source: tab.sql,
});
await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema);
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
if (executableSql.trim()) {
const saved = await executeWithProductionSqlGuard({
connection,
database: tab.database,
sql: executableSql,
source: t("production.sourceObjectSource"),
execute: async () => {
await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema);
return true;
},
});
if (!saved) return false;
} else {
await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema);
}
queryStore.markTabClean(tab);
toast(t("objects.sourceSaved"), 2000);
return true;
@@ -917,9 +970,7 @@ async function openSqlFilePath(path: string) {
const connectionId = connectionStore.activeConnectionId || activeTab.value?.connectionId || connectionStore.connections[0]?.id || "";
const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined;
const database = activeTab.value?.database || (connection ? resolveDefaultDatabase(connection, []) : "");
const tabId = queryStore.createTab(connectionId, database, sqlFileTitleFromPath(path), "query");
queryStore.updateSql(tabId, content);
queryStore.linkExternalSqlPath(tabId, path, sqlFileTitleFromPath(path));
queryStore.openExternalSqlFile(connectionId, database, path, content);
} catch (e: any) {
toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000);
}
@@ -1054,7 +1105,19 @@ async function newQuery() {
const conn = connectionStore.getConfig(target.connectionId);
if (!conn) return;
connectionStore.activeConnectionId = target.connectionId;
const tabId = queryStore.createTab(conn.id, target.database, undefined, "query", target.schema);
// Prefill the editor with `SELECT * FROM <focused table>` when enabled and a
// table context (active data/structure tab or selected table node) is available.
// Built before createTab so the tab opens with the content directly (no flash).
const initialSql = resolveNewQueryInitialSql({
activeTab: activeTab.value,
selectedTreeNode: findTreeNodeById(connectionStore.treeNodes, connectionStore.selectedTreeNodeId),
preferredSource: newQueryContextSource.value,
prefillEnabled: settingsStore.editorSettings.prefillNewQueryWithSelect,
targetConnectionId: target.connectionId,
targetDatabase: target.database,
databaseType: effectiveDatabaseTypeForConnection(conn),
});
const tabId = queryStore.createTab(conn.id, target.database, undefined, "query", target.schema, initialSql, target.catalog);
try {
await connectionStore.ensureConnected(target.connectionId);
if (target.shouldRefreshDefaultDatabase) {
@@ -1272,6 +1335,12 @@ function onAiRequestAutoExecuteSql(sql: string) {
queryStore.updateSql(tabId, buildAppendedEditorSql(activeTab.value?.sql || "", sql));
selectedSql.value = "";
const productionAssessment = assessProductionSql(sql, activeConnection.value, activeTab.value?.database);
if (productionAssessment.active && productionAssessment.isMutation) {
toast(t("production.aiReviewRequired"), 5000);
return;
}
const decision = classifyAiSqlExecution(sql, activeConnection.value);
if (decision.action === "block") {
toast(t("ai.autoSqlBlocked"), 5000);
@@ -1380,6 +1449,12 @@ async function handleQuickOpenSelect(item: any) {
}
}
return;
} else if (item.type === "schema") {
const dbNode = findTreeNodeById(connectionStore.treeNodes, `${item.connectionId}:${item.database}`);
if (dbNode && !dbNode.isExpanded) await connectionStore.loadSchemas(item.connectionId, item.database);
const schemaNode = findTreeNodeById(connectionStore.treeNodes, `${item.connectionId}:${item.database}:${item.schema}`);
if (schemaNode && !schemaNode.isExpanded) await connectionStore.loadTables(item.connectionId, item.database, item.schema);
return;
} else if (item.type === "table" || item.type === "view" || item.type === "materialized_view") {
// Open the table/view in a data tab
await openTableTarget({
@@ -1898,7 +1973,7 @@ onUnmounted(() => {
@editor-selection-state-change="(tabId: string, selection: { anchor: number; head: number }) => queryStore.updateEditorSelection(tabId, selection)"
@format-error="toast(t('toolbar.formatSqlFailed'))"
@save-sql="void openSaveSqlDialog()"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => onReloadData(sql, searchText, whereInput, orderBy, limit, offset)"
@reload="(sql, searchText, whereInput, orderBy, limit, offset, intent) => onReloadData(sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="onPaginate"
@sort="onSort"
@execute-sql="onExecuteSql"
@@ -1913,6 +1988,7 @@ onUnmounted(() => {
connectionId: activeTab.connectionId,
database: activeTab.database,
schema: target.schema,
catalog: target.catalog,
tableName: target.tableName,
tableType: target.tableType,
})
@@ -2008,6 +2084,7 @@ onUnmounted(() => {
:sql-parameter-source-sql="sqlParameterSourceSql"
:sql-parameter-names="sqlParameterNames"
:sql-parameter-database-type="sqlParameterDatabaseType"
:sql-parameter-enabled-syntaxes="sqlParameterEnabledSyntaxes"
@update:show-connection-dialog="setConnectionDialogOpen"
@update:show-danger-dialog="showDangerDialog = $event"
@update:suppress-danger-confirm="suppressDangerConfirm = $event"
@@ -2029,6 +2106,10 @@ onUnmounted(() => {
setConnectionDialogOpen(false);
openDriverStorePage();
"
@open-tunnel-profile-settings="
setConnectionDialogOpen(false);
openSettings('tunnels');
"
@open-lineage-target="openLineageTarget"
@open-database-search-target="openDatabaseSearchTarget"
@open-diagram-target="openDiagramTarget"
@@ -2096,7 +2177,16 @@ onUnmounted(() => {
</DialogFooter>
</DialogContent>
</Dialog>
<DdlViewDialog v-if="queryEditorDdlTarget" v-model:open="showQueryEditorDdlDialog" :connection-id="queryEditorDdlTarget.connectionId" :database="queryEditorDdlTarget.database" :schema="queryEditorDdlTarget.schema" :table-name="queryEditorDdlTarget.tableName" :dialect="queryEditorDdlDialect" />
<DdlViewDialog
v-if="queryEditorDdlTarget"
v-model:open="showQueryEditorDdlDialog"
:connection-id="queryEditorDdlTarget.connectionId"
:database="queryEditorDdlTarget.database"
:schema="queryEditorDdlTarget.schema"
:table-name="queryEditorDdlTarget.tableName"
:database-type="queryEditorDdlDatabaseType"
:dialect="queryEditorDdlDialect"
/>
</TooltipProvider>
</div>
</template>
@@ -10,6 +10,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import {
DAMENG_JOB_ENVIRONMENT_SQL,
damengClearJobHistoriesSql,
@@ -169,10 +170,18 @@ async function applyPendingSql() {
applying.value = true;
try {
await ensureConnection();
await api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, {
maxRows: 1000,
useTransaction: pendingUseTransaction.value,
const result = await executeWithProductionSqlGuard({
connection: props.connection,
database: executionDatabase.value,
sql: pendingSql.value,
source: t("production.sourceAdmin"),
execute: () =>
api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, {
maxRows: 1000,
useTransaction: pendingUseTransaction.value,
}),
});
if (!result) return;
toast(t("damengJobAdmin.applySuccess"), 2500);
previewDialogOpen.value = false;
await (pendingAfterApply.value?.() ?? Promise.resolve());
@@ -13,6 +13,7 @@ import { useToast } from "@/composables/useToast";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { grantsFromQueryResult, getDatabaseUserAdminProvider, supportsDatabaseUserAdmin, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin";
const props = defineProps<{
@@ -165,7 +166,14 @@ async function applyPendingSql() {
if (!pendingSql.value.trim()) return;
applying.value = true;
try {
await api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000 });
const result = await executeWithProductionSqlGuard({
connection: props.connection,
database: "",
sql: pendingSql.value,
source: t("production.sourceAdmin"),
execute: () => api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000 }),
});
if (!result) return;
toast(t("userAdmin.applySuccess"), 2500);
sqlDialogOpen.value = false;
await (pendingAfterApply.value?.() ?? Promise.resolve());
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Activity, ArrowDownUp, ChevronRight, Database, Gauge, Loader2, RefreshCcw, Search, Timer, TriangleAlert, Users } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import MetricCard from "@/components/common/MetricCard.vue";
import MetricLineChart from "@/components/chart/MetricLineChart.vue";
import * as api from "@/lib/backend/api";
import { computeQps, computeRate, formatBytes, formatBytesPerSec, formatNumber, formatUptime, GLOBAL_STATUS_SQL, GLOBAL_VARIABLES_SQL, innodbBufferHitRatio, MAX_SAMPLES, parseStatusResult, statusEntries, statusNumber, type StatusSample } from "@/lib/database/mysqlServerStatus";
const props = defineProps<{
connectionId: string;
}>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const loading = ref(false);
const fetching = ref(false);
const error = ref("");
const variables = ref<Record<string, string>>({});
const samples = ref<StatusSample[]>([]);
const autoRefreshInterval = ref(5);
const statusSearch = ref("");
const showStatusTable = ref(true);
let refreshTimer: ReturnType<typeof setInterval> | null = null;
const connectionName = computed(() => connectionStore.getConfig(props.connectionId)?.name ?? "");
const latest = computed(() => samples.value[samples.value.length - 1]);
const previous = computed(() => (samples.value.length >= 2 ? samples.value[samples.value.length - 2] : undefined));
function rate(key: string): number {
const prev = previous.value;
const curr = latest.value;
return prev && curr ? computeRate(prev, curr, key) : 0;
}
const qps = computed(() => {
const prev = previous.value;
const curr = latest.value;
return prev && curr ? computeQps(prev, curr) : 0;
});
const maxConnections = computed(() => statusNumber(variables.value, "max_connections"));
const serverVersion = computed(() => variables.value.version ?? "");
const threadsConnected = computed(() => (latest.value ? statusNumber(latest.value.status, "Threads_connected") : 0));
const threadsRunning = computed(() => (latest.value ? statusNumber(latest.value.status, "Threads_running") : 0));
const slowQueries = computed(() => (latest.value ? statusNumber(latest.value.status, "Slow_queries") : 0));
const uptimeSeconds = computed(() => (latest.value ? statusNumber(latest.value.status, "Uptime") : 0));
const innodbHit = computed(() => (latest.value ? innodbBufferHitRatio(latest.value.status) : null));
// Rate series are computed between consecutive samples, so labels/data start at
// the second sample.
const chartLabels = computed(() => samples.value.slice(1).map((s) => formatClock(s.at)));
function rateSeries(key: string): number[] {
const out: number[] = [];
for (let i = 1; i < samples.value.length; i++) {
out.push(computeRate(samples.value[i - 1], samples.value[i], key));
}
return out;
}
const qpsSeries = computed(() => {
const out: number[] = [];
for (let i = 1; i < samples.value.length; i++) out.push(computeQps(samples.value[i - 1], samples.value[i]));
return [{ name: "QPS", data: out, color: "#3b82f6" }];
});
const trafficSeries = computed(() => [
{ name: t("serverDashboard.in"), data: rateSeries("Bytes_received"), color: "#3b82f6" },
{ name: t("serverDashboard.out"), data: rateSeries("Bytes_sent"), color: "#8b5cf6" },
]);
const commandSeries = computed(() => [
{ name: "SELECT", data: rateSeries("Com_select"), color: "#3b82f6" },
{ name: "INSERT", data: rateSeries("Com_insert"), color: "#22c55e" },
{ name: "UPDATE", data: rateSeries("Com_update"), color: "#f59e0b" },
{ name: "DELETE", data: rateSeries("Com_delete"), color: "#ef4444" },
]);
// New sessions per second — the `Connections` status var is the cumulative count
// of connection attempts, so its rate is the sessions-established-per-second.
const sessionsSeries = computed(() => [{ name: t("serverDashboard.sessions"), data: rateSeries("Connections"), color: "#14b8a6" }]);
const statusRows = computed(() => {
if (!latest.value) return [];
const query = statusSearch.value.trim().toLowerCase();
const rows = statusEntries(latest.value.status);
if (!query) return rows;
return rows.filter((row) => row.name.toLowerCase().includes(query) || row.value.toLowerCase().includes(query));
});
function formatClock(at: number): string {
const d = new Date(at);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
async function fetchVariables() {
try {
const result = await api.executeQuery(props.connectionId, "", GLOBAL_VARIABLES_SQL, undefined, undefined, { maxRows: 2000 });
variables.value = parseStatusResult(result);
} catch {
// Non-fatal: cards that depend on variables (max_connections/version) degrade.
}
}
async function fetchStatus(options: { silent?: boolean } = {}) {
if (fetching.value) return;
fetching.value = true;
if (!options.silent) loading.value = true;
error.value = "";
try {
await connectionStore.ensureConnected(props.connectionId);
const result = await api.executeQuery(props.connectionId, "", GLOBAL_STATUS_SQL, undefined, undefined, { maxRows: 2000 });
const sample: StatusSample = { at: Date.now(), status: parseStatusResult(result) };
const next = [...samples.value, sample];
samples.value = next.length > MAX_SAMPLES ? next.slice(next.length - MAX_SAMPLES) : next;
} catch (e: any) {
error.value = e?.message || String(e);
} finally {
loading.value = false;
fetching.value = false;
}
}
function startAutoRefresh() {
stopAutoRefresh();
if (autoRefreshInterval.value <= 0) return;
refreshTimer = setInterval(() => {
if (document.hidden) return;
void fetchStatus({ silent: true });
}, autoRefreshInterval.value * 1000);
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
function onIntervalChange(value: unknown) {
autoRefreshInterval.value = Number(value);
startAutoRefresh();
}
async function handleRefresh() {
await fetchStatus();
}
onMounted(async () => {
await fetchStatus();
if (!error.value) await fetchVariables();
startAutoRefresh();
});
onUnmounted(stopAutoRefresh);
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-background">
<div class="flex h-11 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
<Gauge class="h-4 w-4 text-primary" />
<div class="truncate text-sm font-semibold">{{ t("serverDashboard.title") }}</div>
<Badge variant="outline" class="h-5 rounded-md px-1.5 text-[11px]">{{ connectionName }}</Badge>
<Badge v-if="serverVersion" variant="secondary" class="h-5 rounded-md px-1.5 text-[11px]">{{ serverVersion }}</Badge>
<div class="ml-auto flex items-center gap-2">
<span class="text-xs text-muted-foreground">{{ t("serverDashboard.autoRefresh") }}</span>
<Select :model-value="String(autoRefreshInterval)" @update:model-value="onIntervalChange">
<SelectTrigger class="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{{ t("serverDashboard.off") }}</SelectItem>
<SelectItem value="1">1s</SelectItem>
<SelectItem value="2">2s</SelectItem>
<SelectItem value="5">5s</SelectItem>
<SelectItem value="10">10s</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="loading" @click="handleRefresh">
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
<RefreshCcw v-else class="h-3.5 w-3.5" />
{{ t("grid.refresh") }}
</Button>
</div>
</div>
<div v-if="error" class="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ error }}</div>
<div class="flex min-h-0 flex-1 flex-col gap-3 p-3">
<div class="grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-4">
<MetricCard :label="t('serverDashboard.qps')" :value="formatNumber(qps)" :icon="Gauge" />
<MetricCard :label="t('serverDashboard.connections')" :value="`${formatNumber(threadsConnected)}${maxConnections ? ' / ' + formatNumber(maxConnections) : ''}`" :icon="Users" />
<MetricCard :label="t('serverDashboard.running')" :value="formatNumber(threadsRunning)" :icon="Activity" />
<MetricCard :label="t('serverDashboard.trafficIn')" :value="formatBytesPerSec(rate('Bytes_received'))" :icon="ArrowDownUp" />
<MetricCard :label="t('serverDashboard.trafficOut')" :value="formatBytesPerSec(rate('Bytes_sent'))" :icon="ArrowDownUp" />
<MetricCard :label="t('serverDashboard.slowQueries')" :value="formatNumber(slowQueries)" :icon="TriangleAlert" />
<MetricCard :label="t('serverDashboard.uptime')" :value="formatUptime(uptimeSeconds)" :icon="Timer" />
<MetricCard :label="t('serverDashboard.innodbHit')" :value="innodbHit === null ? '—' : innodbHit.toFixed(2) + '%'" :icon="Database" />
</div>
<div class="grid shrink-0 grid-cols-1 gap-3 xl:grid-cols-2">
<MetricLineChart :title="t('serverDashboard.qpsChart')" :labels="chartLabels" :series="qpsSeries" :value-formatter="formatNumber" />
<MetricLineChart :title="t('serverDashboard.sessionsChart')" :labels="chartLabels" :series="sessionsSeries" :value-formatter="formatNumber" />
<MetricLineChart :title="t('serverDashboard.trafficChart')" :labels="chartLabels" :series="trafficSeries" :value-formatter="formatBytes" />
<MetricLineChart :title="t('serverDashboard.commandChart')" :labels="chartLabels" :series="commandSeries" :value-formatter="formatNumber" />
</div>
<div class="flex flex-col rounded-lg border bg-card" :class="showStatusTable ? 'min-h-0 flex-1' : 'shrink-0'">
<button type="button" class="flex w-full shrink-0 items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-accent/40" @click="showStatusTable = !showStatusTable">
<ChevronRight class="h-3.5 w-3.5 transition-transform" :class="{ 'rotate-90': showStatusTable }" />
{{ t("serverDashboard.rawStatus") }}
<Badge variant="secondary" class="ml-1 h-4 rounded px-1 text-[10px]">{{ statusRows.length }}</Badge>
</button>
<div v-if="showStatusTable" class="flex min-h-0 flex-1 flex-col border-t">
<div class="flex h-9 shrink-0 items-center gap-1.5 border-b px-3">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input v-model="statusSearch" class="h-full w-full min-w-0 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('serverDashboard.filterStatus')" />
</div>
<div class="min-h-0 flex-1 overflow-auto">
<table class="w-full border-collapse text-xs">
<tbody>
<tr v-for="row in statusRows" :key="row.name" class="border-b last:border-0 hover:bg-accent/40">
<td class="whitespace-nowrap px-3 py-1 font-mono text-muted-foreground">{{ row.name }}</td>
<td class="px-3 py-1 font-mono tabular-nums">{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Activity, AlertTriangle, ArrowDown, ArrowUp, Ban, Loader2, RefreshCcw, Search } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { buildKillSql, clampInterval, createProcessListLoadCoordinator, DEFAULT_REFRESH_SECONDS, mapProcessRows, processListExecutionError, processListSessionCount, PROCESS_LIST_SQL, type ProcessRow } from "@/lib/database/mysqlProcessList";
const props = defineProps<{
connection: ConnectionConfig;
}>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const { toast } = useToast();
type SortKey = keyof ProcessRow;
const rows = ref<ProcessRow[]>([]);
const truncated = ref(false);
const ownSessionId = ref<number | null>(null);
const loading = ref(false);
const loadCoordinator = createProcessListLoadCoordinator();
const loadError = ref("");
const search = ref("");
const sortKey = ref<SortKey>("time");
const sortDir = ref<"asc" | "desc">("desc");
const autoRefresh = ref(false);
const intervalSeconds = ref(DEFAULT_REFRESH_SECONDS);
let timer: ReturnType<typeof setInterval> | undefined;
const killTarget = ref<ProcessRow | null>(null);
const killing = ref(false);
const COLUMNS: { key: SortKey; labelKey: string; mono?: boolean }[] = [
{ key: "id", labelKey: "processList.colId", mono: true },
{ key: "user", labelKey: "processList.colUser" },
{ key: "host", labelKey: "processList.colHost" },
{ key: "db", labelKey: "processList.colDb" },
{ key: "command", labelKey: "processList.colCommand" },
{ key: "time", labelKey: "processList.colTime", mono: true },
{ key: "state", labelKey: "processList.colState" },
{ key: "info", labelKey: "processList.colInfo" },
];
const filteredRows = computed(() => {
const query = search.value.trim().toLowerCase();
const base = query ? rows.value.filter((row) => [row.id, row.user, row.host, row.db, row.command, row.state, row.info].some((value) => value !== null && value !== undefined && String(value).toLowerCase().includes(query))) : rows.value.slice();
const key = sortKey.value;
const dir = sortDir.value === "asc" ? 1 : -1;
return base.sort((a, b) => {
const av = a[key];
const bv = b[key];
if (av === bv) return 0;
if (av === null || av === undefined) return 1;
if (bv === null || bv === undefined) return -1;
if (typeof av === "number" && typeof bv === "number") return (av - bv) * dir;
return String(av).localeCompare(String(bv)) * dir;
});
});
function toggleSort(key: SortKey) {
if (sortKey.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
} else {
sortKey.value = key;
sortDir.value = key === "time" || key === "id" ? "desc" : "asc";
}
}
async function load(options: { silent?: boolean } = {}) {
if (!loadCoordinator.tryStart()) return;
if (!options.silent) loading.value = true;
loadError.value = "";
try {
await connectionStore.ensureConnected(props.connection.id);
if (ownSessionId.value === null) {
try {
const idResult = await api.executeQuery(props.connection.id, "", "SELECT CONNECTION_ID()", undefined, undefined, { maxRows: 1 });
const raw = idResult?.rows?.[0]?.[0];
const parsed = Number(raw);
if (Number.isFinite(parsed)) ownSessionId.value = parsed;
} catch {
// Non-fatal: without our own id we simply cannot dim the self row.
}
}
const result = await api.executeQuery(props.connection.id, "", PROCESS_LIST_SQL, undefined, undefined, { maxRows: 5000 });
rows.value = mapProcessRows(result);
truncated.value = result.truncated === true;
} catch (error: any) {
loadError.value = error?.message || String(error);
} finally {
loading.value = false;
loadCoordinator.finish();
}
}
function isOwnSession(row: ProcessRow): boolean {
return ownSessionId.value !== null && row.id === ownSessionId.value;
}
function requestKill(row: ProcessRow) {
if (isOwnSession(row)) return;
killTarget.value = row;
}
async function confirmKill() {
const target = killTarget.value;
if (!target) return;
killing.value = true;
try {
const killSql = buildKillSql(target.id);
const result = await executeWithProductionSqlGuard({
connection: props.connection,
database: "",
sql: killSql,
source: t("production.sourceAdmin"),
execute: () => api.executeMulti(props.connection.id, "", killSql, undefined, undefined, { maxRows: 1 }),
});
if (result === undefined) return;
const executionError = processListExecutionError(result);
if (executionError) throw new Error(executionError);
toast(t("processList.killSuccess", { id: target.id }), 2500);
killTarget.value = null;
await load({ silent: true });
} catch (error: any) {
toast(t("processList.killFailed", { message: error?.message || String(error) }), 5000);
} finally {
killing.value = false;
}
}
function stopTimer() {
if (timer) {
clearInterval(timer);
timer = undefined;
}
}
function restartTimer() {
stopTimer();
if (!autoRefresh.value) return;
const seconds = clampInterval(intervalSeconds.value);
timer = setInterval(() => {
// Skip polling while the window is hidden to avoid needless server load.
if (document.hidden) return;
void load({ silent: true });
}, seconds * 1000);
}
function onIntervalInput() {
intervalSeconds.value = clampInterval(Number(intervalSeconds.value));
if (autoRefresh.value) restartTimer();
}
watch(autoRefresh, restartTimer);
watch(intervalSeconds, () => {
if (autoRefresh.value) restartTimer();
});
watch(
() => props.connection.id,
() => {
rows.value = [];
truncated.value = false;
ownSessionId.value = null;
search.value = "";
void load();
},
);
onMounted(() => void load());
onBeforeUnmount(stopTimer);
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-background">
<div class="flex h-11 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
<div class="flex min-w-0 items-center gap-2">
<Activity class="h-4 w-4 text-primary" />
<div class="truncate text-sm font-semibold">{{ t("processList.title") }}</div>
<Badge variant="outline" class="h-5 rounded-md px-1.5 text-[11px]">{{ connection.name }}</Badge>
<Badge variant="secondary" class="h-5 rounded-md px-1.5 text-[11px]">{{ t("processList.sessionCount", { count: processListSessionCount(truncated ? rows.length : filteredRows.length, truncated) }) }}</Badge>
</div>
<div class="ml-auto flex items-center gap-2">
<div class="flex h-7 items-center gap-1.5 rounded-md border bg-background px-2">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input v-model="search" class="h-full w-40 min-w-0 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('processList.filter')" />
</div>
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
<input v-model="autoRefresh" type="checkbox" class="h-3.5 w-3.5 accent-primary" />
{{ t("processList.autoRefresh") }}
</label>
<div class="flex h-7 items-center gap-1 rounded-md border bg-background px-1.5">
<Input v-model.number="intervalSeconds" type="number" min="1" max="3600" class="h-6 w-14 border-0 px-1 text-xs shadow-none focus-visible:ring-0" @change="onIntervalInput" />
<span class="pr-1 text-[11px] text-muted-foreground">{{ t("processList.seconds") }}</span>
</div>
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="loading" @click="load()">
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
<RefreshCcw v-else class="h-3.5 w-3.5" />
{{ t("grid.refresh") }}
</Button>
</div>
</div>
<div v-if="loadError" class="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ loadError }}</div>
<div class="min-h-0 flex-1 overflow-auto">
<table class="w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-muted/40 backdrop-blur">
<tr>
<th v-for="column in COLUMNS" :key="column.key" class="cursor-pointer select-none whitespace-nowrap border-b px-3 py-2 text-left font-medium hover:bg-accent" @click="toggleSort(column.key)">
<span class="inline-flex items-center gap-1">
{{ t(column.labelKey) }}
<ArrowUp v-if="sortKey === column.key && sortDir === 'asc'" class="h-3 w-3" />
<ArrowDown v-else-if="sortKey === column.key && sortDir === 'desc'" class="h-3 w-3" />
</span>
</th>
<th class="w-16 whitespace-nowrap border-b px-3 py-2 text-right font-medium">{{ t("processList.colActions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredRows" :key="row.id" class="border-b hover:bg-accent/40" :class="{ 'bg-primary/5': isOwnSession(row) }">
<td class="whitespace-nowrap px-3 py-1.5 font-mono">
{{ row.id }}
<Badge v-if="isOwnSession(row)" variant="outline" class="ml-1 h-4 rounded px-1 text-[10px]">{{ t("processList.self") }}</Badge>
</td>
<td class="whitespace-nowrap px-3 py-1.5">{{ row.user }}</td>
<td class="whitespace-nowrap px-3 py-1.5">{{ row.host }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-muted-foreground">{{ row.db ?? "—" }}</td>
<td class="whitespace-nowrap px-3 py-1.5">{{ row.command }}</td>
<td class="whitespace-nowrap px-3 py-1.5 font-mono">{{ row.time }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-muted-foreground">{{ row.state ?? "—" }}</td>
<td class="max-w-md truncate px-3 py-1.5 font-mono text-muted-foreground" :title="row.info ?? ''">{{ row.info ?? "—" }}</td>
<td class="px-3 py-1.5 text-right">
<Button
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[11px] text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
:disabled="isOwnSession(row)"
:title="isOwnSession(row) ? t('processList.cannotKillSelf') : t('processList.kill')"
@click="requestKill(row)"
>
<Ban class="h-3.5 w-3.5" />
{{ t("processList.kill") }}
</Button>
</td>
</tr>
<tr v-if="!loading && filteredRows.length === 0">
<td :colspan="COLUMNS.length + 1" class="px-3 py-10 text-center text-muted-foreground">
{{ search ? t("grid.noSearchResults") : t("processList.empty") }}
</td>
</tr>
</tbody>
</table>
</div>
<Dialog
:open="killTarget !== null"
@update:open="
(open) => {
if (!open) killTarget = null;
}
"
>
<DialogContent class="max-w-sm">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<AlertTriangle class="h-4 w-4 text-destructive" />
{{ t("processList.killTitle") }}
</DialogTitle>
</DialogHeader>
<p v-if="killTarget" class="text-sm text-muted-foreground">
{{ t("processList.killConfirm", { id: killTarget.id, user: killTarget.user }) }}
</p>
<DialogFooter>
<Button variant="outline" @click="killTarget = null">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" :disabled="killing" @click="confirmKill">
<Loader2 v-if="killing" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
{{ t("processList.kill") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from "vue";
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { LineChart } from "echarts/charts";
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
import VChart from "vue-echarts";
import { useTheme } from "@/composables/useTheme";
// Reuses the piecewise ECharts registration pattern from QueryChart.vue, but a
// dedicated time-series line chart for live metrics (no bar/pie, no result prop).
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent]);
interface Series {
name: string;
data: number[];
color?: string;
}
const props = defineProps<{
title: string;
labels: string[];
series: Series[];
height?: number;
valueFormatter?: (value: number) => string;
}>();
const { isDark } = useTheme();
const axisColor = computed(() => (isDark.value ? "#3f3f46" : "#e4e4e7"));
const textColor = computed(() => (isDark.value ? "#a1a1aa" : "#71717a"));
const chartOption = computed(() => {
const format = props.valueFormatter ?? ((v: number) => String(Math.round(v)));
return {
animation: false,
grid: { left: 8, right: 12, top: 28, bottom: 8, containLabel: true },
legend: props.series.length > 1 ? { top: 0, textStyle: { color: textColor.value, fontSize: 11 }, itemHeight: 8, itemWidth: 12 } : undefined,
tooltip: {
trigger: "axis",
valueFormatter: (value: number) => format(value),
},
xAxis: {
type: "category",
data: props.labels,
boundaryGap: false,
axisLine: { lineStyle: { color: axisColor.value } },
axisLabel: { color: textColor.value, fontSize: 10 },
},
yAxis: {
type: "value",
axisLabel: { color: textColor.value, fontSize: 10, formatter: (value: number) => format(value) },
splitLine: { lineStyle: { color: axisColor.value } },
},
series: props.series.map((s) => ({
name: s.name,
type: "line",
data: s.data,
smooth: true,
showSymbol: false,
lineStyle: s.color ? { color: s.color, width: 2 } : { width: 2 },
itemStyle: s.color ? { color: s.color } : undefined,
areaStyle: props.series.length === 1 ? { opacity: 0.12 } : undefined,
})),
};
});
</script>
<template>
<div class="flex flex-col rounded-lg border bg-card p-3">
<div class="mb-1 text-xs font-medium text-muted-foreground">{{ title }}</div>
<VChart :option="chartOption" autoresize :style="{ height: `${height ?? 160}px` }" />
</div>
</template>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Code2, Copy } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { useToast } from "@/composables/useToast";
import { useTheme } from "@/composables/useTheme";
import { copyToClipboard } from "@/lib/common/clipboard";
import { parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
import { createShikiJsonHighlighter, type JsonHighlighter } from "@/lib/common/shikiJsonHighlighter";
import JsonTree from "./JsonTree.vue";
const props = defineProps<{
status: number;
body: string;
}>();
const { t } = useI18n();
const { toast } = useToast();
const { isDark } = useTheme();
const responseView = ref<"raw" | "json">("json");
const jsonTreeRef = ref<{ refresh: () => void }>();
const jsonHighlighter = ref<JsonHighlighter>();
const parsedBody = computed(() => {
try {
return { valid: true, value: parseJsonPreservingLargeNumbers(props.body) };
} catch {
return { valid: false, value: null };
}
});
const statusClass = computed(() => {
if (props.status >= 500) return "border-destructive/40 bg-destructive/10 text-destructive";
if (props.status >= 400) return "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300";
if (props.status >= 300) return "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300";
return "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
});
const statusLabel = computed(() => `HTTP ${props.status}`);
const jsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
watch(
() => props.body,
() => {
responseView.value = parsedBody.value.valid ? "json" : "raw";
},
{ immediate: true },
);
watch(responseView, (view) => {
if (view === "json") void nextTick(() => jsonTreeRef.value?.refresh());
});
async function copyResponse() {
try {
await copyToClipboard(props.body);
toast(t("grid.copied"), 2000);
} catch (error: any) {
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
}
}
function highlightJson(value: string): string {
return jsonHighlighter.value?.(value, jsonAppearance.value) ?? escapeHtml(value);
}
function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
onMounted(() => {
void createShikiJsonHighlighter({ appearance: () => jsonAppearance.value })
.then((highlight) => {
jsonHighlighter.value = highlight;
})
.catch(() => {
jsonHighlighter.value = undefined;
});
});
</script>
<template>
<section data-elasticsearch-json-response-root class="flex h-full min-h-0 flex-col bg-background" :aria-label="t('redis.jsonView')">
<header class="flex min-h-11 shrink-0 items-center gap-2 border-b bg-muted/25 px-3 py-1.5 text-xs">
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm" aria-hidden="true">
<Code2 class="h-3.5 w-3.5" />
</span>
<div class="inline-flex h-7 items-center rounded-md border bg-muted/45 p-0.5">
<button type="button" class="h-6 rounded-[4px] px-2 text-xs transition-colors" :class="responseView === 'raw' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" :aria-pressed="responseView === 'raw'" @click="responseView = 'raw'">
{{ t("redis.rawContent") }}
</button>
<button
type="button"
class="h-6 rounded-[4px] px-2 text-xs transition-colors"
:class="responseView === 'json' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="responseView === 'json'"
:disabled="!parsedBody.valid"
@click="responseView = 'json'"
>
{{ t("redis.jsonView") }}
</button>
</div>
</div>
<span class="shrink-0 rounded-full border px-2 py-0.5 font-mono text-[11px] font-medium tabular-nums" :class="statusClass" role="status" :aria-label="statusLabel">
{{ statusLabel }}
</span>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('grid.copyJson')" :aria-label="t('grid.copyJson')" @click="copyResponse">
<Copy class="h-3.5 w-3.5" />
</Button>
</header>
<div class="min-h-0 flex-1 overflow-hidden bg-background p-4">
<pre v-show="responseView === 'raw' || !parsedBody.valid" class="m-0 h-full overflow-auto bg-transparent p-0 font-mono text-sm leading-6 whitespace-pre-wrap break-words">{{ body }}</pre>
<div v-if="parsedBody.valid" v-show="responseView === 'json'" class="h-full min-h-0">
<JsonTree ref="jsonTreeRef" :value="parsedBody.value" :highlight-json="highlightJson" :virtualized="true" class="dbx-editor-font-family text-sm leading-6" />
</div>
</div>
</section>
</template>
@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, defineComponent, h, nextTick, ref, shallowRef, watch, type VNodeChild } from "vue";
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
import { ChevronDown, ChevronRight } from "@lucide/vue";
import { createJsonTreeRoot, getJsonTreeChildren, getVisibleJsonTreeNodes, isJsonTreeContainer, isJsonTreeInitiallyExpanded, jsonTreeContainerKind, jsonTreeContainerSummary, type JsonTreeNode } from "@/lib/common/jsonTree";
import { isLosslessJsonNumber } from "@/lib/common/safeJsonFormat";
defineOptions({ name: "JsonTree" });
const props = withDefaults(
defineProps<{
value: unknown;
wordWrap?: boolean;
/** Optional Shiki-style inline highlighter retained for Redis value views. */
highlightJson?: (json: string) => string;
/** Number of expanded container levels, with the root counted as level one. */
initialExpandedDepth?: number;
/** Render only viewport rows for large, scrollable JSON responses. */
virtualized?: boolean;
}>(),
{
wordWrap: true,
highlightJson: undefined,
initialExpandedDepth: Number.POSITIVE_INFINITY,
virtualized: false,
},
);
type ExpansionMode = "default" | "all" | "none";
type DynamicScrollerHandle = {
forceUpdate: (clear?: boolean) => void;
scrollToPosition?: (position: number) => void;
};
const expansionMode = ref<ExpansionMode>("default");
const expansionOverrides = shallowRef(new Map<string, boolean>());
const virtualScroller = ref<DynamicScrollerHandle>();
const rootNode = computed(() => createJsonTreeRoot(props.value));
function resetExpansion() {
expansionMode.value = "default";
expansionOverrides.value = new Map();
}
function expandAll() {
expansionMode.value = "all";
expansionOverrides.value = new Map();
}
function collapseAll() {
expansionMode.value = "none";
expansionOverrides.value = new Map();
}
function isNodeExpanded(node: JsonTreeNode): boolean {
const override = expansionOverrides.value.get(node.path);
if (override !== undefined) return override;
if (expansionMode.value === "all") return true;
if (expansionMode.value === "none") return false;
return isJsonTreeInitiallyExpanded(node.depth, props.initialExpandedDepth);
}
function setNodeExpanded(path: string, expanded: boolean) {
const next = new Map(expansionOverrides.value);
next.set(path, expanded);
expansionOverrides.value = next;
}
function toggleNode(node: JsonTreeNode) {
setNodeExpanded(node.path, !isNodeExpanded(node));
}
function scalarClass(value: unknown): string {
if (isLosslessJsonNumber(value) || typeof value === "number") return "json-tree-number";
if (typeof value === "string") return "json-tree-string";
if (typeof value === "boolean") return "json-tree-boolean";
if (value === null) return "json-tree-null";
return "json-tree-string";
}
function scalarText(value: unknown): string {
if (isLosslessJsonNumber(value)) return value.raw;
if (typeof value === "string") return JSON.stringify(value);
if (value === null) return "null";
return String(value);
}
function highlightedJson(value: string): string {
return props.highlightJson?.(value) ?? escapeHtml(value);
}
function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function nodeAccessibleLabel(node: JsonTreeNode): string {
// JSON Pointer paths are language-neutral and uniquely identify each toggle.
return node.path || "$";
}
function highlightedJsonSpan(className: string, json: string): VNodeChild {
if (!props.highlightJson) return h("span", { class: className }, json);
return h("span", { class: className, innerHTML: props.highlightJson(json) });
}
function renderJsonNode(node: JsonTreeNode): VNodeChild {
const containerValue = isJsonTreeContainer(node.value) ? node.value : undefined;
const expanded = containerValue !== undefined && isNodeExpanded(node);
const indent = `${node.depth * 16}px`;
const rowChildren: VNodeChild[] = [];
if (containerValue !== undefined) {
const accessibleLabel = nodeAccessibleLabel(node);
rowChildren.push(
h(
"button",
{
type: "button",
class: "json-tree-toggle",
"aria-expanded": expanded,
"aria-label": accessibleLabel,
title: accessibleLabel,
onClick: () => toggleNode(node),
},
[expanded ? h(ChevronDown, { class: "h-3.5 w-3.5", "aria-hidden": "true" }) : h(ChevronRight, { class: "h-3.5 w-3.5", "aria-hidden": "true" })],
),
);
} else {
rowChildren.push(h("span", { class: "json-tree-spacer", "aria-hidden": "true" }));
}
if (node.parentKind !== "root") {
rowChildren.push(node.parentKind === "array" ? h("span", { class: "json-tree-index" }, `[${node.label}]`) : highlightedJsonSpan("json-tree-key", JSON.stringify(node.label)), h("span", { class: "json-tree-punctuation" }, ":"));
}
if (containerValue !== undefined) {
const kind = jsonTreeContainerKind(containerValue);
rowChildren.push(h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "[" : "{"), h("span", { class: "json-tree-summary" }, jsonTreeContainerSummary(containerValue, expanded)), h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "]" : "}"));
} else {
rowChildren.push(highlightedJsonSpan(scalarClass(node.value), scalarText(node.value)));
}
const children = containerValue !== undefined && expanded ? h("div", { class: "json-tree-children" }, getJsonTreeChildren(node).map(renderJsonNode)) : null;
return h("div", { key: node.path, class: "json-tree-node", "data-json-path": node.path }, [h("div", { class: "json-tree-row", style: { paddingInlineStart: indent } }, rowChildren), children]);
}
const JsonTreeRoot = defineComponent({
name: "JsonTreeRoot",
setup() {
return () => renderJsonNode(rootNode.value);
},
});
const visibleNodes = computed(() => getVisibleJsonTreeNodes(rootNode.value, isNodeExpanded));
function refresh(resetScroll = false) {
if (!props.virtualized) return;
void nextTick(() => {
virtualScroller.value?.forceUpdate(true);
if (resetScroll) virtualScroller.value?.scrollToPosition?.(0);
});
}
watch([() => props.value, () => props.initialExpandedDepth], () => {
resetExpansion();
refresh(true);
});
defineExpose({ expandAll, collapseAll, resetExpansion, refresh });
</script>
<template>
<div class="json-tree" :class="{ 'is-nowrap': !wordWrap, 'is-virtualized': virtualized }">
<!-- Virtual rows keep a fully expanded response responsive even when it has thousands of nodes. -->
<DynamicScroller v-if="virtualized" ref="virtualScroller" :items="visibleNodes" :min-item-size="24" :buffer="600" key-field="path" class="json-tree-scroller">
<template #default="{ item: node, active, index }">
<DynamicScrollerItem :item="node" :active="active" :size-dependencies="[wordWrap, node.path, node.label, scalarText(node.value), isNodeExpanded(node)]" :data-index="index">
<div class="json-tree-node" :data-json-path="node.path">
<div class="json-tree-row" :style="{ paddingInlineStart: `${node.depth * 16}px` }">
<button v-if="isJsonTreeContainer(node.value)" type="button" class="json-tree-toggle" :aria-expanded="isNodeExpanded(node)" :aria-label="nodeAccessibleLabel(node)" :title="nodeAccessibleLabel(node)" @click="toggleNode(node)">
<ChevronDown v-if="isNodeExpanded(node)" class="h-3.5 w-3.5" aria-hidden="true" />
<ChevronRight v-else class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<span v-else class="json-tree-spacer" aria-hidden="true" />
<template v-if="node.parentKind !== 'root'">
<span v-if="node.parentKind === 'array'" class="json-tree-index">[{{ node.label }}]</span>
<span v-else class="json-tree-key" v-html="highlightedJson(JSON.stringify(node.label))" />
<span class="json-tree-punctuation">:</span>
</template>
<template v-if="isJsonTreeContainer(node.value)">
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "[" : "{" }}</span>
<span class="json-tree-summary">{{ jsonTreeContainerSummary(node.value, isNodeExpanded(node)) }}</span>
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "]" : "}" }}</span>
</template>
<span v-else :class="scalarClass(node.value)" v-html="highlightedJson(scalarText(node.value))" />
</div>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
<JsonTreeRoot v-else />
</div>
</template>
<style>
.json-tree {
--json-tree-key: #1d4ed8;
--json-tree-string: #15803d;
--json-tree-number: #b45309;
--json-tree-boolean: #7c3aed;
--json-tree-null: #64748b;
color: hsl(var(--foreground));
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.json-tree.is-virtualized {
height: 100%;
min-height: 0;
}
.json-tree-scroller {
height: 100%;
min-height: 0;
overflow: auto;
}
.json-tree.is-nowrap {
white-space: pre;
overflow-wrap: normal;
}
.json-tree-row {
display: flex;
min-height: 24px;
align-items: flex-start;
gap: 4px;
border-radius: 4px;
}
.json-tree-row:hover {
background: hsl(var(--muted) / 0.5);
}
.json-tree-toggle {
margin-top: 1px;
display: inline-flex;
height: 18px;
width: 18px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 3px;
color: hsl(var(--muted-foreground));
}
.json-tree-toggle:hover {
background: hsl(var(--accent));
color: hsl(var(--foreground));
}
.json-tree-toggle:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 1px;
}
.json-tree-spacer {
width: 18px;
flex: 0 0 auto;
}
.json-tree-key {
color: var(--json-tree-key);
}
.json-tree-string {
color: var(--json-tree-string);
}
.json-tree-number {
color: var(--json-tree-number);
}
.json-tree-boolean {
color: var(--json-tree-boolean);
}
.json-tree-null {
color: var(--json-tree-null);
font-style: italic;
}
.json-tree-index,
.json-tree-punctuation,
.json-tree-summary {
color: hsl(var(--muted-foreground));
}
.json-tree-bracket {
color: hsl(var(--foreground));
font-weight: 650;
}
.dark .json-tree {
--json-tree-key: #93c5fd;
--json-tree-string: #86efac;
--json-tree-number: #fbbf24;
--json-tree-boolean: #c4b5fd;
--json-tree-null: #94a3b8;
}
</style>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { Component } from "vue";
defineProps<{
label: string;
value: string;
sub?: string;
icon?: Component;
}>();
</script>
<template>
<div class="flex flex-col gap-1 rounded-lg border bg-card p-3 text-card-foreground">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<component :is="icon" v-if="icon" class="h-3.5 w-3.5" />
<span class="truncate">{{ label }}</span>
</div>
<div class="text-xl font-semibold tabular-nums">{{ value }}</div>
<div v-if="sub" class="truncate text-[11px] text-muted-foreground">{{ sub }}</div>
</div>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { ShieldAlert } from "@lucide/vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
withDefaults(
defineProps<{
compact?: boolean;
}>(),
{ compact: false },
);
</script>
<template>
<span class="inline-flex h-5 shrink-0 items-center gap-1 rounded-[4px] border border-red-500/40 bg-red-500/10 px-1.5 font-mono text-[10px] font-semibold leading-none text-red-700 dark:text-red-300" :class="compact ? 'px-1' : ''" :title="t('production.title')">
<ShieldAlert class="h-3 w-3" aria-hidden="true" />
<span>PROD</span>
</span>
</template>
@@ -36,10 +36,28 @@ const { t } = useI18n();
{{ t(labelKey) }}
<span v-if="elapsedSeconds !== undefined" class="ml-1 tabular-nums text-muted-foreground/80">· {{ elapsedSeconds }}s</span>
</div>
<Button v-if="showCancel" variant="destructive" size="sm" class="h-7 gap-1.5" :disabled="cancelDisabled" @click="emit('cancel')">
<Button v-if="showCancel" variant="destructive" size="sm" class="query-loading-cancel-button h-7 gap-1.5" :disabled="cancelDisabled" @click="emit('cancel')">
<Loader2 v-if="cancelling" class="h-3.5 w-3.5 animate-spin" />
<Square v-else class="h-3.5 w-3.5 fill-current" />
{{ t(cancelLabelKey) }}
</Button>
</div>
</template>
<style>
.query-loading-cancel-button {
border: 1px solid rgba(220, 38, 38, 0.22) !important;
background-color: rgba(220, 38, 38, 0.08) !important;
color: rgb(185, 28, 28) !important;
}
.query-loading-cancel-button:hover {
background-color: rgba(220, 38, 38, 0.14) !important;
}
.dark .query-loading-cancel-button {
border-color: rgba(248, 113, 113, 0.28) !important;
background-color: rgba(248, 113, 113, 0.12) !important;
color: rgb(248, 113, 113) !important;
}
</style>
@@ -44,6 +44,7 @@ const driverStoreTab = ref("agent");
import { useSettingsStore } from "@/stores/settingsStore";
import type { DriverStorePathInfo } from "@/lib/backend/api";
import { driverRuntimeProtocolLabel } from "./driverRuntimeDisplay";
const settingsStore = useSettingsStore();
type DriverStoreDirKind = "plugin" | "agent";
@@ -1059,12 +1060,12 @@ watch(driverStoreTab, (tab) => {
</script>
<template>
<div class="h-full flex flex-col">
<div class="flex-1 min-h-0 overflow-y-auto">
<div class="max-w-4xl mx-auto px-6 py-6">
<Tabs v-model="driverStoreTab" default-value="agent">
<div class="flex items-center justify-between">
<TabsList class="grid w-[360px] grid-cols-3">
<div class="driver-store-view h-full flex flex-col">
<div class="driver-store-scroll flex-1 min-h-0 overflow-y-auto">
<div class="driver-store-container max-w-4xl mx-auto px-6 py-6">
<Tabs v-model="driverStoreTab" default-value="agent" class="driver-store-tabs-root">
<div class="driver-store-header flex items-center justify-between">
<TabsList class="driver-store-tabs grid w-[360px] grid-cols-3">
<TabsTrigger value="agent" class="gap-1.5 relative">
{{ t("driverStore.agentDrivers") }}
<span v-if="agentTabUpdateCount > 0" class="inline-block h-2 w-2 rounded-full bg-red-500" />
@@ -1090,7 +1091,7 @@ watch(driverStoreTab, (tab) => {
</div>
<!-- Agent Tab -->
<TabsContent value="agent" class="mt-5 space-y-5">
<TabsContent value="agent" class="driver-store-tab driver-store-agent-tab mt-5 space-y-5">
<!-- Java Runtime -->
<div class="rounded-xl border bg-muted/20 p-4 space-y-3">
<div class="flex flex-wrap items-center gap-2">
@@ -1157,7 +1158,7 @@ watch(driverStoreTab, (tab) => {
<div v-else-if="filteredAgentDrivers.length === 0" class="py-12 text-center text-sm text-muted-foreground">
{{ t("driverStore.noMatchingDrivers") }}
</div>
<div v-else class="rounded-md border divide-y">
<div v-else class="driver-store-agent-list rounded-md border divide-y">
<div v-if="updatableCount > 0" class="flex items-center justify-between px-4 py-2 bg-muted/30">
<span class="text-xs text-muted-foreground">{{ t("driverStore.driversUpdatable", { count: updatableCount }) }}</span>
<Button size="sm" class="h-7 rounded-[6px] text-xs" :disabled="installing !== null || upgradingAll" @click="upgradeAll">
@@ -1166,14 +1167,14 @@ watch(driverStoreTab, (tab) => {
{{ upgradingAll ? t("driverStore.upgradingProgress", { current: upgradingIndex, total: upgradingTotal }) : t("driverStore.upgradeAll") }}
</Button>
</div>
<div v-for="driver in filteredAgentDrivers" :key="driver.db_type" class="flex items-center gap-3 px-4 py-2 transition hover:bg-muted/30">
<div v-for="driver in filteredAgentDrivers" :key="driver.db_type" class="driver-store-agent-row flex items-center gap-3 px-4 py-2 transition hover:bg-muted/30">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 shrink-0">
<DatabaseIcon :db-type="driver.db_type" class="h-4 w-4" />
</span>
<div class="min-w-0 flex-1">
<div class="driver-store-agent-name min-w-0 flex-1">
<div class="text-sm font-medium">{{ driver.label }}</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<div class="driver-store-agent-meta flex shrink-0 items-center gap-1.5">
<span v-if="driverRequiresJavaRuntime(driver) && driver.jre" class="rounded-full px-2 py-0.5 text-[11px]" :class="driver.jre !== '21' ? 'bg-blue-500/10 text-blue-600' : 'bg-muted text-muted-foreground'">JRE {{ driver.jre }}</span>
<template v-if="driver.installed">
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.installed_version }}</span>
@@ -1184,7 +1185,7 @@ watch(driverStoreTab, (tab) => {
</template>
<span v-if="formatSize(driver.size)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ formatSize(driver.size) }}</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<div class="driver-store-agent-actions flex shrink-0 items-center gap-2">
<Button v-if="!driver.installed && isDriverQueued(driver.db_type)" size="sm" variant="outline" class="h-7 rounded-[6px] border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15" :disabled="upgradingAll" @click="removeQueuedDriverInstall(driver.db_type)">
<Clock3 class="h-3 w-3 mr-1" />
{{ t("driverStore.queued") }}
@@ -1232,7 +1233,7 @@ watch(driverStoreTab, (tab) => {
</TabsContent>
<!-- JDBC Tab -->
<TabsContent value="jdbc" class="mt-5 space-y-5">
<TabsContent value="jdbc" class="driver-store-tab driver-store-jdbc-tab mt-5 space-y-5">
<!-- JDBC Plugin -->
<div class="rounded-xl border bg-muted/20 p-4">
<div class="flex min-h-12 items-center justify-between gap-3">
@@ -1311,7 +1312,7 @@ watch(driverStoreTab, (tab) => {
<Input v-if="jdbcMavenRepository === 'custom'" v-model="customJdbcMavenRepository" class="h-8 text-xs" placeholder="https://repo.example.com/repository/maven-public" @keydown.enter.prevent="installJdbcMavenDriver" />
</div>
<div class="rounded-md border">
<div class="driver-store-jdbc-list rounded-md border">
<div v-if="isLoadingJdbcDrivers" class="p-4 text-sm text-muted-foreground">
{{ t("common.loading") }}
</div>
@@ -1322,8 +1323,8 @@ watch(driverStoreTab, (tab) => {
{{ t("driverStore.noMatchingDrivers") }}
</div>
<div v-else class="divide-y">
<div v-for="item in filteredJdbcDrivers" :key="item.id" class="flex items-center gap-3 p-3">
<div class="min-w-0 flex-1">
<div v-for="item in filteredJdbcDrivers" :key="item.id" class="driver-store-jdbc-row flex items-center gap-3 p-3">
<div class="driver-store-jdbc-name min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<div class="truncate text-sm font-medium">{{ item.title }}</div>
<Badge variant="outline" class="h-5 shrink-0 rounded-full px-2 text-[10px] font-medium">
@@ -1342,7 +1343,7 @@ watch(driverStoreTab, (tab) => {
</TabsContent>
<!-- Runtime Tab -->
<TabsContent value="storage" class="mt-5 space-y-5">
<TabsContent value="storage" class="driver-store-tab driver-store-storage-tab mt-5 space-y-5">
<!-- Storage Usage -->
<div class="rounded-xl border bg-muted/20 p-4 space-y-3">
<div class="flex items-center justify-between gap-3">
@@ -1479,6 +1480,10 @@ watch(driverStoreTab, (tab) => {
<span>{{ runtimeKindLabel(runtime) }}</span>
<span class="text-muted-foreground/50">/</span>
<span>{{ runtimeSourceLabel(runtime) }}</span>
<template v-if="runtime.protocol_mode">
<span class="text-muted-foreground/50">/</span>
<span>{{ driverRuntimeProtocolLabel(runtime) }}</span>
</template>
</div>
</div>
@@ -1528,3 +1533,156 @@ watch(driverStoreTab, (tab) => {
</div>
</div>
</template>
<style>
.driver-store-view,
.driver-store-scroll {
overflow-x: hidden;
}
.driver-store-view {
height: 100%;
min-height: 0;
overflow: hidden;
}
.driver-store-scroll {
height: 100%;
min-height: 0;
overflow-y: auto !important;
}
.driver-store-container {
box-sizing: border-box;
width: 100%;
max-width: none !important;
margin-left: 0 !important;
margin-right: 0 !important;
padding: 1.25rem 1.5rem 1.5rem !important;
}
.driver-store-tabs {
display: grid !important;
width: 360px !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
}
.driver-store-tabs-root {
display: flex !important;
width: 100%;
min-width: 0;
flex-direction: column !important;
}
.driver-store-tabs-root > [data-slot="tabs-content"] {
width: 100%;
min-width: 0;
}
.driver-store-header {
flex-shrink: 0;
}
.driver-store-tab {
min-height: 0;
overflow: visible;
}
.driver-store-tabs-root > [data-slot="tabs-content"][hidden] {
display: none !important;
}
.driver-store-agent-tab,
.driver-store-jdbc-tab {
flex-direction: column;
gap: 1rem;
}
.driver-store-agent-tab:not([hidden]),
.driver-store-jdbc-tab:not([hidden]) {
display: flex !important;
}
.driver-store-agent-tab > :not([hidden]) ~ :not([hidden]),
.driver-store-jdbc-tab > :not([hidden]) ~ :not([hidden]) {
margin-top: 0 !important;
}
.driver-store-agent-tab > *,
.driver-store-jdbc-tab > * {
flex-shrink: 0;
}
.driver-store-agent-row {
display: flex !important;
align-items: center !important;
min-width: 0;
width: 100%;
}
.driver-store-agent-list,
.driver-store-jdbc-list {
width: 100%;
flex: 0 0 auto !important;
min-height: 0;
overflow-y: visible;
overflow-x: hidden;
}
.driver-store-agent-name,
.driver-store-jdbc-name {
flex: 1 1 auto !important;
min-width: 0 !important;
}
.driver-store-agent-meta,
.driver-store-agent-actions,
.driver-store-jdbc-row > .shrink-0,
.driver-store-jdbc-row > button {
flex-shrink: 0 !important;
}
.driver-store-jdbc-row {
display: flex !important;
align-items: center !important;
min-width: 0;
width: 100%;
}
.driver-store-jdbc-row > button {
width: 2rem !important;
height: 2rem !important;
}
@media (max-width: 900px) {
.driver-store-header {
align-items: flex-start !important;
flex-direction: column !important;
gap: 0.75rem;
}
.driver-store-tabs {
width: 100% !important;
}
.driver-store-agent-row {
align-items: flex-start !important;
flex-wrap: wrap;
}
.driver-store-jdbc-row {
align-items: flex-start !important;
flex-wrap: wrap;
}
.driver-store-agent-meta,
.driver-store-agent-actions {
margin-left: 2.75rem;
}
.driver-store-jdbc-row > .shrink-0,
.driver-store-jdbc-row > button {
margin-left: 2.75rem;
}
}
</style>
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { driverRuntimeProtocolLabel } from "./driverRuntimeDisplay";
describe("driverRuntimeProtocolLabel", () => {
it("shows aggregated multi-session counts", () => {
expect(driverRuntimeProtocolLabel({ protocol_mode: "multi_session", active_sessions: 4 })).toBe("Multi-session · 4 sessions");
});
it("labels legacy runtimes and hides unknown modes", () => {
expect(driverRuntimeProtocolLabel({ protocol_mode: "legacy", active_sessions: null })).toBe("Legacy");
expect(driverRuntimeProtocolLabel({ protocol_mode: null, active_sessions: null })).toBeNull();
});
});
@@ -0,0 +1,11 @@
import type { DriverRuntimeInfo } from "@/lib/backend/tauri";
export function driverRuntimeProtocolLabel(runtime: Pick<DriverRuntimeInfo, "protocol_mode" | "active_sessions">): string | null {
if (runtime.protocol_mode === "multi_session") {
return `Multi-session · ${runtime.active_sessions ?? 0} sessions`;
}
if (runtime.protocol_mode === "legacy") {
return "Legacy";
}
return null;
}

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