mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
Compare commits
268
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1180dcfe4 | ||
|
|
5d48a7b974 | ||
|
|
069adbc5ab | ||
|
|
2a09c1c2b2 | ||
|
|
c90a523e95 | ||
|
|
4489ebfcf3 | ||
|
|
381b4a9dd5 | ||
|
|
cbe30ef3a2 | ||
|
|
192eecb1ec | ||
|
|
a3fed878ca | ||
|
|
517f939b66 | ||
|
|
d6777a5ce4 | ||
|
|
75e112be4c | ||
|
|
c8123aaa5b | ||
|
|
c109522318 | ||
|
|
5076569a0e | ||
|
|
845fe37b86 | ||
|
|
9bdf6fb0a0 | ||
|
|
692c63b787 | ||
|
|
ffc2a5b008 | ||
|
|
8ad26fd0ae | ||
|
|
96eca282b6 | ||
|
|
f62f75d585 | ||
|
|
894684b1e5 | ||
|
|
952b15deca | ||
|
|
08a8d9ed77 | ||
|
|
73aee8e976 | ||
|
|
f61e216c30 | ||
|
|
274a944fa2 | ||
|
|
3d87ea5797 | ||
|
|
f0e80bc97f | ||
|
|
6f0c84556a | ||
|
|
a3e368733a | ||
|
|
786ecff8ce | ||
|
|
246df32553 | ||
|
|
04bf3be779 | ||
|
|
2ca2dda15b | ||
|
|
9f4e5643ce | ||
|
|
4ad6410ca3 | ||
|
|
7ca1b011ee | ||
|
|
de5f9a4b73 | ||
|
|
99c5ecf737 | ||
|
|
d80c8c7326 | ||
|
|
ee8cba7daa | ||
|
|
428e6bd69c | ||
|
|
9026e2de17 | ||
|
|
0818de9f9f | ||
|
|
76d34a56ac | ||
|
|
75d2570524 | ||
|
|
aa2cac1948 | ||
|
|
3fa23a6e47 | ||
|
|
b3acccac7c | ||
|
|
2f56bc7802 | ||
|
|
21f90fd0fd | ||
|
|
d70c637541 | ||
|
|
a53ff2cc73 | ||
|
|
d56bb2138f | ||
|
|
ae1804daba | ||
|
|
94f8970efd | ||
|
|
fd0e6ca314 | ||
|
|
c17fbdc8ea | ||
|
|
e63faca4cd | ||
|
|
607d3a9070 | ||
|
|
f95344367c | ||
|
|
4411e41e42 | ||
|
|
95030c353a | ||
|
|
26085752a1 | ||
|
|
3b20148a14 | ||
|
|
3883b96c14 | ||
|
|
1d3ee9e09b | ||
|
|
e32ff2ed5a | ||
|
|
bcf5313f3c | ||
|
|
8b30a42b65 | ||
|
|
b755a5ca1e | ||
|
|
e5ed89083d | ||
|
|
4d5943a784 | ||
|
|
ea8797c03c | ||
|
|
cfdad51e8e | ||
|
|
5204b43077 | ||
|
|
98970ea7d4 | ||
|
|
24523d44f6 | ||
|
|
76c4f346d1 | ||
|
|
7ca34d76a9 | ||
|
|
b06c499902 | ||
|
|
732e081ee4 | ||
|
|
7c5e9dcda0 | ||
|
|
9d8a5efedf | ||
|
|
c1948bff0a | ||
|
|
c243261125 | ||
|
|
89ed17d645 | ||
|
|
be2831eafd | ||
|
|
641a7bd71a | ||
|
|
b351c0f3b8 | ||
|
|
7be6bb59aa | ||
|
|
95e571b687 | ||
|
|
849d831ae2 | ||
|
|
f262f5c80c | ||
|
|
05a57cbfde | ||
|
|
8c20043234 | ||
|
|
bb1628187b | ||
|
|
89ea786e62 | ||
|
|
54cbb4c409 | ||
|
|
2189baacae | ||
|
|
8d19034af9 | ||
|
|
886c30aa3f | ||
|
|
720fad47e5 | ||
|
|
3353411fb6 | ||
|
|
d3dfcc9d3d | ||
|
|
504ef7e1c6 | ||
|
|
92ad833181 | ||
|
|
e67fdc6337 | ||
|
|
83e3513572 | ||
|
|
f37ce68a6b | ||
|
|
e013fffb8a | ||
|
|
64b5682660 | ||
|
|
9aa2d40aab | ||
|
|
9541aeb257 | ||
|
|
8bf9068383 | ||
|
|
ad425d66b1 | ||
|
|
67e8bf8cd3 | ||
|
|
9f26db0278 | ||
|
|
cd07675e83 | ||
|
|
e65f3eec66 | ||
|
|
ba6ca64e5d | ||
|
|
f84137cdd9 | ||
|
|
527b56ac41 | ||
|
|
026e0b539d | ||
|
|
5b2772c444 | ||
|
|
488504f636 | ||
|
|
8a8770066c | ||
|
|
c7403444ea | ||
|
|
5630f8b7f8 | ||
|
|
46366b00ae | ||
|
|
2c48928936 | ||
|
|
a597a65c86 | ||
|
|
609f6805a3 | ||
|
|
d43ba11164 | ||
|
|
74960d9fc8 | ||
|
|
33f7c19675 | ||
|
|
934d731d00 | ||
|
|
380f869e50 | ||
|
|
787899f201 | ||
|
|
63d996b48d | ||
|
|
33b4652d39 | ||
|
|
dab39c690a | ||
|
|
262a538310 | ||
|
|
9f1e297c76 | ||
|
|
376a106bc6 | ||
|
|
79dca4bea9 | ||
|
|
f995e060a8 | ||
|
|
83efdfb65e | ||
|
|
1943484a16 | ||
|
|
d69facab23 | ||
|
|
658a7a405d | ||
|
|
09c47b1851 | ||
|
|
7e324bdd01 | ||
|
|
0fce8f445f | ||
|
|
0f59ecd18d | ||
|
|
4f229a8554 | ||
|
|
4e111225ee | ||
|
|
3d9e590e94 | ||
|
|
03b50e167e | ||
|
|
7530777bac | ||
|
|
1da95288c6 | ||
|
|
090e9037a8 | ||
|
|
7f60b43b43 | ||
|
|
4e144035b1 | ||
|
|
d9338d1a35 | ||
|
|
b1ee471fb3 | ||
|
|
124093eca9 | ||
|
|
ca1ef942fa | ||
|
|
88755811ba | ||
|
|
9ca4ab8c6c | ||
|
|
ae1ee25a76 | ||
|
|
f9ee05fd7d | ||
|
|
c6159213aa | ||
|
|
0503d3b9d1 | ||
|
|
53606ffd63 | ||
|
|
f9680e4a8d | ||
|
|
45c23b1521 | ||
|
|
8025519039 | ||
|
|
32789b1d7d | ||
|
|
8124247657 | ||
|
|
c87351e0df | ||
|
|
ae86a8c73c | ||
|
|
6c380269e7 | ||
|
|
1ed598308a | ||
|
|
f1a7cdb461 | ||
|
|
1539b6233c | ||
|
|
220dfa2170 | ||
|
|
e983f2432c | ||
|
|
55ca94bd30 | ||
|
|
d58a79d6c8 | ||
|
|
f8b7ffe516 | ||
|
|
7c28b6789e | ||
|
|
67b9565979 | ||
|
|
1a57e54f17 | ||
|
|
5067514e8d | ||
|
|
38ce7b5dd2 | ||
|
|
de265287e0 | ||
|
|
25e1c6d25d | ||
|
|
3f3482525a | ||
|
|
36b372891f | ||
|
|
c3c53fee9a | ||
|
|
f19b62be0e | ||
|
|
558b22b717 | ||
|
|
4778aa05b4 | ||
|
|
760423873a | ||
|
|
9a6a2049bf | ||
|
|
fcb239abf4 | ||
|
|
b25154466a | ||
|
|
25931c1063 | ||
|
|
eafde81af1 | ||
|
|
508fd00776 | ||
|
|
2729d83eb8 | ||
|
|
a919333418 | ||
|
|
95138e5a68 | ||
|
|
bf68b93b05 | ||
|
|
1d9223eeed | ||
|
|
7fef9698ac | ||
|
|
d757238b1b | ||
|
|
ae205a318f | ||
|
|
75c9122799 | ||
|
|
320e37aa42 | ||
|
|
a90b00e77a | ||
|
|
30e86367ca | ||
|
|
235a2d2dbb | ||
|
|
c2d9610d1c | ||
|
|
dee12449d9 | ||
|
|
1bd9b821d3 | ||
|
|
a24a559160 | ||
|
|
971a95aa80 | ||
|
|
4dbbfec86f | ||
|
|
c7c54c8239 | ||
|
|
37b1c061d5 | ||
|
|
3f70763531 | ||
|
|
b10c5433da | ||
|
|
8df09c779f | ||
|
|
99d603fe15 | ||
|
|
87e3c0634a | ||
|
|
12479faf00 | ||
|
|
e224690049 | ||
|
|
36ac001d70 | ||
|
|
22c384517e | ||
|
|
90221ee3ac | ||
|
|
2c31c6cf32 | ||
|
|
02bc0cf47e | ||
|
|
2e98a5bb9f | ||
|
|
86659553a6 | ||
|
|
4fadbb9801 | ||
|
|
827c934b18 | ||
|
|
7af2ee1fce | ||
|
|
09a7d21510 | ||
|
|
b12e71455c | ||
|
|
a28b5d5280 | ||
|
|
ee2573f7db | ||
|
|
b66d7313f1 | ||
|
|
8ca612d1f7 | ||
|
|
500c0ae692 | ||
|
|
7b47b64391 | ||
|
|
024dc757a8 | ||
|
|
7bb0431470 | ||
|
|
6606a98af5 | ||
|
|
f951983914 | ||
|
|
9419e18b79 | ||
|
|
8a2241c36e | ||
|
|
e7a48bbcf5 | ||
|
|
645c351789 |
@@ -44,3 +44,6 @@ crates/dbx-core/assets/docs-export.css -text -diff linguist-generated
|
||||
# parser must not depend on the contributor's core.autocrlf: a CRLF checkout
|
||||
# silently rewrites all three files.
|
||||
src-tauri/tests/fixtures/** text eol=lf
|
||||
|
||||
# Preserve the upstream SQL attachment bytes used by the live MySQL regression.
|
||||
crates/dbx-core/tests/fixtures/qrtz.sql -text
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ko_fi: dbxio
|
||||
custom: ['https://t8y2.com/sponsors/', 'https://afdian.com/a/t8y2o']
|
||||
@@ -86,6 +86,30 @@ and Java rather than Docker; those matrix entries explicitly retain Java 21.
|
||||
| Cassandra | 3.11.19, 5.0.6 |
|
||||
| RabbitMQ | 3.13, 4.3 |
|
||||
|
||||
## Rust caches and build timings
|
||||
|
||||
Linux package, clippy, Rust test and native Agent jobs use the same pinned sccache
|
||||
compiler wrappers on main and fork PRs. Main uses the existing S3 backend; jobs
|
||||
without repository secrets use sccache's local disk backend. Only backend settings
|
||||
are conditional: changing `CC` or `CXX` invalidates native build-script fingerprints
|
||||
even if a target archive was restored. The cache key retains the real compiler
|
||||
environment; only the CI selection variables are excluded. Full/fast features and
|
||||
test-group selection remain unchanged and can still require additional builds.
|
||||
|
||||
All Rust target caches use the `v1-rust-complete` namespace and save only after a
|
||||
successful job, from main. This bypasses old partial snapshots without deleting
|
||||
caches or changing the S3 namespace. A cancelled or failed first build must not
|
||||
publish a shared snapshot: rust-cache does not update an exact cache hit, so later
|
||||
successful builds would otherwise keep restoring the unfinished snapshot.
|
||||
The first successful main run must populate the new namespace before fork PRs can
|
||||
reuse it. Target caching is not a per-commit snapshot of workspace artifacts.
|
||||
|
||||
Clippy, nextest and native Agent nextest builds emit Cargo HTML timing reports.
|
||||
Their `DBX-*-cargo-timings` artifacts are retained for three days, including after
|
||||
failures when a report exists, but uploads do not delay cancelled runs. Inspect
|
||||
these reports alongside sccache statistics: its hit rate excludes unsupported
|
||||
crate types and does not measure the time spent compiling and linking test binaries.
|
||||
|
||||
## Validation
|
||||
|
||||
Run these from the repository root:
|
||||
|
||||
@@ -277,8 +277,11 @@ test("nextest, doctests and coverage select identical packages and features in e
|
||||
const nextest = rustCommand("test", group, mode);
|
||||
const doctest = rustCommand("doctest", group, mode);
|
||||
assert.deepEqual(nextest.slice(0, 3), ["nextest", "run", "--no-fail-fast"]);
|
||||
assert.deepEqual(doctest, ["test", "--doc", ...nextest.slice(3)]);
|
||||
assert.deepEqual(rustCommand("tree", group, mode), ["tree", ...nextest.slice(3)]);
|
||||
const selection = nextest.slice(3).filter((argument) => argument !== "--timings");
|
||||
assert.deepEqual(doctest, ["test", "--doc", ...selection]);
|
||||
assert.deepEqual(rustCommand("tree", group, mode), ["tree", ...selection]);
|
||||
assert.ok(nextest.includes("--timings"));
|
||||
assert.ok(rustCommand("clippy", "workspace", mode).includes("--timings"));
|
||||
assert.ok(nextest.includes("--no-default-features"));
|
||||
assert.ok(nextest.includes("--locked"));
|
||||
}
|
||||
|
||||
@@ -8,21 +8,21 @@ export function rustCommand(action, group, mode) {
|
||||
|| (group !== "workspace" && !Object.hasOwn(rustGroups, group)) || (action === "clippy" && group !== "workspace")) {
|
||||
throw new Error(`Unknown Rust CI configuration: ${action}/${group}/${mode}`);
|
||||
}
|
||||
const capabilityFeatures = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher"];
|
||||
const capabilityFeatures = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "sqlserver-native-attention"];
|
||||
if (mode === "full") capabilityFeatures.push("system-fonts");
|
||||
const appFeatures = ["dbx", "dbx-core", "dbx-web"].flatMap((name) => capabilityFeatures.map((feature) => `${name}/${feature}`));
|
||||
const foundationFeatures = ["dbx-types/mq-admin", "dbx-types/openapi", "dbx-sql/duckdb-sidecar", "dbx-sql/openapi",
|
||||
"dbx-sql-data/openapi", "dbx-sql-schema/duckdb-sidecar",
|
||||
"dbx-platform/downloads", "dbx-platform/host-prompts", "dbx-platform/test-support", "dbx-plugin-runtime/default", "dbx-plugin-runtime/test-support"];
|
||||
const features = group === "foundation" ? foundationFeatures : group === "drivers" ? [
|
||||
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "test-support"].map((feature) => `dbx-drivers/${feature}`),
|
||||
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "sqlserver-native-attention", "test-support"].map((feature) => `dbx-drivers/${feature}`),
|
||||
"dbx-driver-agent/test-support", "dbx-driver-mysql/test-support", "dbx-driver-redis/test-support",
|
||||
"dbx-driver-support/test-support",
|
||||
"dbx-sqlite-worker/runtime", "dbx-types/openapi", "dbx-sql-data/openapi",
|
||||
] : appFeatures;
|
||||
const packages = group === "workspace" ? ["--workspace"] : rustGroups[group].flatMap((name) => ["--package", name]);
|
||||
const command = action === "test" ? ["nextest", "run", "--no-fail-fast"] : action === "doctest" ? ["test", "--doc"] : [action];
|
||||
return [...command, ...packages, "--locked", ...(action === "clippy" ? ["--all-targets"] : []),
|
||||
return [...command, ...packages, "--locked", ...(["test", "clippy"].includes(action) ? ["--timings"] : []), ...(action === "clippy" ? ["--all-targets"] : []),
|
||||
"--no-default-features", "--features", features.join(","), ...(action === "clippy" ? ["--", "-D", "warnings"] : [])];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const workflow = readFileSync(new URL("../workflows/ci.yml", import.meta.url), "utf8");
|
||||
@@ -83,10 +86,101 @@ test("every old Agent stage has an independent owner and Java packaging remains
|
||||
for (const name of ["agent-rust", "agent-go", "agent-integration", "agent-java"]) assert.doesNotMatch(job(name), /continue-on-error: true/);
|
||||
});
|
||||
|
||||
test("native Rust driver caches exclude failed build artifacts", () => {
|
||||
const content = job("agent-rust");
|
||||
assert.ok(content.includes("shared-key: ci-agent-rust-v2-${{ matrix.driver }}"));
|
||||
assert.ok(content.includes("cache-on-failure: false"));
|
||||
test("shared Rust caches publish only complete builds outside the old cache namespace", () => {
|
||||
for (const name of ["packages", "rust-fmt-clippy", "rust-test", "agent-rust", "agent-integration"]) {
|
||||
const content = job(name);
|
||||
assert.ok(content.includes("prefix-key: v1-rust-complete"), name);
|
||||
assert.ok(content.includes("cache-on-failure: false"), name);
|
||||
assert.doesNotMatch(content, /cache-on-failure: true/);
|
||||
assert.ok(content.includes("save-if: ${{ github.ref == 'refs/heads/main'"), name);
|
||||
}
|
||||
});
|
||||
|
||||
test("main and fork Rust jobs use the same rust-cache fingerprint", () => {
|
||||
const stableCacheEnvironment = /- name: Rust cache\s+env:\s+RUST_FEATURE_MODE: ""\s+RUST_TEST_GROUP: ""\s+uses: swatinem\/rust-cache@v2/;
|
||||
for (const name of ["packages", "rust-fmt-clippy", "rust-test", "agent-rust"]) {
|
||||
const content = job(name);
|
||||
assert.match(content, stableCacheEnvironment);
|
||||
assert.match(content, /^ RUSTC_WRAPPER: sccache$/m);
|
||||
assert.ok(content.includes('version: "v0.16.0"'));
|
||||
assert.doesNotMatch(content, /RUSTC_WRAPPER: ""|CC: ""|CXX: ""/);
|
||||
}
|
||||
});
|
||||
|
||||
test("S3 and secretless Rust jobs export identical compiler wrappers", (context) => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-sccache-"));
|
||||
context.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const compiler = "/opt/hostedtoolcache/sccache/0.16.0/x64/sccache";
|
||||
const backend = {
|
||||
SCCACHE_BUCKET: "test-bucket",
|
||||
SCCACHE_ENDPOINT: "https://cache.example.test",
|
||||
SCCACHE_REGION: "test-region",
|
||||
SCCACHE_S3_KEY_PREFIX: "test-prefix",
|
||||
SCCACHE_S3_USE_SSL: "true",
|
||||
AWS_ACCESS_KEY_ID: "test-access-key",
|
||||
AWS_SECRET_ACCESS_KEY: "test-secret-key",
|
||||
};
|
||||
for (const name of ["packages", "rust-fmt-clippy", "rust-test", "agent-rust"]) {
|
||||
const setup = job(name).match(/- name: Configure sccache\n([\s\S]*?)(?=\n - name: Rust cache)/)?.[1];
|
||||
assert.ok(setup, name);
|
||||
assert.doesNotMatch(setup, /^\s+if:/m);
|
||||
const script = setup.match(/ run: \|\n([\s\S]*)/)[1].split("\n").map((line) => line.slice(10)).join("\n");
|
||||
for (const remote of [true, false]) {
|
||||
const output = path.join(directory, `${name}-${remote}.env`);
|
||||
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
GITHUB_ENV: output,
|
||||
SCCACHE_PATH: compiler,
|
||||
CACHE_BUCKET: remote ? backend.SCCACHE_BUCKET : "",
|
||||
CACHE_ENDPOINT: remote ? backend.SCCACHE_ENDPOINT : "",
|
||||
CACHE_REGION: remote ? backend.SCCACHE_REGION : "",
|
||||
CACHE_KEY_PREFIX: remote ? backend.SCCACHE_S3_KEY_PREFIX : "",
|
||||
CACHE_ACCESS_KEY_ID: remote ? backend.AWS_ACCESS_KEY_ID : "",
|
||||
CACHE_SECRET_ACCESS_KEY: remote ? backend.AWS_SECRET_ACCESS_KEY : "",
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, "");
|
||||
const exported = Object.fromEntries(readFileSync(output, "utf8").trim().split("\n").map((line) => {
|
||||
const separator = line.indexOf("=");
|
||||
return [line.slice(0, separator), line.slice(separator + 1)];
|
||||
}));
|
||||
assert.deepEqual(exported, {
|
||||
...(remote ? backend : {}),
|
||||
SCCACHE_IDLE_TIMEOUT: "0",
|
||||
CC: `${compiler} cc`,
|
||||
CXX: `${compiler} c++`,
|
||||
}, `${name}: ${remote ? "S3" : "local"}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Rust build timings remain available after failures without waiting for cancelled runs", () => {
|
||||
for (const [name, artifact, location] of [
|
||||
["rust-fmt-clippy", "DBX-clippy-cargo-timings", "target/cargo-timings/"],
|
||||
["rust-test", "DBX-rust-${{ matrix.group }}-cargo-timings", "target/cargo-timings/"],
|
||||
["agent-rust", "DBX-agent-${{ matrix.driver }}-cargo-timings", "agents/drivers/${{ matrix.driver }}/target/cargo-timings/"],
|
||||
]) {
|
||||
const content = job(name);
|
||||
assert.match(content, /name: Upload Rust Cargo timings\s+if: always\(\) && !cancelled\(\)\s+uses: actions\/upload-artifact@v6/);
|
||||
assert.ok(content.includes(`name: ${artifact}`));
|
||||
assert.ok(content.includes(`path: ${location}`));
|
||||
assert.ok(content.includes("retention-days: 3"));
|
||||
assert.ok(content.includes("if-no-files-found: ignore"));
|
||||
}
|
||||
assert.ok(job("agent-rust").includes('--manifest-path "drivers/$DRIVER/Cargo.toml" --locked --no-fail-fast --timings'));
|
||||
});
|
||||
|
||||
test("Linux Rust dependency installs bound apt mirror stalls", () => {
|
||||
for (const name of ["packages", "rust-fmt-clippy", "rust-test", "agent-rust"]) {
|
||||
const content = job(name);
|
||||
assert.equal((content.match(/sudo timeout --kill-after=30s 10m apt-get/g) ?? []).length, 2);
|
||||
for (const option of ["Acquire::Retries=3", "Acquire::http::Timeout=30", "Acquire::https::Timeout=30"]) {
|
||||
assert.ok(content.includes(option));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Rust test jobs install pinned nextest and retain separate doctests", () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# UI preview
|
||||
|
||||
`.github/workflows/ui-preview.yml` shows reviewers what a pull request does to the UI, the way
|
||||
[yetone/magpie](https://github.com/yetone/magpie) does for its own — this is a port of that
|
||||
pipeline, adapted to dbx's Tauri/frontend split. When a PR touches `apps/desktop/src/` (or
|
||||
carries the `ui-preview` label), the workflow does this:
|
||||
|
||||
1. **publish.mjs detect** decides whether the PR is a UI change, and which lane it is:
|
||||
a diff that reaches `crates/`, `plugins/` or `src-tauri/` is a *rust* lane, anything else
|
||||
is a *web* lane. It puts a "recording…" note in a fixed block at the foot of the PR
|
||||
description, between `<!-- dbx-ui-preview:begin -->` and `<!-- dbx-ui-preview:end -->`.
|
||||
2. **The record job** builds the PR's frontend (`pnpm build`) and runs it against a backend:
|
||||
the released dbx-web static binary (web lane — no Rust is built; the PR's `dist/` replaces
|
||||
the embedded frontend via `DBX_STATIC_DIR`), or dbx-web built from the PR (rust lane).
|
||||
**run.sh** starts it headless (`DBX_DISABLE_PASSWORD=1`, an isolated home), then
|
||||
**seed.mjs** fills it with made-up data, so a change that only shows with data can be seen:
|
||||
- two SQLite databases with fixed, deterministic content (orders/products/customers with
|
||||
a view, a trigger and foreign keys; an events table that pages), saved as two
|
||||
connections and really connected and queried through the app;
|
||||
- a saved-SQL folder with three files, and three days of query history (real executions
|
||||
plus seeded entries, a few failures among them).
|
||||
3. **record.mjs**, which:
|
||||
- gives DeepSeek the diff, an outline of the running app, and the changed frontend files;
|
||||
- has it plan scenes: what to click, double-click, hover, type and capture;
|
||||
- walks the plan in Chromium with `cursor.js` drawing the pointer, its trail and each
|
||||
click. It always takes screenshots. When the plan interacts, it also records an mp4,
|
||||
never a GIF — each scene on a page of its own, its load trimmed off and the takes
|
||||
joined, so scene changes read as cuts rather than the app reloading. A failed step
|
||||
is re-planned from the page as it is (up to three times),
|
||||
after an Escape clears anything the failure left open (a menu, a dialog); what still
|
||||
fails is listed honestly in the PR block, and shots taken after a failure in their
|
||||
scene are marked ⚠️ both there and on the Pages player.
|
||||
4. **publish.mjs publish** puts the files under `pr-<n>/<sha>/` on the `ui-previews` branch,
|
||||
which GitHub Pages serves, and rewrites the block in the description: the video's poster
|
||||
links to the player page (GitHub won't embed a video it didn't host), the screenshots are
|
||||
shown inline, and the plan's summary / mismatch (what the description claims but the diff
|
||||
doesn't do) / unseen (what this sandbox can't show) notes go on top.
|
||||
|
||||
Security: the recording job runs the PR's code without asking anyone — its frontend build
|
||||
scripts, and in the rust lane the PR's backend itself. Any PR's code can therefore read
|
||||
the planner's API key (`PLAN_API_KEY`, environment `ui-preview`), so the key there should
|
||||
have a low spending limit. That job has no write access, and the job that writes never
|
||||
runs the PR's code. The planner talks to any OpenAI-compatible endpoint
|
||||
(`PLAN_API_URL`, default AtlasCloud; `PLAN_MODEL`, overridable via the
|
||||
`UI_PREVIEW_PLAN_MODEL` repo variable).
|
||||
|
||||
Known limits: the recording is of the web build, so desktop-shell behaviour (detached tabs,
|
||||
OS dialogs) is not covered; in the web lane the backend is the released dbx-web, so a PR
|
||||
whose frontend calls an endpoint newer than the release records that call failing — the
|
||||
honest-notes fields are the place that shows up.
|
||||
|
||||
To run it locally against a dbx-web that already runs somewhere (all of record.mjs, none of
|
||||
the starting):
|
||||
|
||||
```sh
|
||||
cd .github/ui-preview && npm install && npx playwright install chromium
|
||||
DBX_URL=http://127.0.0.1:4280 PLAN_API_KEY=… DIFF_FILE=pr.diff PR_TITLE=… \
|
||||
OUT_DIR=/tmp/out SRC_DIR=../.. node record.mjs # a plan from DeepSeek
|
||||
PLAN_FILE=plan.json DBX_URL=… … node record.mjs # or a hand-written plan
|
||||
```
|
||||
|
||||
`seed.mjs dbs <dir>` writes the databases; `seed.mjs api <url> <dir> [--as <server path>]`
|
||||
seeds a running instance (pass `--as` when the databases live where dbx-web runs, not where
|
||||
you run the script — testing over an SSH tunnel).
|
||||
|
||||
To re-run it for a PR, use `gh workflow run ui-preview.yml -f pr=<n>`.
|
||||
@@ -0,0 +1,107 @@
|
||||
// Drawn into every page the recorder opens, so the video shows where the
|
||||
// mouse is, the path it took and each click: an arrow on top of everything,
|
||||
// its trail fading behind it, a ring where a button went down. The recorder
|
||||
// sets captions with window.__uiPreviewCaption(text). None of it takes
|
||||
// pointer events, so the app under it behaves as it would.
|
||||
(() => {
|
||||
if (window.__uiPreviewCursor) return;
|
||||
window.__uiPreviewCursor = true;
|
||||
const TRAIL_MS = 1400;
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
let root, canvas, ctx, arrow, caption;
|
||||
const points = []; // {x, y, t}
|
||||
const rings = []; // {x, y, t}
|
||||
let x = -100, y = -100, down = false;
|
||||
|
||||
function mount() {
|
||||
if (root?.isConnected) return;
|
||||
root = document.createElement("div");
|
||||
root.setAttribute("data-ui-preview", "");
|
||||
root.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;contain:strict;";
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;";
|
||||
ctx = canvas.getContext("2d");
|
||||
arrow = document.createElementNS(ns, "svg");
|
||||
arrow.setAttribute("width", "26");
|
||||
arrow.setAttribute("height", "26");
|
||||
arrow.setAttribute("viewBox", "0 0 26 26");
|
||||
arrow.style.cssText = "position:absolute;left:0;top:0;transform-origin:3px 3px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.35));transition:scale .08s;";
|
||||
arrow.innerHTML = '<path d="M3 2.5v18.2l4.6-4.4 3.1 7.2 3.3-1.4-3.1-7.1h6.4z" fill="#111" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/>';
|
||||
caption = document.createElement("div");
|
||||
caption.style.cssText = "position:absolute;left:50%;bottom:18px;translate:-50% 0;max-width:80%;padding:7px 14px;border-radius:9px;background:rgba(17,17,17,.82);color:#fff;font:500 14px/1.4 -apple-system,'Segoe UI','Noto Sans CJK SC','PingFang SC',sans-serif;opacity:0;transition:opacity .2s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
|
||||
root.append(canvas, arrow, caption);
|
||||
document.documentElement.append(root);
|
||||
size();
|
||||
}
|
||||
function size() {
|
||||
const r = devicePixelRatio || 1;
|
||||
canvas.width = innerWidth * r;
|
||||
canvas.height = innerHeight * r;
|
||||
ctx.setTransform(r, 0, 0, r, 0, 0);
|
||||
}
|
||||
|
||||
function frame() {
|
||||
mount();
|
||||
const now = performance.now();
|
||||
while (points.length && now - points[0].t > TRAIL_MS) points.shift();
|
||||
while (rings.length && now - rings[0].t > 900) rings.shift();
|
||||
ctx.clearRect(0, 0, innerWidth, innerHeight);
|
||||
// the path: each segment as faint as it is old
|
||||
ctx.lineCap = ctx.lineJoin = "round";
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const a = points[i - 1], b = points[i];
|
||||
const age = (now - b.t) / TRAIL_MS;
|
||||
ctx.strokeStyle = `rgba(255, 59, 48, ${0.75 * (1 - age)})`;
|
||||
ctx.lineWidth = 3.5 * (1 - age) + 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
// a click: a ring that opens and fades, a dot where it landed
|
||||
for (const r of rings) {
|
||||
const k = (now - r.t) / 900;
|
||||
ctx.strokeStyle = `rgba(255, 59, 48, ${1 - k})`;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, 8 + 26 * Math.sqrt(k), 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = `rgba(255, 59, 48, ${0.9 * (1 - k)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
if (down) {
|
||||
ctx.fillStyle = "rgba(255, 59, 48, .35)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
arrow.style.translate = `${x - 3}px ${y - 3}px`;
|
||||
arrow.style.scale = down ? "0.85" : "1";
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
const opts = { capture: true, passive: true };
|
||||
addEventListener("mousemove", (e) => {
|
||||
x = e.clientX; y = e.clientY;
|
||||
points.push({ x, y, t: performance.now() });
|
||||
}, opts);
|
||||
addEventListener("mousedown", (e) => {
|
||||
x = e.clientX; y = e.clientY; down = true;
|
||||
rings.push({ x, y, t: performance.now() });
|
||||
}, opts);
|
||||
addEventListener("mouseup", () => { down = false; }, opts);
|
||||
addEventListener("resize", () => canvas && size());
|
||||
|
||||
window.__uiPreviewCaption = (text) => {
|
||||
mount();
|
||||
caption.textContent = text || "";
|
||||
caption.style.opacity = text ? "1" : "0";
|
||||
};
|
||||
// the recorder's last known spot, carried across a page load
|
||||
window.__uiPreviewAt = (px, py) => { x = px; y = py; };
|
||||
|
||||
if (document.readyState === "loading") addEventListener("DOMContentLoaded", () => requestAnimationFrame(frame));
|
||||
else requestAnimationFrame(frame);
|
||||
})();
|
||||
Generated
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "dbx-ui-preview",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dbx-ui-preview",
|
||||
"dependencies": {
|
||||
"playwright": "1.49.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.49.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "dbx-ui-preview",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"seed": "node seed.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "1.49.1"
|
||||
}
|
||||
}
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
// The trusted half of the UI preview: never runs the PR's code.
|
||||
//
|
||||
// node publish.mjs detect — does the PR touch the UI? (GITHUB_OUTPUT: ui,
|
||||
// lane, sha, number) and, when it does, puts
|
||||
// the "recording" note in the PR's description
|
||||
// node publish.mjs publish — the recording (OUT_DIR, from record.mjs) onto
|
||||
// the ui-previews branch, served by Pages, and
|
||||
// the preview into the PR's description
|
||||
//
|
||||
// The preview lives in one fixed block at the foot of the description,
|
||||
// between the markers below; each run replaces that block and leaves the
|
||||
// rest of the description as the author wrote it.
|
||||
//
|
||||
// lane: "rust" when the diff reaches the backend (crates/, plugins/,
|
||||
// src-tauri/) — then CI builds dbx-web from the PR; anything else builds
|
||||
// only the frontend and runs it against the released dbx-web.
|
||||
//
|
||||
// env: GITHUB_TOKEN, GITHUB_REPOSITORY, PR, OUT_DIR, RECORD_RESULT, RUN_URL
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const BEGIN = "<!-- dbx-ui-preview:begin -->";
|
||||
const END = "<!-- dbx-ui-preview:end -->";
|
||||
const BRANCH = "ui-previews";
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const pr = Number(process.env.PR);
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const runURL = process.env.RUN_URL || "";
|
||||
// what counts as the UI: the app's own sources, not their tests
|
||||
const NOT_TEST = (f) => !/(__tests__|\.spec\.|\.test\.|\.bench\.)/.test(f);
|
||||
const UI = (f) => /^apps\/desktop\/src\//.test(f) && NOT_TEST(f);
|
||||
const RUST = (f) => /^(crates|plugins|src-tauri)\//.test(f);
|
||||
|
||||
async function gh(p, init = {}) {
|
||||
const res = await fetch(`https://api.github.com${p}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", ...init.headers },
|
||||
});
|
||||
if (!res.ok) throw new Error(`${init.method || "GET"} ${p}: ${res.status} ${(await res.text()).slice(0, 300)}`);
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function files() {
|
||||
const out = [];
|
||||
for (let page = 1; page <= 30; page++) {
|
||||
const batch = await gh(`/repos/${repo}/pulls/${pr}/files?per_page=100&page=${page}`);
|
||||
out.push(...batch.map((f) => f.filename));
|
||||
if (batch.length < 100) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// the description with the block in place of the old one, or at its foot
|
||||
function withBlock(body, block) {
|
||||
body = body || "";
|
||||
const a = body.indexOf(BEGIN), b = body.indexOf(END);
|
||||
if (a >= 0 && b > a) return body.slice(0, a) + block + body.slice(b + END.length);
|
||||
return body.replace(/\s*$/, "") + "\n\n" + block;
|
||||
}
|
||||
async function setBlock(inner) {
|
||||
// read just before writing, so an edit the author just made is kept
|
||||
const cur = await gh(`/repos/${repo}/pulls/${pr}`);
|
||||
const next = withBlock(cur.body, `${BEGIN}\n${inner.trim()}\n${END}`);
|
||||
if (next !== cur.body) await gh(`/repos/${repo}/pulls/${pr}`, { method: "PATCH", body: JSON.stringify({ body: next }) });
|
||||
}
|
||||
|
||||
const short = (sha) => sha.slice(0, 7);
|
||||
const head = (sha) => `---\n### 🎬 界面预览\n<sub>CI 用这个 PR 的前端构建并真实运行 dbx(沙盒环境,dbx-web 后端 + 本地 SQLite 种子数据),按改动自动操作、截图和录屏 · <code>${short(sha)}</code>${runURL ? ` · [运行记录](${runURL})` : ""}</sub>\n`;
|
||||
|
||||
async function detect() {
|
||||
const p = await gh(`/repos/${repo}/pulls/${pr}`);
|
||||
const changed = await files();
|
||||
const forced = p.labels.some((l) => l.name === "ui-preview");
|
||||
const ui = forced || changed.some(UI);
|
||||
const lane = changed.some(RUST) ? "rust" : "web";
|
||||
const out = process.env.GITHUB_OUTPUT;
|
||||
const lines = [`ui=${ui}`, `lane=${lane}`, `sha=${p.head.sha}`, `number=${pr}`];
|
||||
if (out) await fs.appendFile(out, lines.join("\n") + "\n");
|
||||
console.log(lines.join(" "), "·", changed.filter(UI).join(", ") || "no UI files");
|
||||
if (ui && p.state === "open") await setBlock(`${head(p.head.sha)}\n⏳ 正在录制这次提交的界面改动…`);
|
||||
}
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
// the recording onto the ui-previews branch: one commit, replaced each time
|
||||
// so the branch never piles up old videos; another PR's run pushing in
|
||||
// between makes this one start over from theirs
|
||||
async function pushMedia(sha, dir) {
|
||||
const work = await fs.mkdtemp(path.join(os.tmpdir(), "ui-previews-"));
|
||||
const remote = `https://x-access-token:${token}@github.com/${repo}.git`;
|
||||
git(["init", "-q"], work);
|
||||
git(["config", "user.name", "github-actions[bot]"], work);
|
||||
git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"], work);
|
||||
const open = new Set((await gh(`/repos/${repo}/pulls?state=open&per_page=100`)).map((p) => p.number));
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
let base = "";
|
||||
try {
|
||||
git(["fetch", "-q", "--depth=1", remote, BRANCH], work);
|
||||
base = git(["rev-parse", "FETCH_HEAD"], work);
|
||||
git(["checkout", "-q", "-f", "--detach", "FETCH_HEAD"], work);
|
||||
} catch { /* the first one */ }
|
||||
// this PR's latest run only, and no PR that's been closed
|
||||
for (const d of await fs.readdir(work).catch(() => [])) {
|
||||
const m = /^pr-(\d+)$/.exec(d);
|
||||
if (m && (Number(m[1]) === pr || !open.has(Number(m[1])))) await fs.rm(path.join(work, d), { recursive: true, force: true });
|
||||
}
|
||||
const to = path.join(work, `pr-${pr}`, short(sha));
|
||||
await fs.mkdir(to, { recursive: true });
|
||||
for (const f of await fs.readdir(dir)) await fs.copyFile(path.join(dir, f), path.join(to, f));
|
||||
await fs.writeFile(path.join(to, "index.html"), player(sha, JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8"))));
|
||||
await fs.writeFile(path.join(work, ".nojekyll"), "");
|
||||
await fs.writeFile(path.join(work, "index.html"), `<!doctype html><meta charset="utf-8"><title>dbx UI previews</title><p>Screenshots and recordings of <a href="https://github.com/${repo}/pulls">${repo}</a> pull requests, made by .github/workflows/ui-preview.yml.</p>`);
|
||||
git(["checkout", "-q", "--orphan", `tmp-${attempt}`], work);
|
||||
git(["add", "-A"], work);
|
||||
git(["commit", "-q", "-m", `ui preview for #${pr} at ${short(sha)}`], work);
|
||||
try {
|
||||
git(["push", "-q", `--force-with-lease=${BRANCH}:${base}`, remote, `HEAD:refs/heads/${BRANCH}`], work);
|
||||
await fs.rm(work, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log(`push lost a race (${e.message.split("\n")[0]}), again`);
|
||||
await new Promise((r) => setTimeout(r, 2000 + Math.random() * 4000));
|
||||
}
|
||||
}
|
||||
throw new Error("couldn't push the preview");
|
||||
}
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
|
||||
// the page the video plays on: GitHub shows no video it didn't host itself
|
||||
function player(sha, m) {
|
||||
const shots = m.scenes.flatMap((s) => s.shots.map((x) => ({ ...x, scene: s.title })));
|
||||
const warned = m.errors?.length || shots.some((x) => x.warn);
|
||||
return `<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>#${pr} 界面预览 · ${short(sha)}</title>
|
||||
<style>:root{color-scheme:light dark;--bg:#f6f6f7;--fg:#1d1d1f;--muted:#6e6e73;--card:#fff;--line:#e5e5ea}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#111113;--fg:#f2f2f4;--muted:#9a9aa0;--card:#1c1c1f;--line:#2c2c30}}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 -apple-system,"Segoe UI","PingFang SC","Noto Sans CJK SC",sans-serif}
|
||||
main{max-width:1200px;margin:0 auto;padding:28px 16px 60px}h1{font-size:20px;margin:0 0 4px}.m{color:var(--muted);font-size:13px;margin:0 0 20px}
|
||||
.warn{margin:0 0 20px;padding:10px 14px;border:1px solid #c0392b;border-radius:10px;background:rgba(192,57,43,.07);color:#c0392b;font-size:14px}
|
||||
figure.shaky img{border-color:#c0392b}
|
||||
video,img{display:block;width:100%;border-radius:10px;border:1px solid var(--line);background:var(--card)}figure{margin:0 0 28px}figcaption{color:var(--muted);font-size:13px;margin-top:8px}
|
||||
a{color:inherit}</style>
|
||||
<main><h1>#${pr} 界面预览</h1><p class="m">commit ${short(sha)} · <a href="https://github.com/${repo}/pull/${pr}">回到 PR</a>${m.summary ? " · " + esc(m.summary) : ""}</p>
|
||||
${warned ? `<p class="warn">⚠️ 这次录制有 ${m.errors?.length || 0} 步没能按计划执行${shots.some((x) => x.warn) ? ";带 ⚠️ 的截图拍摄于步骤失败之后,画面未必是说明文字所描述的状态" : ""}。</p>` : ""}
|
||||
${m.video ? `<figure><video src="${m.video}" controls autoplay muted playsinline poster="${m.poster || ""}"></video><figcaption>红线是鼠标走过的轨迹,红色圆环是一次点击;底部文字是这一步在做什么。多个场景各自从应用初始状态开始。空格暂停,← → 逐段查看。</figcaption></figure>` : ""}
|
||||
${shots.map((x) => `<figure${x.warn ? ' class="shaky"' : ""}><img src="${esc(x.file)}" alt="${esc(x.caption)}"><figcaption>${x.warn ? "⚠️ " : ""}${esc(x.scene)} · ${esc(x.caption)}</figcaption></figure>`).join("\n")}
|
||||
</main></html>`;
|
||||
}
|
||||
|
||||
async function pagesURL() {
|
||||
try { return (await gh(`/repos/${repo}/pages`)).html_url.replace(/\/?$/, "/"); }
|
||||
catch { const [o, n] = repo.split("/"); return `https://${o.toLowerCase()}.github.io/${n}/`; }
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
const p = await gh(`/repos/${repo}/pulls/${pr}`);
|
||||
const sha = process.env.SHA || p.head.sha;
|
||||
const dir = process.env.OUT_DIR;
|
||||
const result = process.env.RECORD_RESULT || "success";
|
||||
let m = null;
|
||||
try { m = JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8")); } catch {}
|
||||
if (result === "cancelled" && !m) return; // a newer commit took over
|
||||
if (!m || m.leak || (!m.scenes.some((s) => s.shots.length) && !m.video)) {
|
||||
const why = m?.leak ? "录制时屏幕上出现了密钥,已全部丢弃,没有发布任何截图"
|
||||
: m?.skipped === "no visible UI change found in the diff" ? "看了改动,没有找到用户能看到的界面变化,所以没有录制"
|
||||
: `这次没录成(${result})`;
|
||||
const errs = m?.errors?.length ? `\n\n<details><summary>细节</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>` : "";
|
||||
await setBlock(`${head(sha)}\n${m?.summary ? `**改动**(按代码):${m.summary}\n\n` : ""}${m?.mismatch ? `> [!WARNING]\n> **描述与代码不符**:${m.mismatch}\n\n` : ""}${why}。${runURL ? `详见[运行记录](${runURL})。` : ""}${errs}`);
|
||||
return;
|
||||
}
|
||||
await pushMedia(sha, dir);
|
||||
const raw = `https://raw.githubusercontent.com/${repo}/${BRANCH}/pr-${pr}/${short(sha)}/`;
|
||||
const site = `${await pagesURL()}pr-${pr}/${short(sha)}/`;
|
||||
let md = `${head(sha)}\n`;
|
||||
if (m.summary) md += `**改动**(按代码):${m.summary}\n\n`;
|
||||
if (m.mismatch) md += `> [!WARNING]\n> **描述与代码不符**:${m.mismatch}\n\n`;
|
||||
if (m.unseen) md += `> [!NOTE]\n> **沙盒里看不到**:${m.unseen}\n\n`;
|
||||
if (m.errors?.length) md += `<details><summary>⚠️ 有 ${m.errors.length} 步没能照计划执行</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>\n\n`;
|
||||
if (m.video) {
|
||||
md += `[](${site})\n\n`;
|
||||
md += `<sub>▶️ 点图打开录屏(可暂停、拖动)· [直接下载 mp4](${raw}${m.video}) · 红线是鼠标轨迹,红色圆环是点击</sub>\n\n`;
|
||||
}
|
||||
for (const s of m.scenes) {
|
||||
if (!s.shots.length) continue;
|
||||
md += `#### ${s.title}\n\n`;
|
||||
// shown at its own size (they're taken at 2x), a full page no wider than 760
|
||||
for (const x of s.shots) md += `<img src="${raw}${x.file}" width="${Math.min(760, Math.round((x.width || 1520) / 2))}" alt="${esc(x.caption)}">\n\n${x.warn ? "⚠️ " : ""}${x.caption}\n\n`;
|
||||
}
|
||||
await setBlock(md);
|
||||
}
|
||||
|
||||
const cmd = process.argv[2];
|
||||
(cmd === "detect" ? detect() : cmd === "publish" ? publish() : Promise.reject(new Error("detect | publish")))
|
||||
.catch((e) => { console.error(e); process.exit(1); });
|
||||
Executable
+704
@@ -0,0 +1,704 @@
|
||||
// Records a PR's UI changes in the real app: dbx-web is already running
|
||||
// (run.sh), this reads the PR's diff, has DeepSeek plan what to show from it
|
||||
// and the page as it really is, then walks the plan in Chromium with the
|
||||
// mouse drawn in (cursor.js) — screenshots always, a video when the change
|
||||
// needs clicking to see. Everything lands in OUT_DIR with a manifest.json
|
||||
// that publish.mjs turns into the PR's preview.
|
||||
//
|
||||
// env: DBX_URL (the app's link), DIFF_FILE, PR_TITLE, PR_BODY_FILE, OUT_DIR,
|
||||
// SRC_DIR (the PR's source, for code context), DEEPSEEK_API_KEY, SECRETS
|
||||
// (words that must never be on screen, one per line), UI_LOCALE (zh-CN),
|
||||
// PLAN_MODEL, PLAN_FILE (a plan to walk instead of asking for one).
|
||||
import { chromium } from "playwright";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const env = (k, d) => process.env[k] ?? d;
|
||||
const OUT = path.resolve(process.env.OUT_DIR || "ui-preview-out");
|
||||
// a leak empties OUT, so it must be a folder of its own: not the working
|
||||
// folder, the home, the root or one holding them
|
||||
for (const keep of [process.cwd(), os.homedir(), path.parse(OUT).root]) {
|
||||
const rel = path.relative(OUT, keep);
|
||||
if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
|
||||
console.error(`record: OUT_DIR ${OUT} holds ${keep}; give it a folder of its own`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const BASE = env("DBX_URL");
|
||||
const LOCALE = env("UI_LOCALE", "zh-CN");
|
||||
const MODEL = env("PLAN_MODEL", "deepseek-flash");
|
||||
const VIEW = { width: 1440, height: 900 };
|
||||
// never pressed: they delete the sandbox's own data or its setup
|
||||
const FORBIDDEN = /删除|清空|移除|卸载|重置|格式化|delete|remove|clear all|drop\b|truncate\b/i;
|
||||
const secrets = env("SECRETS", "").split("\n").map((s) => s.trim()).filter((s) => s.length >= 8);
|
||||
|
||||
const log = (...a) => console.log("[ui-preview]", ...a);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const manifest = { summary: "", scenes: [], video: null, poster: null, errors: [], leak: false, skipped: null };
|
||||
|
||||
// The page as a compact outline the planner can pick real selectors from:
|
||||
// visible elements, their tag, id, classes, a few data- attributes and
|
||||
// their own text, indented; svg insides and invisible parts left out.
|
||||
function outline() {
|
||||
const lines = [];
|
||||
const MAX = 420;
|
||||
const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(" ").replace(/\s+/g, " ").slice(0, 70);
|
||||
const shown = (e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return false;
|
||||
const s = getComputedStyle(e);
|
||||
return s.visibility !== "hidden" && s.display !== "none" && +s.opacity !== 0;
|
||||
};
|
||||
// fold: where the view it is in ends on screen
|
||||
const walk = (e, depth, fold) => {
|
||||
if (lines.length >= MAX || e.hasAttribute?.("data-ui-preview")) return;
|
||||
const tag = e.tagName.toLowerCase();
|
||||
if (["script", "style", "svg", "link", "meta", "noscript", "template"].includes(tag) || !shown(e)) return;
|
||||
const cls = [...e.classList].slice(0, 4).map((c) => "." + c).join("");
|
||||
const data = [...e.attributes].filter((a) => a.name.startsWith("data-") && a.value.length < 40).slice(0, 3).map((a) => `[${a.name}="${a.value}"]`).join("");
|
||||
const aria = ["role", "aria-label", "title", "placeholder", "type"].map((k) => e.getAttribute(k) ? `${k}="${e.getAttribute(k).slice(0, 40)}"` : "").filter(Boolean).join(" ");
|
||||
const text = own(e);
|
||||
const r = e.getBoundingClientRect();
|
||||
const away = r.top >= fold ? " (below the fold)" : "";
|
||||
const interesting = cls || data || e.id || text || aria || /^(button|a|input|select|textarea|label|h\d)$/.test(tag);
|
||||
if (interesting) lines.push(`${" ".repeat(Math.min(depth, 12))}${tag}${e.id ? "#" + e.id : ""}${cls}${data}${aria ? " " + aria : ""}${text ? ` "${text}"` : ""}${away}`);
|
||||
const scrolls = /auto|scroll|overlay/.test(getComputedStyle(e).overflowY) && e.scrollHeight > e.clientHeight + 1;
|
||||
for (const c of e.children) walk(c, interesting ? depth + 1 : depth, scrolls ? Math.min(fold, r.bottom) : fold);
|
||||
};
|
||||
walk(document.body, 0, innerHeight);
|
||||
if (lines.length >= MAX) lines.push("… (cut)");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function screenText(page) {
|
||||
return page.evaluate(() => document.documentElement.innerText + "\n" +
|
||||
[...document.querySelectorAll("input, textarea")].map((e) => e.value).join("\n")).catch(() => "");
|
||||
}
|
||||
async function checkLeak(page, where) {
|
||||
if (!secrets.length) return;
|
||||
const text = await screenText(page);
|
||||
for (const s of secrets) {
|
||||
// the whole key, or a long enough run of its middle
|
||||
const mid = s.slice(4, -4);
|
||||
if (text.includes(s) || (mid.length >= 12 && text.includes(mid))) {
|
||||
manifest.leak = true;
|
||||
throw new Error(`a secret is on screen (${where}); nothing will be published`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// any OpenAI-compatible endpoint; PLAN_API_URL/PLAN_API_KEY/PLAN_MODEL move
|
||||
// it off DeepSeek (AtlasCloud and friends) without touching this file
|
||||
const API_URL = env("PLAN_API_URL", "https://api.deepseek.com/v1/chat/completions");
|
||||
const API_KEY = env("PLAN_API_KEY") || env("DEEPSEEK_API_KEY");
|
||||
|
||||
async function askPlanner(messages) {
|
||||
// a reply cut short or not JSON is asked for again
|
||||
let last;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
|
||||
body: JSON.stringify({ model: MODEL, messages, response_format: { type: "json_object" }, max_tokens: 16000 }),
|
||||
signal: AbortSignal.timeout(240e3),
|
||||
});
|
||||
if (!res.ok) throw new Error(`planner API ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
||||
const j = await res.json();
|
||||
const text = j.choices?.[0]?.message?.content ?? "";
|
||||
try { return JSON.parse(text.replace(/^```(?:json)?\s*|\s+```$/g, "")); }
|
||||
catch (e) { last = new Error(`reply not JSON (${j.choices?.[0]?.finish_reason}): ${e.message}`); log(last.message, "— asking again"); }
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
// Two things for the planner, from the PR's source:
|
||||
//
|
||||
// 1. the changed frontend files themselves — a Vue component's template is
|
||||
// the surest guide to the selectors it renders;
|
||||
// 2. the entry chains to them: a dialog or panel the diff touches is often
|
||||
// rendered by some other component that isn't in the diff, opened by a
|
||||
// condition the diff never shows. For each changed .vue component we
|
||||
// grep the frontend for who imports it and under what v-if/v-show it is
|
||||
// rendered, then for who flips those conditions — the magpie-style
|
||||
// two-hop caller walk, adapted to Vue.
|
||||
const SKIP = /(__tests__|\.spec\.|\.test\.|\.bench\.|^apps\/desktop\/src\/i18n\/locales\/)/;
|
||||
async function readTree(root) {
|
||||
const out = [];
|
||||
const walk = async (dir) => {
|
||||
for (const e of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) await walk(p);
|
||||
else if (/\.(vue|ts)$/.test(e.name)) out.push(p);
|
||||
}
|
||||
};
|
||||
await walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function codeContext(diff, src) {
|
||||
if (!src) return "";
|
||||
const changed = [...new Set([...diff.matchAll(/^diff --git a\/(\S+) b\/\1$/gm)].map((m) => m[1]))]
|
||||
.filter((f) => /^apps\/desktop\/src\//.test(f) && !SKIP.test(f.slice("apps/desktop/src/".length - 1)) && !SKIP.test(f));
|
||||
|
||||
// 1. the changed files
|
||||
let out = "";
|
||||
for (const f of changed.slice(0, 12)) {
|
||||
let body;
|
||||
try { body = await fs.readFile(path.join(src, f), "utf8"); } catch { continue; }
|
||||
const chunk = `--- ${f}\n${body.length > 8000 ? body.slice(0, 8000) + "\n… (cut)" : body}\n`;
|
||||
if (out.length + chunk.length > 20e3) { out += "… (cut)\n"; break; }
|
||||
out += chunk;
|
||||
}
|
||||
|
||||
// 2. entry chains: who renders the changed components, and who opens that
|
||||
const components = changed.filter((f) => f.endsWith(".vue")).map((f) => path.basename(f, ".vue"));
|
||||
if (!components.length) return out;
|
||||
const rel = (p) => path.relative(src, p);
|
||||
const files = (await readTree(path.join(src, "apps/desktop/src"))).filter((p) => !SKIP.test(rel(p)));
|
||||
const texts = new Map();
|
||||
const read = async (p) => {
|
||||
if (!texts.has(p)) {
|
||||
const b = await fs.readFile(p, "utf8").catch(() => "");
|
||||
texts.set(p, b.length > 400e3 ? "" : b);
|
||||
}
|
||||
return texts.get(p);
|
||||
};
|
||||
let entries = "";
|
||||
for (const name of components.slice(0, 6)) {
|
||||
const kebab = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
||||
const re = new RegExp(
|
||||
"import\\s+" + name + "\\b" +
|
||||
"|from\\s+[\"'][^\"']*/" + name + "(\\.vue)?[\"']" +
|
||||
"|import\\(\\s*[\"'][^\"']*/" + name + "(\\.vue)?[\"']\\s*\\)" +
|
||||
"|<" + kebab + "[\\s>/]|<" + name + "[\\s>/]");
|
||||
const flags = new Map(); // caller file -> Set of line numbers
|
||||
for (const p of files) {
|
||||
const t = await read(p);
|
||||
if (!t || rel(p).endsWith(`/${name}.vue`)) continue;
|
||||
t.split("\n").forEach((l, i) => { if (re.test(l)) (flags.get(p) ?? flags.set(p, new Set()).get(p)).add(i); });
|
||||
}
|
||||
for (const [p, lines] of flags) {
|
||||
// a dynamic import renames the component (const X = defineAsyncComponent):
|
||||
// resolve the local aliases, then mark where they are rendered — that
|
||||
// line, not the import, carries the v-if that shows it
|
||||
const ls = await read(p);
|
||||
const linesArr = ls.split("\n");
|
||||
const aliases = new Set([name]);
|
||||
for (const i of lines) {
|
||||
const m = /(?:import\s+(\w+)\s+from|const\s+(\w+)\s*=\s*(?:defineAsyncComponent|shallowRef|markRaw))/.exec(linesArr[i]);
|
||||
if (m && re.test(linesArr[i])) aliases.add(m[1] || m[2]);
|
||||
}
|
||||
if (aliases.size > 1) {
|
||||
const tagRe = new RegExp("<(?:" + [...aliases].join("|") + "|" + [...aliases].map((a) => a.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()).join("|") + ")[\\s>/]");
|
||||
linesArr.forEach((l, i) => { if (tagRe.test(l)) lines.add(i); });
|
||||
}
|
||||
// the usage with its v-if/v-show/:open condition, and identifiers in it
|
||||
const span = [];
|
||||
for (const i of lines) span.push(linesArr.slice(Math.max(0, i - 6), i + 14).join("\n"));
|
||||
const cond = span.join("\n");
|
||||
const chunk = `--- ${rel(p)} renders ${name} (how it is shown):\n${cond.slice(0, 2500)}\n`;
|
||||
if (entries.length + chunk.length > 9e3) { entries += "… (cut)\n"; break; }
|
||||
entries += chunk;
|
||||
// who flips those conditions: identifiers in v-if / :open / v-model near the usage
|
||||
const ids = [...new Set([...cond.matchAll(/(?:v-if|v-show|:open|v-model:open|:model-value)="\s*([A-Za-z_$][\w$.]*)/g)].map((m) => m[1]))].slice(0, 4);
|
||||
for (const id of ids) {
|
||||
const base = id.split(".").pop();
|
||||
const flip = new RegExp("\\b" + base + "\\s*=\\s*(true|false|!)|(?:function|const)\\s+" + base + "\\b|\\b" + base + "\\s*\\(");
|
||||
let shown = 0;
|
||||
for (const q of files) {
|
||||
if (shown >= 2) break;
|
||||
const qt = await read(q);
|
||||
if (!qt) continue;
|
||||
const qArr = qt.split("\n");
|
||||
for (let i = 0; i < qArr.length && shown < 2; i++) {
|
||||
if (!flip.test(qArr[i]) || re.test(qArr[i])) continue;
|
||||
const c = `--- ${rel(q)} flips ${id}:\n${qArr.slice(Math.max(0, i - 8), i + 8).join("\n")}\n`;
|
||||
if (entries.length + c.length > 12e3) break;
|
||||
entries += c;
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out + (entries ? `\n=== how the changes are reached (other components rendering/opening them — the entry points to plan from) ===\n${entries}` : "");
|
||||
}
|
||||
|
||||
const GUIDE = `You plan a short screen recording of dbx, a database GUI (shown here in a browser at ${VIEW.width}x${VIEW.height}). It runs for real on a sandbox machine: the web build of the app against a dbx-web backend with password protection off, and made-up data beside it (seed.mjs) so the pages that need data have some. That data: two saved SQLite connections, "商店 Shop (SQLite)" (tables customers, products, orders, audit_log, a view v_daily_revenue, foreign keys and a trigger) and "分析 Analytics (SQLite)" (events with 5000 rows — the grid pages — and daily_stats with 90 days); a small sample.csv (name,category,price,stock) for import dialogs; a saved-SQL folder "常用查询" with three files; and query history over the last three days across both connections, real executions mixed with seeded entries, a few failures among them. The UI language is ${LOCALE}. What it doesn't have: any server database (MySQL, PostgreSQL, Redis, MongoDB… none are connected — connection dialogs can be opened but not completed), no AI provider configured, no plugins or JDBC drivers installed, no SSH tunnels.
|
||||
|
||||
dbx is a single-page app with no URL routing: every recording starts from the page as it opens. The left sidebar is a tree that opens one level at a time: a click on a connection lists its databases (SQLite shows one, "main 默认库"), a click on that database node lists its tables, and a click on a table opens its data grid — the query editor, table data, structure, ER diagram are that connection's tabs. Tree rows are plain divs inside div.connection-tree-content — click them as div.connection-tree-content div with the node's text, never as buttons. A double-click on a connection instead opens a database-browser tab, so working down the tree is how a table is reached; the outline you are given shows the app at rest, connections collapsed, so a node the plan needs that isn't in it has to be expanded into view first, not looked for on other pages. The search box above the tree (placeholder 搜索...) can reveal a node without that clicking, but while it holds text it filters the tree — a blank tree with text in the box means no match, and its text survives everything else the plan does: clear it with the × at its right end before walking the tree by hand, and never plan other steps while it holds text. The toolbar above the editor runs the SQL; other panels (SQL 库, 查询历史, 全局搜索, 设置) open from the interface itself. Action buttons in toolbars and dialogs are elements carrying data-slot="button", not the button tag — a step targeting "button" matches both, and grid toolbar actions also carry data-toolbar-action (camelCase, e.g. addRow). An editable table's grid toolbar has a 新增行 action (data-toolbar-action="addRow") that adds one row on a click; the icon button beside it, carrying aria-label="新增行" and no label text, opens a menu whose 插入多行… entry opens the insert-rows dialog (title 新增多行, row-count input #insert-rows-count). Two ways of working that reach further than clicks:
|
||||
- The query editor runs any SQL against the sandbox's SQLite databases (they are throwaway — writes are fine): when a state needs data (a result with no columns, an empty table, a table of a shape), open a connection, type the SQL that produces it and run it, and show the state it makes.
|
||||
- A file picker can't be clicked through, but a step can set it: { "do": "upload", "target": "input[type=file]", "value": "sample.csv" } — seed.mjs puts sample.csv (a small product list: name,category,price,stock) beside the databases, for import and mapping dialogs.
|
||||
|
||||
The diff is what the PR does. The title and description are the author's words and may be out of date or wrong: plan and summarise from the diff alone, and when the description claims something in the UI the diff doesn't do (a setting, a page, a button), don't look for it — say so in "mismatch".
|
||||
|
||||
You get the PR's title, description, diff, an outline of the app as it renders now, the changed frontend files themselves, and the entry chains: the other components that render the changed ones, under what v-if/v-show condition they are shown, and the code that flips those conditions. Use only selectors you can build from what the outline shows, or, for what only appears after an interaction (a dialog, a context menu, a dropdown, a tab), from the changed files and entry chains: the outline shows the app at rest, so an element the diff styles or builds being missing from it means you must open it first, not that it isn't there. The entry chains are the map — when a selector from the diff is nowhere in the outline, plan the steps that flip its condition (the toolbar button, the context menu, the tab the entry chain names), and don't search random other pages for it.
|
||||
|
||||
Write a plan that shows a reviewer exactly what this PR changes in the UI and whether it works as intended: go where the change is, do what a user would do to see it (open the connection, switch the tab, type the SQL, open the dialog…), and take a screenshot at each state that matters, before and after an interaction when that is the point. Put a wish for text in the step's "text" field, never inside the target selector ([aria-label="设置"] hides it from the runner's text fallback — target "button" with text "设置" instead). Captions say what is being done or what to look at ("打开订单表后的数据网格"), never what the result is or should be — the reviewer judges that from the picture, and the sandbox may differ from what you expect; and they never name a thing the diff doesn't add. When the change only shows with data this sandbox doesn't have (a server database, a plugin), say so in "unseen" and still show the place it would be. Keep it short: usually 1–3 scenes, under 15 steps each. Don't show unrelated pages. Never press anything that deletes, removes, clears or resets (删除/清空/移除/重置), and never run SQL that drops or truncates. Say ui_change false (and no scenes) only when the diff plainly changes nothing a user can see — only tests, comments, docs, or code that never reaches the screen; any change to the page's CSS, markup, text or behaviour is a UI change.
|
||||
|
||||
Reply with JSON only:
|
||||
{
|
||||
"ui_change": true,
|
||||
"summary": "one or two sentences, in Chinese, on what the diff changes in the UI",
|
||||
"mismatch": "Chinese, or empty: what the description says the UI gets that the diff doesn't do",
|
||||
"unseen": "Chinese, or empty: what of the change this sandbox can't show, and why",
|
||||
"scenes": [
|
||||
{ "title": "short Chinese title", "start": "app",
|
||||
"steps": [
|
||||
{ "do": "click", "target": "css selector", "text": "optional: only elements containing this text", "dblclick": false, "caption": "Chinese caption shown in the video" },
|
||||
{ "do": "hover", "target": "…", "caption": "…" },
|
||||
{ "do": "type", "target": "…", "value": "text to type", "caption": "…" },
|
||||
{ "do": "upload", "target": "input[type=file]", "value": "sample.csv", "caption": "…" },
|
||||
{ "do": "press", "key": "Escape" },
|
||||
{ "do": "scroll", "to": "css selector of what to bring into view", "text": "optional" },
|
||||
{ "do": "scroll", "target": "optional scroll container", "dy": 400 },
|
||||
{ "do": "wait", "ms": 800 },
|
||||
{ "do": "shot", "name": "kebab-case-name", "caption": "Chinese: what this screenshot shows", "target": "optional: capture just this element (with some room around it)" }
|
||||
] }
|
||||
]
|
||||
}`;
|
||||
|
||||
// the mouse glides like a hand would, so its trail reads in the video
|
||||
let mouse = { x: VIEW.width / 2, y: VIEW.height / 2 };
|
||||
async function glide(page, x, y) {
|
||||
const d = Math.hypot(x - mouse.x, y - mouse.y);
|
||||
const ms = Math.min(900, Math.max(250, d * 1.1));
|
||||
const n = Math.max(8, Math.round(ms / 16));
|
||||
const from = { ...mouse };
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const t = i / n, e = t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
// a slight arc, not a ruler line
|
||||
const bend = Math.sin(Math.PI * t) * Math.min(40, d * .08);
|
||||
const nx = from.x + (x - from.x) * e - ((y - from.y) / (d || 1)) * bend;
|
||||
const ny = from.y + (y - from.y) * e + ((x - from.x) / (d || 1)) * bend;
|
||||
await page.mouse.move(nx, ny);
|
||||
await sleep(16);
|
||||
}
|
||||
mouse = { x, y };
|
||||
}
|
||||
|
||||
async function locate(page, step) {
|
||||
if (!step.target) throw new Error(`${step.do} needs a target`);
|
||||
// dbx's UI kit renders its action buttons as elements carrying
|
||||
// data-slot="button" rather than the button tag, so a plan that says
|
||||
// "button" has to mean both
|
||||
const target = /^button$/i.test(step.target.trim()) ? 'button, [data-slot="button"]' : step.target;
|
||||
const all = page.locator(target);
|
||||
// a wish for text tucked into the selector ([aria-label="设置"], [title="Run"]):
|
||||
// take it out and treat it as the text filter, where the fallback can use it
|
||||
const attrText = /\[(?:aria-label|title|placeholder|data-slot|data-view)[*^$]?=~?"([^"]+)"/.exec(step.target)?.[1];
|
||||
const text = step.text || attrText || "";
|
||||
const visible = async (l) => {
|
||||
// the deepest match, not the first: a text filter matches a container and
|
||||
// everything inside it, and the click belongs on the innermost one — the
|
||||
// row, the label — not on the wrapper whose middle is often blank space
|
||||
const n = await l.count();
|
||||
let hit = null;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const one = l.nth(i);
|
||||
if (await one.isVisible()) hit = one;
|
||||
}
|
||||
return hit;
|
||||
};
|
||||
const countVisible = async (l) => {
|
||||
const n = await l.count();
|
||||
let c = 0;
|
||||
for (let i = 0; i < n; i++) if (await l.nth(i).isVisible()) c++;
|
||||
return c;
|
||||
};
|
||||
// a text match is usually a container whose subtree carries the text: walk
|
||||
// down while exactly one visible child still contains it, so the click
|
||||
// lands on the row or the label, not on a wrapper whose middle is blank
|
||||
const deepest = async (hit, text) => {
|
||||
if (!hit || !text) return hit;
|
||||
try {
|
||||
const el = await hit.evaluateHandle((n, text) => {
|
||||
for (;;) {
|
||||
const kids = [...n.children].filter((c) => (c.textContent || "").includes(text) && c.getBoundingClientRect().width > 0);
|
||||
if (kids.length === 1) n = kids[0]; else return n;
|
||||
}
|
||||
}, text);
|
||||
return el.asElement() || hit;
|
||||
} catch { return hit; }
|
||||
};
|
||||
let loc = text ? all.filter({ hasText: text }) : all;
|
||||
let hit = await visible(loc);
|
||||
// hasText matches an element's content, and a form field's content is its
|
||||
// value — empty until typed into — so input[placeholder="搜索..."] filters
|
||||
// itself out. The bare selector was right all along: trust it when exactly
|
||||
// one visible element answers to it.
|
||||
if (!hit && text && (await countVisible(all)) === 1) {
|
||||
loc = all;
|
||||
step = { ...step, text: "" };
|
||||
hit = await visible(loc);
|
||||
}
|
||||
if (hit) return deepest(hit, text);
|
||||
// the selector guessed a role or structure this app doesn't use (a tab, a
|
||||
// menuitem, a tree node…): fall back to the one visible thing carrying the
|
||||
// text — only when exactly one is on screen, so a wrong guess fails loud
|
||||
if (step.text) {
|
||||
const loose = page.locator('button, a, li, span, div[class]:not([class=""]), [role="menuitem"], [role="tab"], [role="button"], [role="treeitem"], [class*="categor"] *, [class*="nav"] *, [class*="tree"] *, [class*="sidebar"] *').filter({ hasText: step.text });
|
||||
const seen = [];
|
||||
const n = await loose.count();
|
||||
for (let i = 0; i < n && seen.length <= 2; i++) {
|
||||
const one = loose.nth(i);
|
||||
if (await one.isVisible()) seen.push(one);
|
||||
}
|
||||
// at most two on screen (the same text in a parent and its child): take
|
||||
// the last, which in DOM order is the innermost; more means the wish for
|
||||
// the text is too vague — fail loud rather than click something wrong
|
||||
if (seen.length >= 1 && seen.length <= 2) return deepest(await seen.at(-1), step.text);
|
||||
}
|
||||
throw new Error(`nothing visible matches ${step.target}${step.text ? ` with "${step.text}"` : ""} (${await loc.count()} in the page)`);
|
||||
}
|
||||
|
||||
|
||||
// Out of sight is scrolled to as a reader would, with the wheel over what
|
||||
// scrolls. Each view that hides it, the innermost first, is wheeled in small
|
||||
// steps until the element is in the middle of it, or at its top when it's
|
||||
// taller.
|
||||
async function bringIntoView(page, loc) {
|
||||
const PAD = 24;
|
||||
for (let pass = 0; pass < 6; pass++) {
|
||||
const views = await loc.evaluate((e, PAD) => {
|
||||
const out = [];
|
||||
const clip = { top: 0, bottom: innerHeight, left: 0, right: innerWidth };
|
||||
const scrollers = [];
|
||||
for (let s = e.parentElement; s; s = s.parentElement) {
|
||||
const root = s === document.scrollingElement;
|
||||
const o = getComputedStyle(s).overflowY;
|
||||
if ((root || /auto|scroll|overlay/.test(o)) && s.scrollHeight > s.clientHeight + 1) scrollers.push(s);
|
||||
}
|
||||
const r = e.getBoundingClientRect();
|
||||
for (const s of scrollers) {
|
||||
const root = s === document.scrollingElement;
|
||||
const b = root ? clip : s.getBoundingClientRect();
|
||||
// what of it shows: inside the window and every view around it
|
||||
let top = Math.max(b.top, 0), bottom = Math.min(b.bottom, innerHeight);
|
||||
let left = Math.max(b.left, 0), right = Math.min(b.right, innerWidth);
|
||||
for (let p = s.parentElement; p && !root; p = p.parentElement) {
|
||||
if (!scrollers.includes(p) || p === document.scrollingElement) continue;
|
||||
const pb = p.getBoundingClientRect();
|
||||
top = Math.max(top, pb.top); bottom = Math.min(bottom, pb.bottom);
|
||||
left = Math.max(left, pb.left); right = Math.min(right, pb.right);
|
||||
}
|
||||
const room = bottom - top - 2 * PAD;
|
||||
const want = r.height <= room ? (r.top + r.bottom) / 2 - (top + bottom) / 2 : r.top - (top + PAD);
|
||||
const shown = r.top >= top + Math.min(PAD, Math.max(0, room - r.height) / 2) && (r.height <= room ? r.bottom <= bottom - PAD : r.top <= top + PAD * 2);
|
||||
out.push({ shown, dy: Math.max(-s.scrollTop, Math.min(s.scrollHeight - s.clientHeight - s.scrollTop, want)),
|
||||
x: (left + right) / 2, y: (top + bottom) / 2, seen: bottom - top > 20 && right - left > 20 });
|
||||
}
|
||||
return out;
|
||||
}, PAD);
|
||||
const v = views.find((v) => !v.shown && Math.abs(v.dy) >= 4 && v.seen);
|
||||
if (!v) return;
|
||||
await glide(page, v.x, v.y);
|
||||
await wheel(page, v.dy);
|
||||
await sleep(350);
|
||||
}
|
||||
}
|
||||
|
||||
// a hand's flick: a few notches at a time, not one jump
|
||||
async function wheel(page, dy) {
|
||||
for (let left = dy; Math.abs(left) >= 1;) {
|
||||
const n = Math.sign(left) * Math.min(Math.abs(left), 90);
|
||||
await page.mouse.wheel(0, n);
|
||||
left -= n;
|
||||
await sleep(28);
|
||||
}
|
||||
}
|
||||
|
||||
async function pointAt(page, loc) {
|
||||
await bringIntoView(page, loc);
|
||||
const b = await loc.boundingBox();
|
||||
if (!b) throw new Error("the element has no box");
|
||||
const x = b.x + Math.min(b.width / 2, 60), y = b.y + b.height / 2;
|
||||
await glide(page, x, y);
|
||||
return b;
|
||||
}
|
||||
|
||||
// The app polls, so the network never goes quiet: loaded, its toolbar and
|
||||
// the seeded connections painted in, is ready
|
||||
async function settle(page) {
|
||||
await page.waitForLoadState("load").catch(() => {});
|
||||
await page.waitForFunction(() => {
|
||||
const txt = document.body.innerText || "";
|
||||
return txt.includes("新建连接") || txt.includes("New Connection") || document.querySelectorAll("button").length >= 6;
|
||||
}, null, { timeout: 20000 }).catch(() => {});
|
||||
await sleep(1500);
|
||||
}
|
||||
|
||||
const caption = (page, text) => page.evaluate((t) => window.__uiPreviewCaption?.(t), text || "").catch(() => {});
|
||||
let shotN = 0;
|
||||
|
||||
async function runStep(page, step, scene) {
|
||||
if (step.caption) await caption(page, step.caption);
|
||||
switch (step.do) {
|
||||
case "click": {
|
||||
const loc = await locate(page, step);
|
||||
const label = (await loc.innerText().catch(() => "")) + " " + (await loc.getAttribute("title").catch(() => "") || "");
|
||||
if (FORBIDDEN.test(label)) throw new Error(`won't press "${label.trim().slice(0, 40)}"`);
|
||||
await pointAt(page, loc);
|
||||
await sleep(180);
|
||||
if (step.dblclick) {
|
||||
await page.mouse.down(); await sleep(60); await page.mouse.up();
|
||||
await sleep(120); await page.mouse.down(); await sleep(60); await page.mouse.up();
|
||||
} else {
|
||||
await page.mouse.down();
|
||||
await sleep(90);
|
||||
await page.mouse.up();
|
||||
}
|
||||
// a click that opens something (a menu, the settings, a dialog) needs
|
||||
// its new DOM painted before the next step looks for it
|
||||
await sleep(1200);
|
||||
break;
|
||||
}
|
||||
case "hover": {
|
||||
await pointAt(page, await locate(page, step));
|
||||
await sleep(900);
|
||||
break;
|
||||
}
|
||||
case "type": {
|
||||
const loc = await locate(page, step);
|
||||
await pointAt(page, loc);
|
||||
await page.mouse.down(); await sleep(80); await page.mouse.up();
|
||||
await loc.fill("");
|
||||
await page.keyboard.type(String(step.value ?? ""), { delay: 70 });
|
||||
await sleep(600);
|
||||
break;
|
||||
}
|
||||
case "upload": {
|
||||
// a file for an <input type=file>: the name is resolved inside SEED_DIR
|
||||
// (seed.mjs puts a sample CSV there) — the OS picker can't be driven
|
||||
const loc = step.target ? await locate(page, step) : page.locator('input[type="file"]').first();
|
||||
const dir = env("SEED_DIR", ".");
|
||||
const file = path.resolve(dir, String(step.value ?? step.file ?? "sample.csv"));
|
||||
await loc.setInputFiles(file);
|
||||
await sleep(800);
|
||||
break;
|
||||
}
|
||||
case "press":
|
||||
await page.keyboard.press(step.key || "Escape");
|
||||
await sleep(500);
|
||||
break;
|
||||
case "scroll": {
|
||||
if (step.to) {
|
||||
await bringIntoView(page, await locate(page, { ...step, target: step.to }));
|
||||
await sleep(400);
|
||||
break;
|
||||
}
|
||||
// over the view to scroll, or the page's middle: the wheel turns what's under the mouse
|
||||
if (step.target) await pointAt(page, await locate(page, step));
|
||||
else if (mouse.y < 60) await glide(page, VIEW.width / 2, VIEW.height / 2);
|
||||
await wheel(page, step.dy ?? 400);
|
||||
await sleep(700);
|
||||
break;
|
||||
}
|
||||
case "wait":
|
||||
await sleep(Math.min(step.ms ?? 800, 4000));
|
||||
break;
|
||||
case "shot": {
|
||||
// in sight first, scrolled to on camera: the clip is of the screen
|
||||
const loc = step.target ? await locate(page, step) : null;
|
||||
if (loc) await bringIntoView(page, loc);
|
||||
await sleep(250);
|
||||
await checkLeak(page, `before ${step.name}`);
|
||||
const file = `${String(++shotN).padStart(2, "0")}-${(step.name || "shot").replace(/[^a-z0-9-]/gi, "-").slice(0, 40)}.png`;
|
||||
// the screenshot without the drawn mouse and caption
|
||||
await page.evaluate(() => document.querySelector("[data-ui-preview]")?.style.setProperty("visibility", "hidden"));
|
||||
let clip;
|
||||
try {
|
||||
if (loc) {
|
||||
const b = await loc.boundingBox();
|
||||
if (b) {
|
||||
// the target with room around it, never so small that a button
|
||||
// is shown without where it is
|
||||
const w = Math.min(VIEW.width, Math.max(560, b.width + 48)), h = Math.min(VIEW.height, Math.max(340, b.height + 48));
|
||||
const x = Math.min(VIEW.width - w, Math.max(0, b.x + b.width / 2 - w / 2));
|
||||
const y = Math.min(VIEW.height - h, Math.max(0, b.y + b.height / 2 - h / 2));
|
||||
clip = { x, y, width: w, height: h };
|
||||
}
|
||||
}
|
||||
await page.screenshot({ path: path.join(OUT, file), clip });
|
||||
} finally {
|
||||
await page.evaluate(() => document.querySelector("[data-ui-preview]")?.style.removeProperty("visibility"));
|
||||
}
|
||||
// a shot taken after a failed step in its scene shows the state the
|
||||
// plan actually reached, not the one the caption claims
|
||||
scene.shots.push({ file, caption: step.caption || step.name || "", width: 2 * (clip?.width ?? VIEW.width), warn: scene.derailed || undefined });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown step ${step.do}`);
|
||||
}
|
||||
if (step.do !== "shot") await checkLeak(page, `after ${step.do}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT, { recursive: true });
|
||||
const diff = await fs.readFile(env("DIFF_FILE"), "utf8");
|
||||
const body = await fs.readFile(env("PR_BODY_FILE", "/dev/null"), "utf8").catch(() => "");
|
||||
const browser = await chromium.launch();
|
||||
const ctxOpts = { viewport: VIEW, deviceScaleFactor: 2, locale: LOCALE, colorScheme: "light" };
|
||||
|
||||
// what the app looks like now, for the plan
|
||||
const look = await browser.newContext(ctxOpts);
|
||||
const lp = await look.newPage();
|
||||
const outlines = {};
|
||||
try {
|
||||
await lp.goto(BASE);
|
||||
await settle(lp);
|
||||
outlines.app = await lp.evaluate(outline);
|
||||
} catch (e) { outlines.app = `(failed to load: ${e.message})`; }
|
||||
await look.close();
|
||||
|
||||
const MAXDIFF = 150e3;
|
||||
const code = await codeContext(diff, env("SRC_DIR")).catch((e) => (log("code context:", e.message), ""));
|
||||
log(`code context: ${code.length} characters`);
|
||||
const ask = [
|
||||
{ role: "system", content: GUIDE },
|
||||
{ role: "user", content: `PR title: ${env("PR_TITLE", "")}\n\nPR description:\n${body.slice(0, 6000)}\n\nDiff${diff.length > MAXDIFF ? " (cut)" : ""}:\n${diff.slice(0, MAXDIFF)}\n\n` +
|
||||
`=== outline: app ===\n${outlines.app}` +
|
||||
(code ? `\n\n=== the changed frontend files (the PR's version) ===\n${code}` : "") },
|
||||
];
|
||||
let plan;
|
||||
try { plan = env("PLAN_FILE") ? JSON.parse(await fs.readFile(env("PLAN_FILE"), "utf8")) : await askPlanner(ask); }
|
||||
catch (e) { plan = null; manifest.errors.push(`plan: ${e.message}`); }
|
||||
if (!plan || plan.ui_change === false || !plan.scenes?.length) {
|
||||
manifest.summary = plan?.summary || "";
|
||||
manifest.skipped = plan ? "no visible UI change found in the diff" : "no plan";
|
||||
// still something to look at: the app as it opens
|
||||
plan = { summary: manifest.summary, scenes: plan ? [] : [{ title: "应用主界面", start: "app", steps: [{ do: "shot", name: "app", caption: "应用主界面" }] }] };
|
||||
}
|
||||
manifest.summary = plan.summary || "";
|
||||
manifest.mismatch = plan.mismatch || "";
|
||||
manifest.unseen = plan.unseen || "";
|
||||
log("plan:", JSON.stringify(plan, null, 1));
|
||||
|
||||
if (plan.scenes.length) {
|
||||
const rec = await browser.newContext({ ...ctxOpts, recordVideo: { dir: path.join(OUT, "raw"), size: VIEW } });
|
||||
await rec.addInitScript({ path: path.join(here, "cursor.js") });
|
||||
let repairs = 0, interactive = false;
|
||||
// one take per scene: every scene starts from the app as it opens, and
|
||||
// its video keeps only what happens once the app has settled — the
|
||||
// load never reaches the final cut, so a scene change reads as a cut,
|
||||
// not as the app reloading out of nowhere
|
||||
const takes = [];
|
||||
for (const s of plan.scenes.slice(0, 5)) {
|
||||
const scene = { title: s.title || "", shots: [], derailed: false };
|
||||
manifest.scenes.push(scene);
|
||||
const page = await rec.newPage();
|
||||
const began = Date.now();
|
||||
let ready = 0;
|
||||
try {
|
||||
await page.goto(BASE);
|
||||
await settle(page);
|
||||
await page.evaluate(([x, y]) => window.__uiPreviewAt?.(x, y), [mouse.x, mouse.y]);
|
||||
ready = Date.now();
|
||||
} catch (e) {
|
||||
manifest.errors.push(`${scene.title}: open: ${e.message}`);
|
||||
await page.close().catch(() => {});
|
||||
if (manifest.leak) break;
|
||||
continue;
|
||||
}
|
||||
// the scene's title readable on the settled page for a beat, before
|
||||
// the first step's caption replaces it
|
||||
await caption(page, scene.title);
|
||||
await sleep(1100);
|
||||
let steps = (s.steps || []).slice(0, 25);
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (["click", "hover", "type", "press", "scroll", "upload"].includes(step.do)) interactive = true;
|
||||
try { await runStep(page, step, scene); }
|
||||
catch (e) {
|
||||
if (manifest.leak) break;
|
||||
manifest.errors.push(`${scene.title} · ${step.do} ${step.target || step.name || ""}: ${e.message.split("\n")[0]}`);
|
||||
log("step failed:", e.message);
|
||||
scene.derailed = true;
|
||||
// whatever the failed step (or an earlier stray one) left open — a
|
||||
// menu, a dropdown, a confirm dialog — swallows every click and
|
||||
// shows up in the shots that follow: clear it before going on
|
||||
await page.keyboard.press("Escape").catch(() => {});
|
||||
await sleep(400);
|
||||
// an active sidebar search keeps the tree filtered down to
|
||||
// nothing, starving every later lookup of its targets: empty it
|
||||
// the way a user would
|
||||
try {
|
||||
const box = page.locator(".connection-tree-search input").first();
|
||||
if ((await box.isVisible().catch(() => false)) && (await box.inputValue().catch(() => ""))) {
|
||||
await box.click({ timeout: 1500 });
|
||||
await page.keyboard.press("ControlOrMeta+A");
|
||||
await page.keyboard.press("Delete");
|
||||
await sleep(300);
|
||||
}
|
||||
} catch {}
|
||||
if (repairs >= 3) continue;
|
||||
repairs++;
|
||||
// the page as it is now, and what went wrong: the rest of the scene again
|
||||
try {
|
||||
const fix = await askPlanner([...ask, { role: "assistant", content: JSON.stringify(plan) },
|
||||
{ role: "user", content: `Step ${i + 1} of scene "${scene.title}" failed: ${e.message.split("\n")[0]}\nThe page now:\n${await page.evaluate(outline)}\n\nIf the failed selector came from the diff, remember the changed-file paths above say which dialog, panel or component holds it — a different one may have to be opened (or closed) first, not just a different selector for this page. Reply with JSON {"steps": [...]}: the steps to do instead of that one and the ones after it in this scene.` }]);
|
||||
if (Array.isArray(fix.steps)) { steps = [...steps.slice(0, i + 1), ...fix.steps.slice(0, 20)]; log("repaired:", JSON.stringify(fix.steps)); }
|
||||
} catch (e2) { manifest.errors.push(`repair: ${e2.message}`); }
|
||||
}
|
||||
}
|
||||
if (manifest.leak) { await page.close().catch(() => {}); break; }
|
||||
await caption(page, "");
|
||||
await sleep(600);
|
||||
takes.push({ video: await page.video().path(), from: Math.max(0, (ready - began) / 1000 - 0.2) });
|
||||
await page.close();
|
||||
}
|
||||
await rec.close();
|
||||
manifest.interactive = interactive;
|
||||
// a video when there's something to watch: an interaction
|
||||
if (!manifest.leak && interactive && takes.length) {
|
||||
// the takes joined, each already trimmed of its load
|
||||
const args = ["-y", "-loglevel", "error"];
|
||||
const pins = takes.map((t, i) => (args.push("-ss", t.from.toFixed(2), "-i", t.video), `[${i}:v]`));
|
||||
args.push("-filter_complex", `${pins.join("")}concat=n=${takes.length}:v=1:a=0[v]`, "-map", "[v]",
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30",
|
||||
path.join(OUT, "preview.mp4"));
|
||||
execFileSync("ffmpeg", args);
|
||||
manifest.video = "preview.mp4";
|
||||
manifest.poster = await poster(browser);
|
||||
}
|
||||
await fs.rm(path.join(OUT, "raw"), { recursive: true, force: true });
|
||||
}
|
||||
await browser.close();
|
||||
if (manifest.leak) {
|
||||
for (const f of await fs.readdir(OUT)) await fs.rm(path.join(OUT, f), { recursive: true, force: true });
|
||||
manifest.scenes = []; manifest.video = manifest.poster = null;
|
||||
}
|
||||
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2));
|
||||
log("done:", JSON.stringify({ shots: manifest.scenes.reduce((n, s) => n + s.shots.length, 0), video: manifest.video, errors: manifest.errors.length, leak: manifest.leak }));
|
||||
}
|
||||
|
||||
// the video's still in the PR: a frame from the middle with a play button
|
||||
async function poster(browser) {
|
||||
const frame = path.join(OUT, "frame.png");
|
||||
const dur = parseFloat(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path.join(OUT, "preview.mp4")]).toString()) || 2;
|
||||
execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-ss", String(Math.min(dur / 3, 6)), "-i", path.join(OUT, "preview.mp4"), "-frames:v", "1", frame]);
|
||||
const img = (await fs.readFile(frame)).toString("base64");
|
||||
const p = await browser.newPage({ viewport: VIEW, deviceScaleFactor: 1 });
|
||||
await p.setContent(`<body style="margin:0;background:url(data:image/png;base64,${img}) center/cover;height:100vh;display:grid;place-items:center">
|
||||
<div style="position:absolute;inset:0;background:rgba(0,0,0,.28)"></div>
|
||||
<div style="position:relative;display:flex;align-items:center;gap:18px;padding:22px 34px 22px 26px;border-radius:999px;background:rgba(17,17,19,.78);color:#fff;font:600 30px -apple-system,'Segoe UI','Noto Sans CJK SC',sans-serif">
|
||||
<svg width="46" height="46" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#fff"/><path d="M9.5 7.5v9l7-4.5z" fill="#111"/></svg>
|
||||
播放录屏 · ${Math.round(dur)} 秒</div></body>`);
|
||||
await p.screenshot({ path: path.join(OUT, "poster.png") });
|
||||
await p.close();
|
||||
await fs.rm(frame);
|
||||
return "poster.png";
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error(e);
|
||||
manifest.errors.push(`recorder: ${e.message}`);
|
||||
await fs.mkdir(OUT, { recursive: true }).catch(() => {});
|
||||
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2)).catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the PR's frontend against a dbx-web backend in a home of its own, with
|
||||
# made-up data beside it (seed.mjs: two SQLite databases, connections, a
|
||||
# saved-SQL library, query history), and records it (record.mjs).
|
||||
#
|
||||
# run.sh <out dir>
|
||||
#
|
||||
# env: DBX_WEB_BIN the dbx-web binary (or the static package's launcher)
|
||||
# — required
|
||||
# DIST_DIR the PR's frontend build; when set it replaces the
|
||||
# binary's embedded frontend (DBX_STATIC_DIR)
|
||||
# DBX_PORT listen port (default 4280)
|
||||
# DEEPSEEK_API_KEY, DIFF_FILE, PR_TITLE, PR_BODY_FILE, SRC_DIR (the
|
||||
# PR's source, for code context) — read by record.mjs;
|
||||
# PLAYWRIGHT_BROWSERS_PATH must already point at the installed
|
||||
# Chromium, since HOME moves.
|
||||
#
|
||||
# For local testing against a dbx-web that already runs somewhere: set
|
||||
# DBX_URL and nothing here is started or seeded.
|
||||
set -euo pipefail
|
||||
[ "$(uname -s)" = Linux ] || { echo "run.sh: Linux only" >&2; exit 1; }
|
||||
out_arg=$1
|
||||
mkdir -p "$out_arg"
|
||||
out=$(cd "$out_arg" && pwd)
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
work=$(mktemp -d)
|
||||
web=""
|
||||
trap 'kill "${web:-}" 2>/dev/null || true' EXIT
|
||||
|
||||
url=${DBX_URL:-}
|
||||
if [ -z "$url" ]; then
|
||||
: "${DBX_WEB_BIN:?DBX_WEB_BIN or DBX_URL is required}"
|
||||
port=${DBX_PORT:-4280}
|
||||
# nothing of the runner's own: the app sees only this home
|
||||
export HOME="$work/home" XDG_CONFIG_HOME="$work/home/.config" XDG_CACHE_HOME="$work/home/.cache" \
|
||||
XDG_DATA_HOME="$work/home/.local/share" XDG_STATE_HOME="$work/home/.local/state"
|
||||
mkdir -p "$HOME" "$work/data" "$work/dbs"
|
||||
|
||||
static=()
|
||||
[ -n "${DIST_DIR:-}" ] && static=(DBX_STATIC_DIR="$(cd "$DIST_DIR" && pwd)")
|
||||
env DBX_PORT="$port" DBX_BIND_ADDR=127.0.0.1 DBX_DISABLE_PASSWORD=1 \
|
||||
DBX_DATA_DIR="$work/data" "${static[@]}" \
|
||||
"$DBX_WEB_BIN" >"$work/web.log" 2>&1 &
|
||||
web=$!
|
||||
url="http://127.0.0.1:$port"
|
||||
for _ in $(seq 60); do
|
||||
curl -fsS -o /dev/null "$url/api/auth/check" 2>/dev/null && break
|
||||
kill -0 "$web" 2>/dev/null || { cat "$work/web.log"; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS -o /dev/null "$url/api/auth/check" || { echo "run.sh: dbx-web never became ready" >&2; cat "$work/web.log"; exit 1; }
|
||||
|
||||
node "$here/seed.mjs" dbs "$work/dbs"
|
||||
node "$here/seed.mjs" api "$url" "$work/dbs"
|
||||
export SEED_DIR="$work/dbs"
|
||||
fi
|
||||
|
||||
status=0
|
||||
DBX_URL="$url" OUT_DIR="$out" node "$here/record.mjs" || status=$?
|
||||
exit $status
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
// Fills the UI preview's sandbox (run.sh) with data to show: a fresh dbx-web
|
||||
// has no connections, so a change to the sidebar, the grid, query history or
|
||||
// the ER diagram has nothing to draw. Made up, every bit of it, and never
|
||||
// real.
|
||||
//
|
||||
// node seed.mjs dbs <dir> [--as <path on the server>] — two SQLite
|
||||
// databases with fixed, deterministic content (python3 writes them, so
|
||||
// the files are identical byte-for-byte on every run);
|
||||
// node seed.mjs api <base-url> <dir> [--as <path on the server>]
|
||||
// — saves the connections (SQLite: the file path is the host), connects,
|
||||
// runs real queries through the gateway so history and result caches
|
||||
// are genuine, then adds a saved-SQL library and a few more history
|
||||
// entries the UI can list.
|
||||
//
|
||||
// The connections point at files as the dbx-web process sees them: pass
|
||||
// --as when the databases are generated on another machine than the one
|
||||
// dbx-web runs on (local testing over an SSH tunnel).
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const arg = (n) => process.argv[n];
|
||||
const opt = (name, d) => {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i > 0 && i + 1 < process.argv.length ? process.argv[i + 1] : d;
|
||||
};
|
||||
|
||||
const DBS = [
|
||||
{
|
||||
file: "dbx-shop.sqlite",
|
||||
name: "商店 Shop (SQLite)",
|
||||
note: "订单、商品与客户示例库",
|
||||
tables: "customers · products · orders · audit_log(含视图 v_daily_revenue、外键与触发器)",
|
||||
warm: ["SELECT * FROM orders ORDER BY created_at DESC LIMIT 50", "SELECT c.city, COUNT(*) AS orders, SUM(o.amount) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC", "SELECT category, COUNT(*) AS n, ROUND(AVG(price), 2) AS avg_price\nFROM products WHERE listed = 1 GROUP BY category ORDER BY n DESC"],
|
||||
},
|
||||
{
|
||||
file: "dbx-analytics.sqlite",
|
||||
name: "分析 Analytics (SQLite)",
|
||||
note: "事件流与每日指标",
|
||||
tables: "events(5000 行,看分页)· daily_stats(90 天)",
|
||||
warm: ["SELECT event_type, COUNT(*) AS n, ROUND(AVG(duration_ms)) AS avg_ms\nFROM events GROUP BY event_type ORDER BY n DESC", "SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14", "SELECT * FROM daily_stats ORDER BY stat_date DESC LIMIT 30"],
|
||||
},
|
||||
];
|
||||
|
||||
// Deterministic content: the same LCG magpie's seed uses, so two previews of
|
||||
// one PR differ only by the PR.
|
||||
function python(dir) {
|
||||
const py = `
|
||||
import os, sqlite3, random, datetime
|
||||
rng = random.Random(20261008)
|
||||
def d(days, hours=0):
|
||||
return (datetime.datetime(2026, 10, 8, 12, 0, 0) - datetime.timedelta(days=days, hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
shop = sqlite3.connect(os.path.join(${JSON.stringify(dir)}, "dbx-shop.sqlite"))
|
||||
shop.executescript("""
|
||||
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT, city TEXT, level TEXT, created_at TEXT);
|
||||
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT, price REAL, stock INTEGER, listed INTEGER DEFAULT 1);
|
||||
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES customers(id), product_id INTEGER REFERENCES products(id),
|
||||
quantity INTEGER, amount REAL, status TEXT, created_at TEXT);
|
||||
CREATE TABLE audit_log (id INTEGER PRIMARY KEY, ts TEXT, what TEXT);
|
||||
CREATE INDEX idx_orders_created ON orders(created_at);
|
||||
CREATE INDEX idx_orders_status ON orders(status);
|
||||
CREATE VIEW v_daily_revenue AS SELECT date(created_at) AS day, COUNT(*) AS orders, ROUND(SUM(amount), 2) AS revenue FROM orders GROUP BY date(created_at);
|
||||
CREATE TRIGGER trg_orders_audit AFTER INSERT ON orders BEGIN INSERT INTO audit_log(ts, what) VALUES (datetime('now'), 'order ' || NEW.id); END;
|
||||
""")
|
||||
cities = ["上海", "北京", "深圳", "杭州", "成都", "广州", "武汉", "南京"]
|
||||
surnames = ["王", "李", "张", "刘", "陈", "杨", "赵", "周", "吴", "徐"]
|
||||
givens = ["晓东", "思远", "雨桐", "子墨", "一鸣", "嘉怡", "浩然", "若彤", "俊杰", "梦琪"]
|
||||
levels = ["bronze", "silver", "gold"]
|
||||
for i in range(1, 201):
|
||||
shop.execute("INSERT INTO customers VALUES (?,?,?,?,?,?)",
|
||||
(i, rng.choice(surnames) + rng.choice(givens), f"user{i}@example.com", rng.choice(cities), rng.choice(levels), d(rng.randint(30, 400), rng.randint(0, 23))))
|
||||
cats = ["键盘", "显示器", "鼠标", "硬盘", "内存", "耳机", "摄像头", "扩展坞"]
|
||||
for i in range(1, 121):
|
||||
shop.execute("INSERT INTO products VALUES (?,?,?,?,?,?)",
|
||||
(i, f"{rng.choice(cats)} {chr(65 + i % 26)}{i:03d}", rng.choice(cats), round(rng.uniform(49, 4999), 2), rng.randint(0, 500), 1 if rng.random() > 0.1 else 0))
|
||||
statuses = ["paid", "shipped", "done", "refund"]
|
||||
for i in range(1, 501):
|
||||
q = rng.randint(1, 3)
|
||||
price = rng.uniform(49, 4999)
|
||||
shop.execute("INSERT INTO orders VALUES (?,?,?,?,?,?,?)",
|
||||
(i, rng.randint(1, 200), rng.randint(1, 120), q, round(q * price, 2), rng.choice(statuses) if rng.random() > 0.15 else "pending", d(rng.randint(0, 90), rng.randint(0, 23))))
|
||||
shop.commit()
|
||||
|
||||
an = sqlite3.connect(os.path.join(${JSON.stringify(dir)}, "dbx-analytics.sqlite"))
|
||||
an.executescript("""
|
||||
CREATE TABLE events (id INTEGER PRIMARY KEY, ts TEXT, user_id INTEGER, event_type TEXT, page TEXT, duration_ms INTEGER);
|
||||
CREATE TABLE daily_stats (stat_date TEXT PRIMARY KEY, dau INTEGER, revenue REAL, new_users INTEGER);
|
||||
CREATE INDEX idx_events_ts ON events(ts);
|
||||
CREATE INDEX idx_events_type ON events(event_type);
|
||||
""")
|
||||
types = ["page_view", "click", "scroll", "search", "submit", "error"]
|
||||
pages = ["/", "/grid", "/editor", "/history", "/settings", "/diagram"]
|
||||
day = datetime.date(2026, 10, 8)
|
||||
for i in range(1, 5001):
|
||||
back = rng.randint(0, 29)
|
||||
ts = datetime.datetime(2026, 10, 8, rng.randint(0, 23), rng.randint(0, 59), rng.randint(0, 59)) - datetime.timedelta(days=back)
|
||||
an.execute("INSERT INTO events VALUES (?,?,?,?,?,?)", (i, ts.strftime("%Y-%m-%d %H:%M:%S"), rng.randint(1, 400), rng.choice(types) if rng.random() > 0.02 else "error", rng.choice(pages), rng.randint(50, 180000) if rng.random() > 0.1 else None))
|
||||
for k in range(90):
|
||||
date = (day - datetime.timedelta(days=k)).strftime("%Y-%m-%d")
|
||||
an.execute("INSERT INTO daily_stats VALUES (?,?,?,?)", (date, rng.randint(180, 420), round(rng.uniform(800, 5200), 2), rng.randint(3, 26)))
|
||||
an.commit()
|
||||
print("ok")
|
||||
`;
|
||||
execFileSync("python3", ["-c", py], { stdio: ["ignore", "inherit", "inherit"] });
|
||||
}
|
||||
|
||||
async function api(base, p, init = {}) {
|
||||
const res = await fetch(base.replace(/\/$/, "") + p, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init.headers || {}) },
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`${init.method || "GET"} ${p}: ${res.status} ${text.slice(0, 300)}`);
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
}
|
||||
|
||||
async function seedApi() {
|
||||
const base = arg(3);
|
||||
const dir = path.resolve(arg(4));
|
||||
const as = path.resolve(opt("--as", dir));
|
||||
const now = Date.now();
|
||||
const iso = (msAgo) => new Date(now - msAgo).toISOString();
|
||||
|
||||
const conns = DBS.map((db, i) => ({
|
||||
id: `seed-${db.file.replace(".sqlite", "")}`,
|
||||
name: db.name,
|
||||
note: db.note,
|
||||
db_type: "sqlite",
|
||||
driver_label: "SQLite",
|
||||
host: path.join(as, db.file),
|
||||
port: 0,
|
||||
username: "",
|
||||
password: "",
|
||||
database: "main",
|
||||
color: i === 0 ? "#2f6fed" : "#8f4bd6",
|
||||
}));
|
||||
await api(base, "/api/connection/save", { method: "POST", body: JSON.stringify({ configs: conns, removed_ids: [] }) });
|
||||
console.log("[ui-preview] saved", conns.length, "connections");
|
||||
|
||||
for (const c of conns) {
|
||||
await api(base, "/api/connection/connect", { method: "POST", body: JSON.stringify({ config: c, client_attempt: 1 }) });
|
||||
const info = await api(base, `/api/connection/info?connection_id=${encodeURIComponent(c.id)}`).catch((e) => (console.log("[ui-preview] connection info:", e.message), null));
|
||||
const database = info?.current_database || info?.databases?.[0] || "main";
|
||||
const db = DBS.find((x) => c.host.endsWith(x.file));
|
||||
for (const sql of db.warm) {
|
||||
const r = await api(base, "/api/query/execute", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ connectionId: c.id, database, sql, maxRows: 200 }),
|
||||
}).catch((e) => (console.log("[ui-preview] warm query failed:", e.message), null));
|
||||
if (r) console.log("[ui-preview] ran", sql.split("\n")[0].slice(0, 60));
|
||||
}
|
||||
}
|
||||
|
||||
// A saved-SQL library, so the SQL library panel has folders and files.
|
||||
const folder = await api(base, "/api/saved-sql/folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: "seed-folder-daily", name: "常用查询", createdAt: iso(36e5 * 72), updatedAt: iso(36e5 * 5) }),
|
||||
}).catch(() => null);
|
||||
const saved = [
|
||||
{ id: "seed-sql-1", name: "各城市营收 Top", sql: "SELECT c.city, COUNT(*) AS orders, ROUND(SUM(o.amount), 2) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC" },
|
||||
{ id: "seed-sql-2", name: "近 14 天 DAU", sql: "SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14" },
|
||||
{ id: "seed-sql-3", name: "低库存商品", sql: "SELECT name, category, stock FROM products WHERE stock < 20 AND listed = 1 ORDER BY stock" },
|
||||
];
|
||||
for (const [i, s] of saved.entries()) {
|
||||
await api(base, "/api/saved-sql", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...s, connectionId: conns[i % conns.length].id, folderId: folder?.id || "seed-folder-daily",
|
||||
database: "main", orderIndex: i, openCount: 3 + i, createdAt: iso(36e5 * (70 - i)), updatedAt: iso(36e5 * (24 - i * 6)),
|
||||
}),
|
||||
}).catch((e) => console.log("[ui-preview] saved-sql:", e.message));
|
||||
}
|
||||
console.log("[ui-preview] saved-sql library seeded");
|
||||
|
||||
// A few more history entries besides the real ones above: spread over the
|
||||
// last three days, one failure included, so the history list pages.
|
||||
const extra = [
|
||||
[conns[0], "SELECT COUNT(*) FROM orders WHERE status = 'pending'", 12, true],
|
||||
[conns[0], "SELECT * FROM v_daily_revenue ORDER BY day DESC LIMIT 7", 18, true],
|
||||
[conns[0], "UPDATE products SET stock = stock - 1 WHERE id = 42", 6, false],
|
||||
[conns[1], "SELECT stat_date, dau, revenue FROM daily_stats ORDER BY stat_date DESC LIMIT 30", 15, true],
|
||||
[conns[1], "SELECT page, COUNT(*) FROM events WHERE event_type = 'error' GROUP BY page", 22, true],
|
||||
[conns[1], "DELETE FROM events WHERE id < 0", 4, false],
|
||||
];
|
||||
for (const [c, sql, ms, ok] of extra) {
|
||||
await api(base, "/api/history/save", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
entry: {
|
||||
id: `seed-h-${Math.random().toString(36).slice(2, 10)}`, connection_id: c.id, connection_name: c.name,
|
||||
database: "main", sql, executed_at: iso(36e5 * (2 + Math.random() * 60)), execution_time_ms: ms, success: ok,
|
||||
error: ok ? null : "demo entry: would have modified rows (not really run)", activity_kind: "query", source: "editor",
|
||||
},
|
||||
}),
|
||||
}).catch((e) => console.log("[ui-preview] history:", e.message));
|
||||
}
|
||||
console.log("[ui-preview] history seeded");
|
||||
}
|
||||
|
||||
const cmd = process.argv[2];
|
||||
if (cmd === "dbs") {
|
||||
const dir = path.resolve(arg(3));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
python(dir);
|
||||
// a small importable file, for import/mapping dialogs (record.mjs's
|
||||
// "upload" step resolves names inside this directory)
|
||||
fs.writeFileSync(path.join(dir, "sample.csv"),
|
||||
"name,category,price,stock\n" +
|
||||
Array.from({ length: 8 }, (_, i) => `键盘 K${i + 100},键盘,${(199 + i * 23).toFixed(2)},${40 - i * 3}`).join("\n") + "\n");
|
||||
console.log("[ui-preview] databases written to", dir);
|
||||
} else if (cmd === "api") {
|
||||
seedApi().catch((e) => { console.error(e); process.exit(1); });
|
||||
} else {
|
||||
console.error("usage: seed.mjs dbs <dir> | seed.mjs api <base-url> <dir> [--as <server path>]");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1631,9 +1631,26 @@ jobs:
|
||||
- name: Verify managed JDBC assets in all offline ZIPs
|
||||
run: node agents/scripts/verify_offline_jdbc_release.mjs release
|
||||
|
||||
- name: Fetch previous agent registry for delta patches
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.release-token.outputs.token }}
|
||||
run: |
|
||||
mkdir -p prev-release
|
||||
if gh release download agents-latest --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern agent-registry.json --dir prev-release --clobber; then
|
||||
echo "previous agent-registry.json fetched for delta generation"
|
||||
else
|
||||
echo "no previous agents-latest registry found; delta patches are skipped"
|
||||
rm -rf prev-release
|
||||
fi
|
||||
|
||||
- name: Build single-driver packages
|
||||
run: |
|
||||
python3 agents/scripts/build_driver_zips.py release --cleanup-sources
|
||||
PREV_ARGS=()
|
||||
if [ -f prev-release/agent-registry.json ]; then
|
||||
PREV_ARGS+=(--previous-registry prev-release/agent-registry.json)
|
||||
fi
|
||||
python3 agents/scripts/build_driver_zips.py release --cleanup-sources "${PREV_ARGS[@]}"
|
||||
python3 -m json.tool release/agent-registry.json > /dev/null
|
||||
echo "=== final agent-registry.json ==="
|
||||
cat release/agent-registry.json
|
||||
@@ -1776,12 +1793,46 @@ jobs:
|
||||
cat RELEASE_NOTES.md
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
token: ${{ steps.release-token.outputs.token }}
|
||||
files: release/*
|
||||
body_path: RELEASE_NOTES.md
|
||||
make_latest: false
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.release-token.outputs.token }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Idempotent release publish: create the release only when missing and
|
||||
# upload only assets it does not have yet, in small spaced batches.
|
||||
# Re-uploading the full set on every retry trips GitHub secondary
|
||||
# rate limits and dropped connections strand the whole job.
|
||||
if gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release edit "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--notes-file RELEASE_NOTES.md --latest=false >/dev/null
|
||||
else
|
||||
gh release create "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--verify-tag --title "$TAG_NAME" \
|
||||
--notes-file RELEASE_NOTES.md --latest=false
|
||||
fi
|
||||
|
||||
mapfile -t existing < <(gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--json assets --jq '.assets[].name')
|
||||
declare -A have=()
|
||||
for name in "${existing[@]:-}"; do
|
||||
[ -n "$name" ] && have["$name"]=1
|
||||
done
|
||||
|
||||
missing=()
|
||||
for f in release/*; do
|
||||
[ -f "$f" ] || continue
|
||||
[ -n "${have["$(basename "$f")"]:-}" ] || missing+=("$f")
|
||||
done
|
||||
echo "release already has ${#existing[@]} assets; uploading ${#missing[@]} missing"
|
||||
|
||||
batch_size=5
|
||||
i=0
|
||||
while [ "$i" -lt "${#missing[@]}" ]; do
|
||||
gh release upload "$TAG_NAME" "${missing[@]:$i:$batch_size}" --repo "$GITHUB_REPOSITORY"
|
||||
i=$((i + batch_size))
|
||||
if [ "$i" -lt "${#missing[@]}" ]; then sleep 20; fi
|
||||
done
|
||||
echo "uploaded ${#missing[@]} assets in batches of $batch_size"
|
||||
|
||||
- name: Update agents-latest GitHub release
|
||||
env:
|
||||
@@ -1830,6 +1881,7 @@ jobs:
|
||||
--recursive \
|
||||
--exclude "*" \
|
||||
--include "dbx-agent-*.tar.zst" \
|
||||
--include "dbx-agent-*.tar.zst.delta" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
echo "Uploaded single-driver packages"
|
||||
|
||||
|
||||
+132
-66
@@ -171,9 +171,7 @@ jobs:
|
||||
NPM_CONFIG_LOGLEVEL: error
|
||||
# sccache cannot reuse Cargo incremental artifacts, so avoid generating them in CI.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Fork PRs cannot read repository secrets, so they build without sccache
|
||||
# (see rust-fmt-clippy) and lean on the rust-cache restore from main.
|
||||
RUSTC_WRAPPER: ${{ secrets.SCCACHE_S3_BUCKET != '' && 'sccache' || '' }}
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
@@ -190,8 +188,16 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
|
||||
# Pin mold because runner image updates can change bundled DuckDB C++ links.
|
||||
# The pinned build prevents duckdb_create_config runtime failures.
|
||||
@@ -220,8 +226,7 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.RUSTC_WRAPPER == 'sccache'
|
||||
- name: Configure sccache
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
@@ -232,13 +237,15 @@ jobs:
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
if [[ -n "$CACHE_BUCKET" ]]; then
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
fi
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
@@ -249,15 +256,18 @@ jobs:
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
env:
|
||||
RUST_FEATURE_MODE: ""
|
||||
RUST_TEST_GROUP: ""
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: v1-rust-complete
|
||||
workspaces: "./ -> target"
|
||||
shared-key: ci-packages-x86_64-unknown-linux-gnu
|
||||
# Release-linking (LTO) dominates this job and sccache cannot cache
|
||||
# those crate types.
|
||||
cache-workspace-crates: true
|
||||
# Preserve completed dependency builds when a later test step fails.
|
||||
cache-on-failure: true
|
||||
cache-on-failure: false
|
||||
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
@@ -752,10 +762,7 @@ jobs:
|
||||
env:
|
||||
# sccache cannot reuse Cargo incremental artifacts, so avoid generating them in CI.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Fork PRs cannot read repository secrets, and the GHA sccache backend can
|
||||
# never hold entries for this repo because main only populates S3, so fork
|
||||
# builds run without sccache and lean on the rust-cache restore from main.
|
||||
RUSTC_WRAPPER: ${{ secrets.SCCACHE_S3_BUCKET != '' && 'sccache' || '' }}
|
||||
RUSTC_WRAPPER: sccache
|
||||
# The fast lane skips only system font discovery while retaining the other default capabilities.
|
||||
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
|
||||
steps:
|
||||
@@ -763,8 +770,16 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
@@ -776,8 +791,7 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.RUSTC_WRAPPER == 'sccache'
|
||||
- name: Configure sccache
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
@@ -788,13 +802,15 @@ jobs:
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
if [[ -n "$CACHE_BUCKET" ]]; then
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
fi
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
@@ -805,12 +821,15 @@ jobs:
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
env:
|
||||
RUST_FEATURE_MODE: ""
|
||||
RUST_TEST_GROUP: ""
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: v1-rust-complete
|
||||
workspaces: "./ -> target"
|
||||
shared-key: ci-rust-fmt-clippy-x86_64-unknown-linux-gnu
|
||||
# Preserve completed dependency builds when a later lint step fails.
|
||||
cache-on-failure: true
|
||||
cache-on-failure: false
|
||||
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
@@ -818,6 +837,15 @@ jobs:
|
||||
run: |-
|
||||
node .github/scripts/ci-rust.mjs clippy workspace "$RUST_FEATURE_MODE"
|
||||
|
||||
- name: Upload Rust Cargo timings
|
||||
if: always() && !cancelled()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: DBX-clippy-cargo-timings
|
||||
path: target/cargo-timings/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
@@ -834,10 +862,7 @@ jobs:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Deep async export tests exceed the Rust test harness's default thread stack on Linux.
|
||||
RUST_MIN_STACK: 8388608
|
||||
# Fork PRs cannot read repository secrets, and the GHA sccache backend can
|
||||
# never hold entries for this repo because main only populates S3, so fork
|
||||
# builds run without sccache and lean on the rust-cache restore from main.
|
||||
RUSTC_WRAPPER: ${{ secrets.SCCACHE_S3_BUCKET != '' && 'sccache' || '' }}
|
||||
RUSTC_WRAPPER: sccache
|
||||
# The fast lane skips only system font discovery while retaining the other default capabilities.
|
||||
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
|
||||
RUST_TEST_GROUP: ${{ matrix.group }}
|
||||
@@ -846,8 +871,16 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
if: matrix.group != 'foundation'
|
||||
|
||||
# Pin mold because runner image updates can change bundled C++ links.
|
||||
@@ -866,8 +899,7 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.RUSTC_WRAPPER == 'sccache'
|
||||
- name: Configure sccache
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
@@ -878,13 +910,15 @@ jobs:
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
if [[ -n "$CACHE_BUCKET" ]]; then
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
fi
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
@@ -895,14 +929,17 @@ jobs:
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
env:
|
||||
RUST_FEATURE_MODE: ""
|
||||
RUST_TEST_GROUP: ""
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: v1-rust-complete
|
||||
workspaces: "./ -> target"
|
||||
shared-key: ci-rust-test-v2-x86_64-unknown-linux-gnu
|
||||
# Test linking dominates this job and sccache cannot cache those crate types.
|
||||
cache-workspace-crates: true
|
||||
# Preserve completed dependency builds when a later test step fails.
|
||||
cache-on-failure: true
|
||||
cache-on-failure: false
|
||||
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
@@ -921,6 +958,15 @@ jobs:
|
||||
run: |-
|
||||
mold -run node .github/scripts/ci-rust.mjs doctest "$RUST_TEST_GROUP" "$RUST_FEATURE_MODE"
|
||||
|
||||
- name: Upload Rust Cargo timings
|
||||
if: always() && !cancelled()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: DBX-rust-${{ matrix.group }}-cargo-timings
|
||||
path: target/cargo-timings/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
@@ -1319,9 +1365,7 @@ jobs:
|
||||
env:
|
||||
# sccache cannot reuse Cargo incremental artifacts, so avoid generating them in CI.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Fork PRs cannot read repository secrets, so they build without sccache
|
||||
# (see rust-fmt-clippy) and lean on the rust-cache restore from main.
|
||||
RUSTC_WRAPPER: ${{ secrets.SCCACHE_S3_BUCKET != '' && 'sccache' || '' }}
|
||||
RUSTC_WRAPPER: sccache
|
||||
defaults:
|
||||
run:
|
||||
working-directory: agents
|
||||
@@ -1330,8 +1374,16 @@ jobs:
|
||||
- name: Install system dependencies
|
||||
if: matrix.driver == 'duckdb'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libdbus-1-dev pkg-config
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
# Pin mold because runner image updates can change bundled DuckDB C++ links.
|
||||
@@ -1345,8 +1397,7 @@ jobs:
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
- name: Configure S3 sccache
|
||||
if: env.RUSTC_WRAPPER == 'sccache'
|
||||
- name: Configure sccache
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
@@ -1357,13 +1408,15 @@ jobs:
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
if [[ -n "$CACHE_BUCKET" ]]; then
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
fi
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
@@ -1374,8 +1427,12 @@ jobs:
|
||||
echo "CXX=${SCCACHE_PATH} c++"
|
||||
} >> "$GITHUB_ENV"
|
||||
- name: Rust cache
|
||||
env:
|
||||
RUST_FEATURE_MODE: ""
|
||||
RUST_TEST_GROUP: ""
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: v1-rust-complete
|
||||
workspaces: agents/drivers/${{ matrix.driver }} -> target
|
||||
# A failed bundled native build can leave link-compatible but invalid artifacts.
|
||||
shared-key: ci-agent-rust-v2-${{ matrix.driver }}
|
||||
@@ -1391,11 +1448,19 @@ jobs:
|
||||
env:
|
||||
DRIVER: ${{ matrix.driver }}
|
||||
run: |
|
||||
mold -run cargo nextest run --manifest-path "drivers/$DRIVER/Cargo.toml" --locked --no-fail-fast
|
||||
mold -run cargo nextest run --manifest-path "drivers/$DRIVER/Cargo.toml" --locked --no-fail-fast --timings
|
||||
mold -run cargo test --doc --manifest-path "drivers/$DRIVER/Cargo.toml" --locked
|
||||
- name: TDengine native agent build
|
||||
if: matrix.driver == 'tdengine'
|
||||
run: mold -run cargo build --manifest-path drivers/tdengine/Cargo.toml --locked --release --bin dbx-tdengine-driver
|
||||
- name: Upload Rust Cargo timings
|
||||
if: always() && !cancelled()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: DBX-agent-${{ matrix.driver }}-cargo-timings
|
||||
path: agents/drivers/${{ matrix.driver }}/target/cargo-timings/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
# Diagnostics only; agent jobs must not swallow build/test failures
|
||||
# (ci-workflow.test.mjs), so keep this step best-effort at the command level.
|
||||
- name: Show sccache stats
|
||||
@@ -1462,8 +1527,9 @@ jobs:
|
||||
if: matrix.driver == 'tdengine'
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: v1-rust-complete
|
||||
workspaces: agents/drivers/tdengine -> target
|
||||
cache-on-failure: true
|
||||
cache-on-failure: false
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.version == '3.4.2.2' }}
|
||||
- name: Run one live engine and version
|
||||
env:
|
||||
|
||||
@@ -453,7 +453,22 @@ jobs:
|
||||
echo "${{ matrix.package-name }}@${VERSION} already exists on npm; skipping."
|
||||
exit 0
|
||||
fi
|
||||
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance
|
||||
# npm can accept a publish but leave the version stuck in a staged
|
||||
# (invisible, blocks republication) state; the only recovery is
|
||||
# to drop the staged version and publish again.
|
||||
publish_log="${RUNNER_TEMP}/npm-publish.log"
|
||||
if ! npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance 2>&1 | tee "$publish_log"; then
|
||||
if ! grep -q "previously staged version" "$publish_log"; then
|
||||
exit 1
|
||||
fi
|
||||
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${{ matrix.package-name }}@${VERSION} became visible despite the error; continuing."
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::npm holds ${{ matrix.package-name }}@${VERSION} in a staged (invisible) state; dropping it and retrying."
|
||||
npm unpublish "${{ matrix.package-name }}@${VERSION}" --force
|
||||
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance
|
||||
fi
|
||||
|
||||
publish-cli:
|
||||
name: Publish @dbx-app/cli
|
||||
@@ -709,6 +724,25 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# npm can accept a publish but leave the version stuck in a staged
|
||||
# (invisible, blocks republication) state; the only recovery is
|
||||
# to drop the staged version and publish again.
|
||||
npm_publish_with_staged_recovery() {
|
||||
local publish_log="${RUNNER_TEMP}/npm-publish.log"
|
||||
if ! npm publish "$@" --access public --provenance 2>&1 | tee "$publish_log"; then
|
||||
if ! grep -q "previously staged version" "$publish_log"; then
|
||||
return 1
|
||||
fi
|
||||
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${{ matrix.package-name }}@${VERSION} became visible despite the error; continuing."
|
||||
return 0
|
||||
fi
|
||||
echo "::warning::npm holds ${{ matrix.package-name }}@${VERSION} in a staged (invisible) state; dropping it and retrying."
|
||||
npm unpublish "${{ matrix.package-name }}@${VERSION}" --force
|
||||
npm publish "$@" --access public --provenance
|
||||
return
|
||||
fi
|
||||
}
|
||||
if [[ "${{ runner.os }}" == "macOS" ]]; then
|
||||
work_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$work_dir"' EXIT
|
||||
@@ -727,14 +761,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
cmp "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}" "$work_dir/package/bin/${{ matrix.binary }}"
|
||||
npm publish "$tarball" --access public --provenance --ignore-scripts
|
||||
npm_publish_with_staged_recovery "$tarball" --ignore-scripts
|
||||
exit 0
|
||||
fi
|
||||
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${{ matrix.package-name }}@${VERSION} already exists on npm; skipping."
|
||||
exit 0
|
||||
fi
|
||||
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance
|
||||
npm_publish_with_staged_recovery "./packages/${{ matrix.package-dir }}"
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
|
||||
@@ -20,6 +20,9 @@ concurrency:
|
||||
jobs:
|
||||
sync-cnb:
|
||||
name: Sync to CNB
|
||||
# Mirror destinations are fixed to the upstream remotes; forks must never
|
||||
# publish their refs there, even with mirrored secrets (#10944).
|
||||
if: github.repository == 't8y2/dbx'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -41,6 +44,7 @@ jobs:
|
||||
|
||||
sync-atomgit:
|
||||
name: Sync to AtomGit
|
||||
if: github.repository == 't8y2/dbx'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -62,6 +66,7 @@ jobs:
|
||||
|
||||
sync-gitee:
|
||||
name: Sync to Gitee
|
||||
if: github.repository == 't8y2/dbx'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
name: UI preview
|
||||
|
||||
# A pull request that changes the UI gets screenshots — and a screen
|
||||
# recording, the mouse drawn in, when the change needs clicking to see — of
|
||||
# the real app built from it, in a fixed block at the foot of its
|
||||
# description. See .github/ui-preview/.
|
||||
#
|
||||
# Pull requests come from forks, so this runs on pull_request_target with
|
||||
# the scripts from main. The PR's code is only built and run in the record
|
||||
# job, which holds the planner's API key (environment ui-preview, no approval
|
||||
# asked: any PR's code can read it, so the key there is one with a low
|
||||
# limit) and nothing that can write to the repo. The publish job, which can
|
||||
# write, never runs the PR's code.
|
||||
#
|
||||
# The backend the recording runs against comes in two lanes: a diff that
|
||||
# only touches the frontend (the usual case) runs against the released
|
||||
# dbx-web static binary — no Rust is built; a diff that reaches crates/,
|
||||
# plugins/ or src-tauri/ builds dbx-web from the PR itself. Either way the
|
||||
# PR's own frontend build is what's on screen (DBX_STATIC_DIR).
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: Pull request number
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: ui-preview-${{ github.event.pull_request.number || inputs.pr }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'ui-preview'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
outputs:
|
||||
ui: ${{ steps.detect.outputs.ui }}
|
||||
lane: ${{ steps.detect.outputs.lane }}
|
||||
sha: ${{ steps.detect.outputs.sha }}
|
||||
number: ${{ steps.detect.outputs.number }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/ui-preview
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- id: detect
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR: ${{ github.event.pull_request.number || inputs.pr }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/ui-preview/publish.mjs detect
|
||||
# what the recorder reads of the PR, handed over so its job needs no token
|
||||
- if: steps.detect.outputs.ui == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR: ${{ steps.detect.outputs.number }}
|
||||
run: |
|
||||
mkdir pr-info
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" -H "Accept: application/vnd.github.diff" > pr-info/pr.diff
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq .title > pr-info/title.txt
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq '.body // ""' > pr-info/body.txt
|
||||
- if: steps.detect.outputs.ui == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pr-info
|
||||
path: pr-info
|
||||
retention-days: 3
|
||||
|
||||
record:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.ui == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: ${{ needs.detect.outputs.lane == 'rust' && 60 || 30 }}
|
||||
environment: ui-preview
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.pw
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
path: ci
|
||||
sparse-checkout: .github/ui-preview
|
||||
persist-credentials: false
|
||||
# the fork's code, on purpose: this job is the one meant to run it,
|
||||
# with a read-only token and no credentials left in the checkout
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.detect.outputs.sha }}
|
||||
path: pr
|
||||
persist-credentials: false
|
||||
allow-unsafe-pr-checkout: true
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-info
|
||||
path: pr-info
|
||||
|
||||
# the frontend, from the PR, for both lanes
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: pr/package.json
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: pr/pnpm-lock.yaml
|
||||
- name: Build the PR's frontend
|
||||
working-directory: pr
|
||||
run: |
|
||||
pnpm install --frozen-lockfile || pnpm install
|
||||
pnpm build
|
||||
|
||||
# the backend: released static binary, or built from the PR
|
||||
- name: The released dbx-web (frontend-only diff)
|
||||
if: needs.detect.outputs.lane != 'rust'
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
mkdir -p backend
|
||||
gh release download --repo "$GITHUB_REPOSITORY" --pattern '*x64-browser-static.tar.gz' --dir backend --clobber
|
||||
tar xzf backend/*x64-browser-static.tar.gz -C backend
|
||||
echo "DBX_WEB_BIN=$(echo backend/*/dbx-web)" >> "$GITHUB_ENV"
|
||||
- name: dbx-web built from the PR (backend diff)
|
||||
if: needs.detect.outputs.lane == 'rust'
|
||||
working-directory: pr
|
||||
run: |
|
||||
cargo build --release -p dbx-web --no-default-features --features dbx-core/sqlite-bundled
|
||||
echo "DBX_WEB_BIN=pr/target/release/dbx-web" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Chromium, ffmpeg and fonts
|
||||
working-directory: ci/.github/ui-preview
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg fonts-noto-cjk fonts-noto-color-emoji
|
||||
npm install --no-audit --no-fund
|
||||
npx playwright install --with-deps chromium
|
||||
- name: Record
|
||||
env:
|
||||
PLAN_API_URL: https://api.atlascloud.ai/v1/chat/completions
|
||||
PLAN_API_KEY: ${{ secrets.PLAN_API_KEY }}
|
||||
PLAN_MODEL: ${{ vars.UI_PREVIEW_PLAN_MODEL || 'openai/gpt-5.4-mini' }}
|
||||
DIFF_FILE: ${{ github.workspace }}/pr-info/pr.diff
|
||||
PR_BODY_FILE: ${{ github.workspace }}/pr-info/body.txt
|
||||
DIST_DIR: ${{ github.workspace }}/pr/dist
|
||||
SRC_DIR: ${{ github.workspace }}/pr
|
||||
run: PR_TITLE="$(cat pr-info/title.txt)" ci/.github/ui-preview/run.sh out
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-preview
|
||||
path: out
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
publish:
|
||||
needs: [detect, record]
|
||||
if: always() && needs.detect.outputs.ui == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/ui-preview
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: ui-preview
|
||||
path: out
|
||||
- env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR: ${{ needs.detect.outputs.number }}
|
||||
SHA: ${{ needs.detect.outputs.sha }}
|
||||
OUT_DIR: out
|
||||
RECORD_RESULT: ${{ needs.record.result }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/ui-preview/publish.mjs publish
|
||||
@@ -60,8 +60,10 @@ Thumbs.db
|
||||
# GitHub (keep workflows)
|
||||
.github/*
|
||||
!.github/CODEOWNERS
|
||||
!.github/FUNDING.yml
|
||||
!.github/workflows/
|
||||
!.github/scripts/
|
||||
!.github/ui-preview/
|
||||
!.github/ISSUE_TEMPLATE/
|
||||
|
||||
# Env
|
||||
|
||||
Generated
+53
-16
@@ -2038,8 +2038,18 @@ version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
|
||||
dependencies = [
|
||||
"darling_core 0.24.1",
|
||||
"darling_macro 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2055,17 +2065,41 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||
dependencies = [
|
||||
"darling_core 0.24.1",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -2103,7 +2137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx"
|
||||
version = "0.6.33"
|
||||
version = "0.6.38"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2186,7 +2220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-cli"
|
||||
version = "0.4.105"
|
||||
version = "0.4.111"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbx-core",
|
||||
@@ -2366,6 +2400,7 @@ dependencies = [
|
||||
"dbx-sql-core",
|
||||
"dbx-types",
|
||||
"deadpool-postgres",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"log",
|
||||
"openssl",
|
||||
@@ -2522,7 +2557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-mcp"
|
||||
version = "0.4.105"
|
||||
version = "0.4.111"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -2689,6 +2724,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"dbx-core",
|
||||
"tauri",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2705,7 +2741,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-web"
|
||||
version = "0.6.33"
|
||||
version = "0.6.38"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"argon2",
|
||||
@@ -5853,7 +5889,7 @@ name = "mysql-common-derive"
|
||||
version = "0.32.2"
|
||||
source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=47740d85a8277167a5717afac785d37b46a36296#47740d85a8277167a5717afac785d37b46a36296"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.23.0",
|
||||
"heck 0.5.0",
|
||||
"manyhow",
|
||||
"num-bigint",
|
||||
@@ -7876,18 +7912,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "2.2.0"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af"
|
||||
checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.0",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"indexmap 2.14.0",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.10.2",
|
||||
@@ -7908,15 +7945,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "2.2.0"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3"
|
||||
checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.24.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8775,7 +8812,7 @@ version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
|
||||
@@ -82,7 +82,7 @@ dev: node_modules/.modules.yaml check-tauri-dev-port
|
||||
dev-fast: node_modules/.modules.yaml check-tauri-dev-port
|
||||
# os-keyring must stay in sync with src-tauri defaults: without it the keychain
|
||||
# key is unreadable and the secret-store migration wizard reappears on every launch.
|
||||
RUST_MIN_STACK=16777216 $(PNPM) dev:tauri -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled,os-keyring
|
||||
RUST_MIN_STACK=16777216 $(PNPM) dev:tauri -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled,os-keyring,sqlserver-native-attention
|
||||
|
||||
dev-web: node_modules/.modules.yaml
|
||||
$(PNPM) dev:web
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<img src="https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Elasticsearch-005571?logo=elasticsearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Meilisearch-FF5CAA?logo=meilisearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Apache%20CouchDB-E42528?logo=apachecouchdb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TiDB-DC150B?logo=tidb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Doris-0052CC?logoColor=white" />
|
||||
@@ -68,6 +69,17 @@
|
||||
## ❤️ Sponsors
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jumpserver.org/" target="_blank">
|
||||
<img src="docs/public/sponsors/jumpserver-card.png" alt="JumpServer" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
JumpServer is an Open-source Privileged Access Management (PAM) platform with AI-powered capabilities, providing DevOps and IT teams a unified workspace to securely access SSH, RDP, Kubernetes, databases, websites, RemoteApp, VirtualApp, and more.
|
||||
<a href="https://github.com/jumpserver/jumpserver" target="_blank">Visit JumpServer</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
|
||||
@@ -79,17 +91,6 @@
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">Visit RainYun</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
TrustAsia provides cloud-based code signing service for DBX, enabling trusted software through automated CI/CD builds.
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">Visit TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jalapeno-cloud.ai/DBX" target="_blank">
|
||||
@@ -134,6 +135,17 @@
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">Visit UCloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
TrustAsia provides cloud-based code signing service for DBX, enabling trusted software through automated CI/CD builds.
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">Visit TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
@@ -224,7 +236,7 @@
|
||||
|
||||
### 100+ Databases, One Tool
|
||||
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. All in a single ~25 MB app. No bundled Chromium.
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, CouchDB, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. All in a single ~25 MB app. No bundled Chromium.
|
||||
|
||||
### Query Editor
|
||||
|
||||
|
||||
+25
-13
@@ -29,6 +29,7 @@
|
||||
<img src="https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Elasticsearch-005571?logo=elasticsearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Meilisearch-FF5CAA?logo=meilisearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Apache%20CouchDB-E42528?logo=apachecouchdb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TiDB-DC150B?logo=tidb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Doris-0052CC?logoColor=white" />
|
||||
@@ -68,6 +69,17 @@
|
||||
## ❤️ 赞助商
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jumpserver.org/" target="_blank">
|
||||
<img src="docs/public/sponsors/jumpserver-card.png" alt="JumpServer" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
JumpServer 是具备 AI 能力的开源特权访问管理(PAM)平台,为 DevOps 和 IT 团队提供统一的工作空间,安全访问 SSH、RDP、Kubernetes、数据库、网站、RemoteApp、VirtualApp 等资源。
|
||||
<a href="https://www.jumpserver.org/" target="_blank">访问 JumpServer</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
|
||||
@@ -79,17 +91,6 @@
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">访问雨云</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
由 TrustAsia 提供代码签名云签服务,实现 CICD 自动化构建可信软件。
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">访问 TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jalapeno-cloud.ai/DBX" target="_blank">
|
||||
@@ -134,6 +135,17 @@
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">访问 UCloud 优刻得</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
由 TrustAsia 提供代码签名云签服务,实现 CICD 自动化构建可信软件。
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">访问 TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
@@ -224,7 +236,7 @@
|
||||
|
||||
### 100+ 种数据库,一个工具搞定
|
||||
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 25 MB 的应用里,不内嵌 Chromium。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、CouchDB、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 25 MB 的应用里,不内嵌 Chromium。
|
||||
|
||||
### 查询编辑器
|
||||
|
||||
@@ -598,7 +610,7 @@ DBX 仅 25 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和
|
||||
|
||||
<details>
|
||||
<summary><strong>支持哪些数据库?</strong></summary>
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Kafka、RocketMQ、RabbitMQ、Pulsar、MQTT)。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、CouchDB、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Kafka、RocketMQ、RabbitMQ、Pulsar、MQTT)。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Shared wire shape; null state properties mean the database did not report them. */
|
||||
public record ConstraintInfo(
|
||||
String name, String constraint_type, String definition, List<String> columns,
|
||||
Boolean deferrable, Boolean initially_deferred, Boolean enabled, Boolean valid
|
||||
) {
|
||||
}
|
||||
@@ -14,6 +14,14 @@ public interface DatabaseAgent {
|
||||
/** Opt in to request-boundary timing, including pooled connection lifecycle. */
|
||||
default boolean supportsQueryTiming() { return false; }
|
||||
|
||||
/** A dedicated manual transaction must never migrate to a replacement connection. */
|
||||
default boolean permitsAutomaticReconnect() { return true; }
|
||||
|
||||
default List<QueryResult> executeQueryResults(String sql, String schema, ExecuteQueryOptions options) {
|
||||
throw new UnsupportedOperationException("Complete manual transaction batches are not supported by this driver");
|
||||
}
|
||||
|
||||
|
||||
void connect(ConnectParams params);
|
||||
|
||||
boolean testConnection(ConnectParams params);
|
||||
@@ -148,6 +156,10 @@ public interface DatabaseAgent {
|
||||
|
||||
List<ForeignKeyInfo> listForeignKeys(String schema, String table);
|
||||
|
||||
default List<ConstraintInfo> listConstraints(String schema, String table) {
|
||||
throw new UnsupportedOperationException("Constraint metadata is not supported by this driver");
|
||||
}
|
||||
|
||||
List<TriggerInfo> listTriggers(String schema, String table);
|
||||
|
||||
default List<PartitionInfo> listPartitions(String schema, String table) {
|
||||
|
||||
@@ -29,6 +29,7 @@ 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 final java.util.Set<NativeOperation> nativeOperations = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public JdbcExecutor() {
|
||||
}
|
||||
@@ -37,11 +38,121 @@ public final class JdbcExecutor {
|
||||
return AgentExecutionContext.jdbcExecutor();
|
||||
}
|
||||
|
||||
/** Latch cancellation across the sequential statements of one native driver operation. */
|
||||
public NativeOperation beginNativeOperation() {
|
||||
NativeOperation operation = new NativeOperation();
|
||||
nativeOperations.add(operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
public final class NativeOperation implements AutoCloseable {
|
||||
private final java.util.concurrent.atomic.AtomicBoolean cancelled = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
private NativeOperation() {
|
||||
}
|
||||
|
||||
public void checkCancelled() {
|
||||
if (cancelled.get()) {
|
||||
throw new java.util.concurrent.CancellationException("Native JDBC operation cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
nativeOperations.remove(this);
|
||||
}
|
||||
}
|
||||
/** Register native-driver statements with this session's cancellation boundary. */
|
||||
public <S extends Statement> TrackedStatement<S> trackStatement(S statement) {
|
||||
java.util.Objects.requireNonNull(statement, "statement");
|
||||
activeStatements.add(statement);
|
||||
return new TrackedStatement<>(statement);
|
||||
}
|
||||
|
||||
/** Owns the original JDBC Statement without changing its identity or unwrap behavior. */
|
||||
public final class TrackedStatement<S extends Statement> implements AutoCloseable {
|
||||
private final S statement;
|
||||
private boolean closed;
|
||||
|
||||
private TrackedStatement(S statement) {
|
||||
this.statement = statement;
|
||||
}
|
||||
|
||||
public S statement() {
|
||||
return statement;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws SQLException {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
statement.close();
|
||||
} finally {
|
||||
activeStatements.remove(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
public static int statementMaxRows(int maxRows) {
|
||||
int effectiveMaxRows = Math.max(maxRows, 1);
|
||||
return effectiveMaxRows == Integer.MAX_VALUE ? Integer.MAX_VALUE : effectiveMaxRows + 1;
|
||||
}
|
||||
|
||||
/** Execute one batch once, consuming every row and response even when retention is capped. */
|
||||
public List<QueryResult> executeAll(Connection conn, String sql, int maxRows, Integer fetchSize,
|
||||
int timeoutSecs, ResultValueReader valueReader) {
|
||||
return unchecked(() -> {
|
||||
long start = System.currentTimeMillis();
|
||||
int retainedRows = Math.max(maxRows, 1);
|
||||
List<QueryResult> results = new ArrayList<>();
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
activeStatements.add(stmt);
|
||||
try {
|
||||
// setMaxRows can truncate the server response; cap retained rows instead.
|
||||
applyQueryTimeout(stmt, timeoutSecs);
|
||||
if (fetchSize != null && fetchSize > 0) stmt.setFetchSize(fetchSize);
|
||||
boolean hasResult = stmt.execute(sql);
|
||||
while (true) {
|
||||
if (hasResult) {
|
||||
try (ResultSet rs = stmt.getResultSet()) {
|
||||
QueryResult result = readResultSet(rs, System.currentTimeMillis() - start, retainedRows, valueReader);
|
||||
while (rs.next()) { /* fully consume rows beyond the UI limit */ }
|
||||
results.add(result);
|
||||
}
|
||||
} else {
|
||||
int count = stmt.getUpdateCount();
|
||||
if (count < 0) break;
|
||||
results.add(new QueryResult(Collections.emptyList(), Collections.emptyList(), count,
|
||||
System.currentTimeMillis() - start, false));
|
||||
}
|
||||
appendStatementWarnings(results.get(results.size() - 1), stmt);
|
||||
hasResult = stmt.getMoreResults();
|
||||
}
|
||||
if (results.isEmpty()) results.add(new QueryResult(Collections.emptyList(), Collections.emptyList(),
|
||||
0, System.currentTimeMillis() - start, false));
|
||||
appendStatementWarnings(results.get(results.size() - 1), stmt);
|
||||
return results;
|
||||
} finally {
|
||||
activeStatements.remove(stmt);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void appendStatementWarnings(QueryResult result, Statement stmt) {
|
||||
try {
|
||||
Set<SQLWarning> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (SQLWarning warning = stmt.getWarnings(); warning != null && seen.add(warning); warning = warning.getNextWarning()) {
|
||||
String message = warning.getMessage();
|
||||
if (message != null && !message.isBlank()) result.addInformationalMessage(message,
|
||||
warning.getErrorCode() == 0 ? null : Integer.toString(warning.getErrorCode()));
|
||||
}
|
||||
stmt.clearWarnings();
|
||||
} catch (SQLException ignored) {
|
||||
// Advisory warnings cannot change an already acknowledged execution.
|
||||
}
|
||||
}
|
||||
|
||||
public QueryResult execute(Connection conn, String sql, String schema, Function<String, String> setSchemaSql) {
|
||||
return execute(conn, sql, schema, setSchemaSql, DEFAULT_MAX_ROWS, null, this::defaultResultValue);
|
||||
}
|
||||
@@ -572,6 +683,9 @@ public final class JdbcExecutor {
|
||||
}
|
||||
|
||||
public void cancelActiveStatements() {
|
||||
for (NativeOperation operation : nativeOperations) {
|
||||
operation.cancelled.set(true);
|
||||
}
|
||||
for (Statement statement : activeStatements) {
|
||||
try {
|
||||
statement.cancel();
|
||||
|
||||
@@ -279,6 +279,10 @@ public final class JsonRpcServer {
|
||||
switchCatalog(params);
|
||||
return agent.listTriggers(params.get("schema").getAsString(), params.get("table").getAsString());
|
||||
}
|
||||
if (AgentProtocol.METHOD_LIST_CONSTRAINTS.equals(method)) {
|
||||
switchCatalog(params);
|
||||
return agent.listConstraints(params.get("schema").getAsString(), params.get("table").getAsString());
|
||||
}
|
||||
if (AgentProtocol.METHOD_LIST_PARTITIONS.equals(method)) {
|
||||
switchCatalog(params);
|
||||
return agent.listPartitions(params.get("schema").getAsString(), params.get("table").getAsString());
|
||||
@@ -288,16 +292,14 @@ public final class JsonRpcServer {
|
||||
return agent.listSubpartitions(params.get("schema").getAsString(), params.get("table").getAsString());
|
||||
}
|
||||
if (AgentProtocol.METHOD_EXECUTE_QUERY.equals(method)) {
|
||||
return agent.executeQuery(
|
||||
params.get("sql").getAsString(),
|
||||
stringOrNull(params, "schema"),
|
||||
new ExecuteQueryOptions(
|
||||
intOrDefault(params, "maxRows", JdbcExecutor.DEFAULT_MAX_ROWS),
|
||||
intOrNull(params, "fetchSize"),
|
||||
intOrDefault(params, "timeoutSecs", 0),
|
||||
booleanOrDefault(params, "deferLobs", false)
|
||||
)
|
||||
);
|
||||
ExecuteQueryOptions options = new ExecuteQueryOptions(
|
||||
intOrDefault(params, "maxRows", JdbcExecutor.DEFAULT_MAX_ROWS),
|
||||
intOrNull(params, "fetchSize"), intOrDefault(params, "timeoutSecs", 0),
|
||||
booleanOrDefault(params, "deferLobs", false));
|
||||
if (params.has("returnAllResults") && params.get("returnAllResults").getAsBoolean()) {
|
||||
return agent.executeQueryResults(params.get("sql").getAsString(), stringOrNull(params, "schema"), options);
|
||||
}
|
||||
return agent.executeQuery(params.get("sql").getAsString(), stringOrNull(params, "schema"), options);
|
||||
}
|
||||
if (AgentProtocol.METHOD_EXECUTE_QUERY_PAGE.equals(method)) {
|
||||
return agent.executeQueryPage(
|
||||
@@ -410,6 +412,7 @@ public final class JsonRpcServer {
|
||||
}
|
||||
|
||||
private void ensureLiveConnection(String method) {
|
||||
if (!agent.permitsAutomaticReconnect()) return;
|
||||
if (lastConnectParams == null || !shouldValidateConnection(method)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -425,6 +425,8 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
|
||||
|
||||
private boolean quarantineAndClose(ExecutorService cleanup) {
|
||||
state.compareAndSet(State.ACTIVE, State.QUARANTINED);
|
||||
// Release native requests before cleanup waits for their session lock.
|
||||
cancel();
|
||||
boolean replaceRuntime = server != null && server.quarantine();
|
||||
if (!cleanupScheduled.compareAndSet(false, true)) {
|
||||
return replaceRuntime;
|
||||
|
||||
@@ -20,6 +20,22 @@ public final class QueryResult {
|
||||
private Long server_execute_time_us;
|
||||
private java.util.Map<String, Double> query_timings_ms;
|
||||
private boolean truncated;
|
||||
// Optional protocol field: existing drivers omit it, manual SQL Server batches retain PRINT/warnings.
|
||||
private List<Map<String, Object>> messages;
|
||||
|
||||
public List<Map<String, Object>> getMessages() {
|
||||
return messages == null ? Collections.emptyList() : messages;
|
||||
}
|
||||
|
||||
public void addInformationalMessage(String message, String code) {
|
||||
if (messages == null) messages = new ArrayList<>();
|
||||
if (messages.size() >= 512) return;
|
||||
Map<String, Object> entry = new java.util.LinkedHashMap<>();
|
||||
entry.put("severity", "INFO");
|
||||
entry.put("message", message);
|
||||
if (code != null) entry.put("code", code);
|
||||
messages.add(entry);
|
||||
}
|
||||
|
||||
public QueryResult() {
|
||||
this(Collections.emptyList(), Collections.emptyList(), 0L, 0L, false);
|
||||
|
||||
@@ -65,6 +65,15 @@ public final class StandardJdbcMetadata {
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
}
|
||||
}
|
||||
// HANA's databaseName (for example HXE) is not a JDBC catalog.
|
||||
// Its driver returns no rows, rather than throwing, for that catalog.
|
||||
// Retry without it before falling back to only the current schema.
|
||||
if (names.isEmpty() && catalog != null && profile.getCatalogFallbackEnabled()) {
|
||||
try {
|
||||
appendSchemas(names, meta.getSchemas(null, null));
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
addNonBlank(names, conn.getSchema());
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
@@ -641,6 +650,10 @@ public final class StandardJdbcMetadata {
|
||||
if ("BASE TABLE".equals(normalized) || "COLUMN TABLE".equals(normalized) || "ROW TABLE".equals(normalized)) {
|
||||
return "TABLE";
|
||||
}
|
||||
if ("CALC VIEW".equals(normalized) || "JOIN VIEW".equals(normalized)
|
||||
|| "OLAP VIEW".equals(normalized) || "HIERARCHY VIEW".equals(normalized)) {
|
||||
return "VIEW";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,17 @@ class CommonJavaCompatibilityTest {
|
||||
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), "metadata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constraintMetadataUnsupportedIsNotAnEmptySuccess() {
|
||||
JsonRpcServer server = new JsonRpcServer(new MinimalAgent());
|
||||
JsonObject response = JsonParser.parseString(server.handleRequest(
|
||||
"{\"id\":1,\"method\":\"list_constraints\",\"params\":{\"schema\":\"APP\",\"table\":\"T\"}}"
|
||||
)).getAsJsonObject();
|
||||
|
||||
assertFalse(response.has("result"));
|
||||
assertTrue(response.getAsJsonObject("error").get("message").getAsString().contains("not supported"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonRpcConnectionTestAddsOptionalDatabaseInfoWithoutChangingLegacySuccess() {
|
||||
JsonRpcServer legacyServer = new JsonRpcServer(new MinimalAgent());
|
||||
@@ -381,6 +392,66 @@ class CommonJavaCompatibilityTest {
|
||||
assertFalse(queryB.isAlive());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeSessionCancellationOnlyCancelsTargetSession() throws Exception {
|
||||
nativeCancellationProof("cancel_session");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeSessionCloseCancelsTargetBeforeWaitingForItsRequestLock() throws Exception {
|
||||
nativeCancellationProof("close_session");
|
||||
}
|
||||
|
||||
private void nativeCancellationProof(String action) throws Exception {
|
||||
List<NativeCancelAgent> created = Collections.synchronizedList(new ArrayList<>());
|
||||
MultiSessionJsonRpcServer server = new MultiSessionJsonRpcServer(() -> {
|
||||
NativeCancelAgent agent = new NativeCancelAgent();
|
||||
created.add(agent);
|
||||
return agent;
|
||||
});
|
||||
nativeRequest(server, "open_session", "a", 1);
|
||||
nativeRequest(server, "open_session", "b", 2);
|
||||
try (var workers = java.util.concurrent.Executors.newFixedThreadPool(2)) {
|
||||
var target = workers.submit(() -> nativeRequest(server, "get_explain_info", "a", 3));
|
||||
var sibling = workers.submit(() -> nativeRequest(server, "get_explain_info", "b", 4));
|
||||
try {
|
||||
assertTrue(created.get(0).started.await(3, TimeUnit.SECONDS));
|
||||
assertTrue(created.get(1).started.await(3, TimeUnit.SECONDS));
|
||||
nativeRequest(server, action, "a", 5);
|
||||
String targetResponse = target.get(2, TimeUnit.SECONDS);
|
||||
assertTrue(JsonParser.parseString(targetResponse).getAsJsonObject().has("error"), targetResponse);
|
||||
assertTrue(created.get(0).cancelled.get());
|
||||
assertTrue(created.get(0).cleaned.get());
|
||||
assertFalse(created.get(0).executor.hasActiveStatements());
|
||||
assertFalse(created.get(1).cancelled.get(), "Sibling native Statement remains active");
|
||||
assertFalse(sibling.isDone(), "Sibling request remains blocked until its own release");
|
||||
if ("close_session".equals(action)) {
|
||||
assertTrue(created.get(0).closed.await(2, TimeUnit.SECONDS), "Close acquired request lock after cancellation");
|
||||
}
|
||||
created.get(1).release.countDown();
|
||||
String siblingResponse = sibling.get(2, TimeUnit.SECONDS);
|
||||
assertTrue(JsonParser.parseString(siblingResponse).getAsJsonObject().has("result"), siblingResponse);
|
||||
} finally {
|
||||
created.forEach(agent -> agent.release.countDown());
|
||||
nativeRequest(server, "close_session", "a", 6);
|
||||
nativeRequest(server, "close_session", "b", 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String nativeRequest(MultiSessionJsonRpcServer server, String method, String session, int id) {
|
||||
JsonObject request = new JsonObject();
|
||||
request.addProperty("jsonrpc", "2.0");
|
||||
request.addProperty("id", id);
|
||||
request.addProperty("method", method);
|
||||
JsonObject params = new JsonObject();
|
||||
params.addProperty("agentSessionId", session);
|
||||
params.addProperty("sql", "SELECT 1");
|
||||
params.addProperty("timeoutSecs", 0);
|
||||
params.addProperty("mode", "explain");
|
||||
request.add("params", params);
|
||||
return server.handleRequest(request.toString());
|
||||
}
|
||||
@Test
|
||||
void cancelActiveStatementsClosesOnlyThatExecutorPagedSessions() {
|
||||
JdbcExecutor target = new JdbcExecutor();
|
||||
@@ -980,6 +1051,41 @@ class CommonJavaCompatibilityTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NativeCancelAgent extends MinimalAgent {
|
||||
private final CountDownLatch started = new CountDownLatch(1);
|
||||
private final CountDownLatch release = new CountDownLatch(1);
|
||||
private final CountDownLatch closed = new CountDownLatch(1);
|
||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||
private final AtomicBoolean cleaned = new AtomicBoolean();
|
||||
private volatile JdbcExecutor executor;
|
||||
private final java.sql.Statement statement = proxy(java.sql.Statement.class, (method, args) -> {
|
||||
if ("cancel".equals(method.getName())) {
|
||||
cancelled.set(true);
|
||||
release.countDown();
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
@Override
|
||||
public String getExplainInfo(String sql, String database, String schema, int timeout, String mode) {
|
||||
executor = JdbcExecutor.current();
|
||||
try (var operation = executor.beginNativeOperation(); var tracked = executor.trackStatement(statement)) {
|
||||
started.countDown();
|
||||
if (!release.await(5, TimeUnit.SECONDS)) throw new AssertionError("Native Statement was not cancelled");
|
||||
operation.checkCancelled();
|
||||
return "{}";
|
||||
} catch (InterruptedException | java.sql.SQLException error) {
|
||||
throw new RuntimeException(error);
|
||||
} finally {
|
||||
cleaned.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {
|
||||
closed.countDown();
|
||||
}
|
||||
}
|
||||
private static final class CancelTrackingAgent extends MinimalAgent {
|
||||
private final CountDownLatch statementStarted = new CountDownLatch(1);
|
||||
private final CountDownLatch release = new CountDownLatch(1);
|
||||
|
||||
@@ -26,6 +26,70 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JdbcExecutorTest {
|
||||
@Test
|
||||
void executeAllPreservesPrintMessagesAlongsideRows() {
|
||||
CountingResultSetFixture rows = countingResultSet(new Object[][]{{1, "value"}});
|
||||
List<QueryResult> results = JdbcExecutor.INSTANCE.executeAll(
|
||||
executionConnection(true, -1, new SQLWarning("printed", "01000", 42), new AtomicInteger(), rows.resultSet(), null),
|
||||
"SELECT; PRINT", 10, null, 5, JdbcExecutor.INSTANCE::defaultResultValue);
|
||||
assertEquals("printed", results.get(0).getMessages().get(0).get("message"));
|
||||
assertEquals("42", results.get(0).getMessages().get(0).get("code"));
|
||||
assertEquals(1, results.get(0).getRows().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeAllDrainsTruncatedRowsAndLaterResultsWithoutReexecuting() {
|
||||
AtomicInteger executeCalls = new AtomicInteger();
|
||||
AtomicInteger index = new AtomicInteger();
|
||||
AtomicInteger drained = new AtomicInteger();
|
||||
ResultSet first = countingResultSet(new Object[][]{{1, "a"}, {2, "b"}, {3, "c"}, {4, "d"}}).resultSet();
|
||||
ResultSet tracked = (ResultSet) Proxy.newProxyInstance(ResultSet.class.getClassLoader(), new Class<?>[]{ResultSet.class}, (ignored, method, args) -> {
|
||||
Object result = method.invoke(first, args);
|
||||
if (method.getName().equals("next") && Boolean.TRUE.equals(result)) drained.incrementAndGet();
|
||||
return result;
|
||||
});
|
||||
ResultSet second = countingResultSet(new Object[][]{{9, "last"}}).resultSet();
|
||||
Statement statement = (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class<?>[]{Statement.class}, (ignored, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "execute": executeCalls.incrementAndGet(); return true;
|
||||
case "setMaxRows": throw new AssertionError("UI retention must not cap JDBC responses");
|
||||
case "getResultSet": return index.get() == 0 ? tracked : second;
|
||||
case "getMoreResults": int next = index.incrementAndGet(); return next == 2;
|
||||
case "getUpdateCount": return index.get() == 1 ? 7 : -1;
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
Connection conn = (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[]{Connection.class}, (ignored, method, args) ->
|
||||
method.getName().equals("createStatement") ? statement : defaultValue(method.getReturnType()));
|
||||
List<QueryResult> results = JdbcExecutor.INSTANCE.executeAll(conn, "SELECT; UPDATE; SELECT", 1, null, 5,
|
||||
JdbcExecutor.INSTANCE::defaultResultValue);
|
||||
assertEquals(1, executeCalls.get());
|
||||
assertEquals(4, drained.get());
|
||||
assertEquals(3, results.size());
|
||||
assertTrue(results.get(0).getTruncated());
|
||||
assertEquals(1, results.get(0).getRows().size());
|
||||
assertEquals(7, results.get(1).getAffected_rows());
|
||||
assertEquals(9, results.get(2).getRows().get(0).get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeAllPropagatesLateErrorsInsteadOfReturningPartialSuccess() {
|
||||
AtomicInteger executeCalls = new AtomicInteger();
|
||||
Statement stmt = (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class<?>[]{Statement.class}, (ignored, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "execute": executeCalls.incrementAndGet(); return false;
|
||||
case "getUpdateCount": return 1;
|
||||
case "getMoreResults": throw new SQLException("late failure");
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
Connection conn = (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[]{Connection.class}, (ignored, method, args) ->
|
||||
method.getName().equals("createStatement") ? stmt : defaultValue(method.getReturnType()));
|
||||
assertThrows(RuntimeException.class, () -> JdbcExecutor.INSTANCE.executeAll(conn, "UPDATE", 1, null, 5,
|
||||
JdbcExecutor.INSTANCE::defaultResultValue));
|
||||
assertEquals(1, executeCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statementRowLimitIncludesOverflowProbeWithoutIntegerOverflow() {
|
||||
assertEquals(2, JdbcExecutor.statementMaxRows(0));
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JdbcNativeStatementTrackingTest {
|
||||
@Test
|
||||
void trackedStatementKeepsOriginalIdentityAndCancellationIsSessionLocal() throws Exception {
|
||||
JdbcExecutor target = new JdbcExecutor();
|
||||
JdbcExecutor sibling = new JdbcExecutor();
|
||||
AtomicInteger targetCancels = new AtomicInteger();
|
||||
AtomicInteger siblingCancels = new AtomicInteger();
|
||||
PreparedStatement original = statement(targetCancels, null);
|
||||
try (var a = target.trackStatement(original); var b = sibling.trackStatement(statement(siblingCancels, null))) {
|
||||
assertSame(original, a.statement());
|
||||
target.cancelActiveStatements();
|
||||
assertEquals(1, targetCancels.get());
|
||||
assertEquals(0, siblingCancels.get());
|
||||
assertTrue(target.hasActiveStatements());
|
||||
assertTrue(sibling.hasActiveStatements());
|
||||
}
|
||||
assertFalse(target.hasActiveStatements());
|
||||
assertFalse(sibling.hasActiveStatements());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationIsRemovedEvenWhenJdbcCloseFails() {
|
||||
JdbcExecutor executor = new JdbcExecutor();
|
||||
SQLException failure = new SQLException("close failed");
|
||||
var tracked = executor.trackStatement(statement(new AtomicInteger(), failure));
|
||||
assertSame(failure, assertThrows(SQLException.class, tracked::close));
|
||||
assertFalse(executor.hasActiveStatements());
|
||||
assertDoesNotThrow(tracked::close);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationLatchesAcrossStatementGapsButDoesNotPoisonNextNativeOperation() {
|
||||
JdbcExecutor executor = new JdbcExecutor();
|
||||
try (var operation = executor.beginNativeOperation()) {
|
||||
assertFalse(executor.hasActiveStatements());
|
||||
executor.cancelActiveStatements();
|
||||
assertThrows(CancellationException.class, operation::checkCancelled);
|
||||
}
|
||||
try (var next = executor.beginNativeOperation()) {
|
||||
assertDoesNotThrow(next::checkCancelled);
|
||||
}
|
||||
}
|
||||
|
||||
private static PreparedStatement statement(AtomicInteger cancels, SQLException closeError) {
|
||||
return (PreparedStatement) Proxy.newProxyInstance(PreparedStatement.class.getClassLoader(),
|
||||
new Class<?>[]{PreparedStatement.class}, (proxy, method, args) -> {
|
||||
if ("cancel".equals(method.getName())) cancels.incrementAndGet();
|
||||
if ("close".equals(method.getName()) && closeError != null) throw closeError;
|
||||
if ("hashCode".equals(method.getName())) return System.identityHashCode(proxy);
|
||||
if ("equals".equals(method.getName())) return proxy == args[0];
|
||||
if (method.getReturnType() == boolean.class) return false;
|
||||
if (method.getReturnType() == int.class) return 0;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -48,10 +50,12 @@ class StandardJdbcMetadataTest {
|
||||
@Test
|
||||
void scopesSchemasToTheConnectionCatalog() {
|
||||
AtomicReference<Object[]> capturedArgs = new AtomicReference<>();
|
||||
AtomicInteger schemaCalls = new AtomicInteger();
|
||||
DatabaseMetaData meta = proxy(DatabaseMetaData.class, new MethodHandler() {
|
||||
@Override
|
||||
public Object handle(Method method, Object[] args) {
|
||||
if ("getSchemas".equals(method.getName())) {
|
||||
schemaCalls.incrementAndGet();
|
||||
capturedArgs.set(args);
|
||||
return rows(row("TABLE_SCHEM", "APP"));
|
||||
}
|
||||
@@ -74,6 +78,57 @@ class StandardJdbcMetadataTest {
|
||||
assertEquals(List.of("APP"), StandardJdbcMetadata.INSTANCE.listSchemas(conn, profile, "initial_catalog"));
|
||||
assertEquals("regular_catalog", capturedArgs.get()[0]);
|
||||
assertEquals(null, capturedArgs.get()[1]);
|
||||
assertEquals(1, schemaCalls.get());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"false, false, true", "true, false, true", "false, true, true", "false, false, false"})
|
||||
void retriesEmptyCatalogSchemasBeforeAddingCurrentSchema(boolean fallbackFails, boolean currentCatalog, boolean fallbackEnabled) {
|
||||
List<String> catalogs = new ArrayList<>();
|
||||
DatabaseMetaData meta = proxy(DatabaseMetaData.class, new MethodHandler() {
|
||||
@Override
|
||||
public Object handle(Method method, Object[] args) {
|
||||
if ("getSchemas".equals(method.getName())) {
|
||||
String catalog = (String) args[0];
|
||||
catalogs.add(catalog);
|
||||
if (catalog != null) {
|
||||
return rows();
|
||||
}
|
||||
if (fallbackFails) {
|
||||
throw new UnsupportedOperationException("Unscoped schemas unavailable");
|
||||
}
|
||||
return rows(row("TABLE_SCHEM", "DBX_USER"), row("TABLE_SCHEM", "DBX_DEMO"), row("TABLE_SCHEM", "SYS"));
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
Connection conn = proxy(Connection.class, new MethodHandler() {
|
||||
@Override
|
||||
public Object handle(Method method, Object[] args) {
|
||||
if ("getMetaData".equals(method.getName())) {
|
||||
return meta;
|
||||
}
|
||||
if ("getCatalog".equals(method.getName())) {
|
||||
return currentCatalog ? "HXE" : null;
|
||||
}
|
||||
if ("getSchema".equals(method.getName())) {
|
||||
return "DBX_USER";
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
|
||||
JdbcAgentProfile effectiveProfile = fallbackEnabled ? profile : new JdbcAgentProfile("example.Driver", "jdbc:example:") {
|
||||
@Override
|
||||
public boolean getCatalogFallbackEnabled() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
assertEquals(
|
||||
fallbackFails || !fallbackEnabled ? List.of("DBX_USER") : List.of("DBX_DEMO", "DBX_USER"),
|
||||
StandardJdbcMetadata.INSTANCE.listSchemas(conn, effectiveProfile, currentCatalog ? "initial_catalog" : "HXE")
|
||||
);
|
||||
assertEquals(fallbackEnabled ? Arrays.asList("HXE", null) : List.of("HXE"), catalogs);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,15 +5,19 @@ import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.JdbcAgentProfile;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.StandardJdbcMetadata;
|
||||
import com.dbx.agent.TableInfo;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -37,6 +41,7 @@ public final class CacheAgent extends ConfiguredJdbcAgent {
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
super.afterConnect(params, connection);
|
||||
configuredDatabase = params.getDatabase();
|
||||
}
|
||||
|
||||
@@ -74,6 +79,85 @@ public final class CacheAgent extends ConfiguredJdbcAgent {
|
||||
return dedupeCaseInsensitiveSchemas(StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), CACHE_PROFILE));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema) {
|
||||
return listTables(schema, MetadataListConstraints.NONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema, MetadataListConstraints constraints) {
|
||||
MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints);
|
||||
List<String> tableTypes = new ArrayList<>();
|
||||
for (String tableType : CACHE_PROFILE.getTableTypes()) {
|
||||
if (normalized.tableTypeAllowed(tableType)) {
|
||||
tableTypes.add(tableType);
|
||||
}
|
||||
}
|
||||
if (tableTypes.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return unchecked(() -> {
|
||||
Connection connection = requireConnection();
|
||||
try {
|
||||
return readTables(connection, schema, normalized, tableTypes);
|
||||
} catch (SQLException error) {
|
||||
if (error.getErrorCode() != -30 && !"42S02".equals(error.getSQLState())) {
|
||||
throw error;
|
||||
}
|
||||
return StandardJdbcMetadata.INSTANCE.listTables(connection, CACHE_PROFILE, configuredDatabase, schema, normalized);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static List<TableInfo> readTables(
|
||||
Connection connection,
|
||||
String schema,
|
||||
MetadataListConstraints constraints,
|
||||
List<String> tableTypes
|
||||
) throws SQLException {
|
||||
int offset = constraints.hasOffset() ? constraints.getOffset() : 0;
|
||||
int limit = constraints.hasLimit() ? constraints.getLimit() : Integer.MAX_VALUE;
|
||||
long fetchLimit = (long) offset + limit;
|
||||
StringBuilder sql = new StringBuilder("SELECT ");
|
||||
if (constraints.hasLimit() && !constraints.hasFilter() && fetchLimit <= Integer.MAX_VALUE) {
|
||||
sql.append("TOP ").append(fetchLimit).append(' ');
|
||||
}
|
||||
sql.append("TABLE_NAME, TABLE_TYPE, DESCRIPTION FROM INFORMATION_SCHEMA.TABLES WHERE ");
|
||||
if (!isBlank(schema)) {
|
||||
sql.append("TABLE_SCHEMA = ? AND ");
|
||||
}
|
||||
sql.append("TABLE_TYPE IN (").append(String.join(", ", Collections.nCopies(tableTypes.size(), "?")))
|
||||
.append(") ORDER BY TABLE_NAME, TABLE_SCHEMA");
|
||||
|
||||
List<TableInfo> tables = new ArrayList<>();
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql.toString())) {
|
||||
int parameter = 1;
|
||||
if (!isBlank(schema)) {
|
||||
statement.setString(parameter++, schema.trim());
|
||||
}
|
||||
for (String tableType : tableTypes) {
|
||||
statement.setString(parameter++, tableType);
|
||||
}
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
int skipped = 0;
|
||||
while (tables.size() < limit && result.next()) {
|
||||
String name = result.getString("TABLE_NAME");
|
||||
String comment = result.getString("DESCRIPTION");
|
||||
if (!constraints.nameOrCommentMatches(name, comment)) {
|
||||
continue;
|
||||
}
|
||||
if (skipped < offset) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
String tableType = result.getString("TABLE_TYPE");
|
||||
tables.add(new TableInfo(name, "BASE TABLE".equalsIgnoreCase(tableType) ? "TABLE" : tableType, comment));
|
||||
}
|
||||
}
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return cacheColumns(requireConnection(), getProfile(), configuredDatabase, schema, table);
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
package com.dbx.agent.cache;
|
||||
|
||||
import com.dbx.agent.AbstractJdbcAgent;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class CacheAgentTablesTest {
|
||||
@Test
|
||||
void bindsSpecialSchemaNamesLiterallyWithoutJdbcMetadata() throws Exception {
|
||||
for (String schema : List.of("_system", "%Atelier", "SQLUser", "a'b")) {
|
||||
Fixture fixture = new Fixture(List.of(table("Example", "BASE TABLE", "description")));
|
||||
List<TableInfo> tables = fixture.agent.listTables(schema);
|
||||
|
||||
assertEquals("Example", tables.get(0).getName());
|
||||
assertEquals("TABLE", tables.get(0).getTable_type());
|
||||
assertEquals("description", tables.get(0).getComment());
|
||||
assertTrue(fixture.sql.contains("FROM INFORMATION_SCHEMA.TABLES"));
|
||||
assertTrue(fixture.sql.contains("TABLE_SCHEMA = ?"));
|
||||
assertEquals(schema, fixture.bindings.get(0));
|
||||
assertFalse(fixture.sql.contains(schema));
|
||||
assertTrue(fixture.statementClosed);
|
||||
assertTrue(fixture.resultClosed);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopsReadingMetadataAtTheRequestedPage() throws Exception {
|
||||
List<Map<String, String>> rows = new ArrayList<>();
|
||||
for (int index = 0; index < 5000; index++) {
|
||||
rows.add(table(String.format("Table%05d", index), "TABLE", ""));
|
||||
}
|
||||
Fixture fixture = new Fixture(rows);
|
||||
|
||||
List<TableInfo> tables = fixture.agent.listTables("_system", constraints(null, 2, 3, "TABLE"));
|
||||
|
||||
assertEquals(2, tables.size());
|
||||
assertEquals("Table00003", tables.get(0).getName());
|
||||
assertEquals("Table00004", tables.get(1).getName());
|
||||
assertEquals(5, fixture.rowsRead);
|
||||
assertTrue(fixture.sql.startsWith("SELECT TOP 5 "));
|
||||
assertTrue(fixture.sql.endsWith("ORDER BY TABLE_NAME, TABLE_SCHEMA"));
|
||||
assertEquals(List.of("_system", "TABLE", "BASE TABLE", "SYSTEM TABLE"), fixture.bindings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersViewsBeforeApplyingThePageLimit() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(table("Report", "VIEW", "")));
|
||||
|
||||
assertEquals("VIEW", fixture.agent.listTables("SQLUser", constraints(null, 1, 0, "VIEW")).get(0).getTable_type());
|
||||
assertEquals(List.of("SQLUser", "VIEW", "SYSTEM VIEW"), fixture.bindings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesObjectTypeOverloadThroughTheNativeQuery() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(table("Report", "SYSTEM VIEW", "")));
|
||||
|
||||
assertEquals("SYSTEM VIEW", fixture.agent.listTables("SQLUser", List.of("VIEW")).get(0).getTable_type());
|
||||
assertEquals(List.of("SQLUser", "VIEW", "SYSTEM VIEW"), fixture.bindings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesOffsetBeyondTheLastTable() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(table("Example", "TABLE", "")));
|
||||
|
||||
assertTrue(fixture.agent.listTables("SQLUser", constraints(null, 2, 5, "TABLE")).isEmpty());
|
||||
assertEquals(1, fixture.rowsRead);
|
||||
}
|
||||
|
||||
@Test
|
||||
void avoidsOverflowInTheServerFetchLimit() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
|
||||
assertTrue(fixture.agent.listTables("SQLUser", constraints(null, 2, Integer.MAX_VALUE, "TABLE")).isEmpty());
|
||||
assertFalse(fixture.sql.contains("TOP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesFuzzyNameAndCommentSearchBeforeOffset() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(
|
||||
table("Alpha", "TABLE", ""),
|
||||
table("Budget", "TABLE", "monthly report"),
|
||||
table("Other", "TABLE", ""),
|
||||
table("MonthlyReport", "TABLE", ""),
|
||||
table("Unused", "TABLE", "")
|
||||
));
|
||||
|
||||
List<TableInfo> tables = fixture.agent.listTables("SQLUser", constraints("mrt", 1, 1, "TABLE"));
|
||||
|
||||
assertEquals("MonthlyReport", tables.get(0).getName());
|
||||
assertEquals(4, fixture.rowsRead);
|
||||
assertFalse(fixture.sql.contains("TOP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsUnscopedAndUnpagedLists() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(table("Alpha", "TABLE", ""), table("Beta", "SYSTEM VIEW", "")));
|
||||
|
||||
assertEquals(2, fixture.agent.listTables(null).size());
|
||||
assertFalse(fixture.sql.contains("TABLE_SCHEMA = ?"));
|
||||
assertFalse(fixture.sql.contains("TOP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedObjectTypesDoNotContactTheDatabase() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
|
||||
assertTrue(fixture.agent.listTables("SQLUser", constraints(null, 1, 0, "PROCEDURE")).isEmpty());
|
||||
assertEquals("", fixture.sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotFallbackForAnEmptySchema() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
|
||||
assertTrue(fixture.agent.listTables("Empty").isEmpty());
|
||||
assertTrue(fixture.statementClosed);
|
||||
assertTrue(fixture.resultClosed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToJdbcForServersWithoutInformationSchema() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(table("Legacy", "TABLE", "")));
|
||||
fixture.queryFailure = new SQLException("Table INFORMATION_SCHEMA.TABLES not found", "42S02", -30);
|
||||
fixture.allowJdbc = true;
|
||||
|
||||
assertEquals("Legacy", fixture.agent.listTables("SQLUser", constraints(null, 1, 0, "TABLE")).get(0).getName());
|
||||
assertTrue(fixture.jdbcUsed);
|
||||
assertTrue(fixture.statementClosed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesConfiguredNamespaceForInheritedMetadata() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
fixture.allowJdbc = true;
|
||||
|
||||
assertEquals("USER", fixture.agent.listDatabases().get(0).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesResourcesWhenReadingFails() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
fixture.readFailure = new SQLException("connection lost", "08006");
|
||||
|
||||
assertThrows(RuntimeException.class, () -> fixture.agent.listTables("SQLUser"));
|
||||
assertTrue(fixture.statementClosed);
|
||||
assertTrue(fixture.resultClosed);
|
||||
assertFalse(fixture.jdbcUsed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotRetryPermissionOrConnectionErrorsThroughLegacyMetadata() throws Exception {
|
||||
for (String state : List.of("42000", "08006")) {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
fixture.queryFailure = new SQLException("metadata query failed", state);
|
||||
|
||||
assertThrows(RuntimeException.class, () -> fixture.agent.listTables("SQLUser"));
|
||||
assertTrue(fixture.statementClosed);
|
||||
assertFalse(fixture.jdbcUsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static MetadataListConstraints constraints(String filter, int limit, int offset, String type) {
|
||||
return new MetadataListConstraints(filter, limit, offset, List.of(type));
|
||||
}
|
||||
|
||||
private static Map<String, String> table(String name, String type, String comment) {
|
||||
return Map.of("TABLE_NAME", name, "TABLE_TYPE", type, "DESCRIPTION", comment, "REMARKS", comment);
|
||||
}
|
||||
|
||||
private static <Value> Value proxy(Class<Value> type, InvocationHandler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final CacheAgent agent = new CacheAgent();
|
||||
private final List<String> bindings = new ArrayList<>();
|
||||
private String sql = "";
|
||||
private int rowsRead;
|
||||
private boolean statementClosed;
|
||||
private boolean resultClosed;
|
||||
private boolean allowJdbc;
|
||||
private boolean jdbcUsed;
|
||||
private SQLException queryFailure;
|
||||
private SQLException readFailure;
|
||||
|
||||
private Fixture(List<Map<String, String>> values) throws Exception {
|
||||
Connection connection = proxy(Connection.class, (instance, method, arguments) -> {
|
||||
switch (method.getName()) {
|
||||
case "prepareStatement":
|
||||
sql = (String) arguments[0];
|
||||
return proxy(PreparedStatement.class, (statement, operation, parameters) -> {
|
||||
switch (operation.getName()) {
|
||||
case "setString": bindings.add((String) parameters[1]); return null;
|
||||
case "executeQuery":
|
||||
if (queryFailure != null) throw queryFailure;
|
||||
return rows(values);
|
||||
case "close": statementClosed = true; return null;
|
||||
default: throw new AssertionError(operation.getName());
|
||||
}
|
||||
});
|
||||
case "getMetaData":
|
||||
if (!allowJdbc) throw new AssertionError("unexpected legacy JDBC metadata query");
|
||||
jdbcUsed = true;
|
||||
return proxy(DatabaseMetaData.class, (metadata, operation, parameters) -> {
|
||||
switch (operation.getName()) {
|
||||
case "getTables": return rows(values);
|
||||
case "getTableTypes": return rows(List.of(Map.of("TABLE_TYPE", "TABLE")));
|
||||
case "getCatalogs": return rows(List.of());
|
||||
case "getSearchStringEscape": return "\\";
|
||||
default: throw new AssertionError(operation.getName());
|
||||
}
|
||||
});
|
||||
case "getCatalog": return null;
|
||||
default: throw new AssertionError(method.getName());
|
||||
}
|
||||
});
|
||||
Field connectionField = AbstractJdbcAgent.class.getDeclaredField("connection");
|
||||
connectionField.setAccessible(true);
|
||||
connectionField.set(agent, connection);
|
||||
agent.afterConnect(new ConnectParams("db.local", 1972, "USER", "", "", "", "", false), connection);
|
||||
}
|
||||
|
||||
private ResultSet rows(List<Map<String, String>> values) {
|
||||
int[] position = {-1};
|
||||
return proxy(ResultSet.class, (instance, method, arguments) -> {
|
||||
switch (method.getName()) {
|
||||
case "next":
|
||||
if (readFailure != null) throw readFailure;
|
||||
boolean hasNext = ++position[0] < values.size();
|
||||
if (hasNext) rowsRead++;
|
||||
return hasNext;
|
||||
case "getString": return values.get(position[0]).get((String) arguments[0]);
|
||||
case "close": resultClosed = true; return null;
|
||||
default: throw new AssertionError(method.getName());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.12.1'
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.ibm.db2:jcc:11.5.9.0'
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ public final class Db2Agent extends AbstractJdbcAgent {
|
||||
deferredTableReadSessions.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getExplainInfo(String sql, String database, String schema, int timeoutSecs, String mode) {
|
||||
return unchecked(() -> Db2ExplainPlan.read(requireConnected(), sql, schema, timeoutSecs, mode));
|
||||
}
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
return unchecked(() -> {
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
package com.dbx.agent.db2;
|
||||
|
||||
import com.dbx.agent.JdbcIdentifiers;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.google.gson.Gson;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Reads a single, tagged DB2 LUW estimated plan on an isolated connection. */
|
||||
final class Db2ExplainPlan {
|
||||
private static final Gson JSON = new Gson();
|
||||
private static final int MAX_ROWS = 10_000;
|
||||
private static final String[] REQUEST_KEYS = {
|
||||
"EXPLAIN_REQUESTER", "EXPLAIN_TIME", "SOURCE_NAME", "SOURCE_SCHEMA",
|
||||
"SOURCE_VERSION", "EXPLAIN_LEVEL", "STMTNO", "SECTNO"
|
||||
};
|
||||
|
||||
private Db2ExplainPlan() {
|
||||
}
|
||||
|
||||
static String read(Connection connection, String sql, String schema, int timeoutSecs, String mode)
|
||||
throws SQLException {
|
||||
if (mode != null && !mode.isBlank() && !"explain".equalsIgnoreCase(mode)) {
|
||||
throw new IllegalArgumentException("DB2 supports estimated execution plans only");
|
||||
}
|
||||
JdbcExecutor executor = JdbcExecutor.current();
|
||||
String source = singleQuery(sql);
|
||||
// Do not risk committing or rolling back an existing business transaction.
|
||||
if (!connection.getAutoCommit()) {
|
||||
throw new IllegalStateException("DB2 execution plans require an isolated connection with autocommit enabled");
|
||||
}
|
||||
try (var operation = executor.beginNativeOperation()) {
|
||||
return readPlan(connection, source, schema, timeoutSecs, executor, operation);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readPlan(Connection connection, String source, String schema, int timeoutSecs,
|
||||
JdbcExecutor executor, JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
String originalSchema = null;
|
||||
boolean schemaChanged = false;
|
||||
Throwable failure = null;
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
String requester;
|
||||
try (var tracked = statement(connection, timeoutSecs, executor, operation);
|
||||
ResultSet rows = tracked.statement().executeQuery("SELECT SESSION_USER, CURRENT SCHEMA FROM SYSIBM.SYSDUMMY1")) {
|
||||
if (!rows.next()) {
|
||||
throw new SQLException("DB2 did not return the session authorization ID");
|
||||
}
|
||||
requester = rows.getString(1).trim();
|
||||
originalSchema = rows.getString(2).trim();
|
||||
}
|
||||
String explainSchema = findExplainSchema(connection, requester, timeoutSecs, executor, operation);
|
||||
if (schema != null && !schema.isBlank() && !schema.equals(originalSchema)) {
|
||||
// SET SCHEMA is a session register: rollback alone does not restore it.
|
||||
schemaChanged = true;
|
||||
execute(connection, "SET SCHEMA " + quote(schema), timeoutSecs, executor, operation);
|
||||
}
|
||||
String tag = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||
execute(connection, "EXPLAIN PLAN SET QUERYTAG = '" + tag + "' FOR " + source, timeoutSecs, executor, operation);
|
||||
Map<String, Object> plan = new LinkedHashMap<>();
|
||||
plan.put("version", 1);
|
||||
plan.put("databaseType", "db2");
|
||||
plan.put("requestTag", tag);
|
||||
plan.put("statement", readStatement(connection, explainSchema, requester, tag, timeoutSecs, executor, operation));
|
||||
plan.put("operators", readRows(connection, explainSchema, "EXPLAIN_OPERATOR",
|
||||
"r.OPERATOR_ID, r.OPERATOR_TYPE, r.TOTAL_COST, r.IO_COST, r.CPU_COST, r.FIRST_ROW_COST",
|
||||
"r.OPERATOR_ID", requester, tag, timeoutSecs,
|
||||
new String[]{"id", "type", "totalCost", "ioCost", "cpuCost", "firstRowCost"}, executor, operation));
|
||||
plan.put("streams", readRows(connection, explainSchema, "EXPLAIN_STREAM",
|
||||
"r.STREAM_ID, r.SOURCE_TYPE, r.SOURCE_ID, r.TARGET_TYPE, r.TARGET_ID, r.STREAM_COUNT, r.OBJECT_SCHEMA, r.OBJECT_NAME",
|
||||
"r.STREAM_ID", requester, tag, timeoutSecs,
|
||||
new String[]{"id", "sourceType", "sourceId", "targetType", "targetId", "rowCount", "objectSchema", "objectName"}, executor, operation));
|
||||
plan.put("predicates", readRows(connection, explainSchema, "EXPLAIN_PREDICATE",
|
||||
"r.OPERATOR_ID, r.PREDICATE_TEXT, r.HOW_APPLIED",
|
||||
"r.OPERATOR_ID, r.PREDICATE_ID", requester, tag, timeoutSecs,
|
||||
new String[]{"operatorId", "text", "howApplied"}, executor, operation));
|
||||
if (((List<?>) plan.get("operators")).isEmpty()) {
|
||||
throw new SQLException("DB2 returned no operators for this execution plan");
|
||||
}
|
||||
return JSON.toJson(plan);
|
||||
} catch (SQLException error) {
|
||||
SQLException diagnostic = diagnostic(error);
|
||||
failure = diagnostic;
|
||||
throw diagnostic;
|
||||
} catch (RuntimeException | Error error) {
|
||||
failure = error;
|
||||
throw error;
|
||||
} finally {
|
||||
SQLException cleanupFailure = null;
|
||||
try {
|
||||
connection.rollback();
|
||||
if (schemaChanged && originalSchema != null) {
|
||||
execute(connection, "SET SCHEMA " + quote(originalSchema), timeoutSecs, executor, null);
|
||||
connection.rollback();
|
||||
}
|
||||
// Never switch autocommit back on when rollback failed: that could commit Explain rows.
|
||||
connection.setAutoCommit(true);
|
||||
} catch (SQLException error) {
|
||||
cleanupFailure = error;
|
||||
try {
|
||||
connection.close();
|
||||
} catch (SQLException closeError) {
|
||||
error.addSuppressed(closeError);
|
||||
}
|
||||
}
|
||||
if (cleanupFailure != null) {
|
||||
if (failure != null) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
} else {
|
||||
throw cleanupFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String findExplainSchema(Connection connection, String requester, int timeoutSecs, JdbcExecutor executor, JdbcExecutor.NativeOperation operation)
|
||||
throws SQLException {
|
||||
String sql = "SELECT TABSCHEMA FROM SYSCAT.TABLES WHERE TABNAME = 'EXPLAIN_INSTANCE' "
|
||||
+ "AND TABSCHEMA IN (?, 'SYSTOOLS') ORDER BY CASE WHEN TABSCHEMA = ? THEN 0 ELSE 1 END";
|
||||
try (var tracked = prepared(connection, sql, timeoutSecs, executor, operation)) {
|
||||
PreparedStatement statement = tracked.statement();
|
||||
statement.setString(1, requester);
|
||||
statement.setString(2, requester);
|
||||
operation.checkCancelled();
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
// SYSCAT.TABLES includes aliases, so qualify reads through the user's aliases.
|
||||
if (rows.next()) {
|
||||
return rows.getString(1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Let EXPLAIN produce native SQL0219N when neither location exists, preserving its
|
||||
// SQLCODE/SQLSTATE. The diagnostic below adds initialization guidance without fabricating a code.
|
||||
return requester;
|
||||
}
|
||||
|
||||
private static Map<String, String> readStatement(Connection connection, String schema, String requester,
|
||||
String tag, int timeoutSecs, JdbcExecutor executor, JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
String sql = "SELECT " + String.join(", ", REQUEST_KEYS) + " FROM "
|
||||
+ qualified(schema, "EXPLAIN_STATEMENT") + " WHERE EXPLAIN_REQUESTER = ? AND QUERYTAG = ? AND EXPLAIN_LEVEL = 'P'";
|
||||
try (var tracked = prepared(connection, sql, timeoutSecs, executor, operation)) {
|
||||
PreparedStatement statement = tracked.statement();
|
||||
statement.setString(1, requester);
|
||||
statement.setString(2, tag);
|
||||
operation.checkCancelled();
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
if (!rows.next()) {
|
||||
throw new SQLException("DB2 returned no tagged plan-selection statement");
|
||||
}
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
for (int index = 0; index < REQUEST_KEYS.length; index++) {
|
||||
result.put(REQUEST_KEYS[index], rows.getString(index + 1));
|
||||
}
|
||||
if (rows.next()) {
|
||||
throw new SQLException("DB2 returned multiple statements for one execution plan request");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Map<String, String>> readRows(Connection connection, String schema, String table,
|
||||
String columns, String order, String requester, String tag, int timeoutSecs, String[] fields, JdbcExecutor executor, JdbcExecutor.NativeOperation operation)
|
||||
throws SQLException {
|
||||
List<String> join = new ArrayList<>();
|
||||
for (String key : REQUEST_KEYS) {
|
||||
join.add("r." + key + " = s." + key);
|
||||
}
|
||||
String sql = "SELECT " + columns + " FROM " + qualified(schema, table) + " r JOIN "
|
||||
+ qualified(schema, "EXPLAIN_STATEMENT") + " s ON " + String.join(" AND ", join)
|
||||
+ " WHERE s.EXPLAIN_REQUESTER = ? AND s.QUERYTAG = ? AND s.EXPLAIN_LEVEL = 'P' ORDER BY " + order;
|
||||
try (var tracked = prepared(connection, sql, timeoutSecs, executor, operation)) {
|
||||
PreparedStatement statement = tracked.statement();
|
||||
statement.setMaxRows(MAX_ROWS + 1);
|
||||
statement.setString(1, requester);
|
||||
statement.setString(2, tag);
|
||||
operation.checkCancelled();
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
List<Map<String, String>> result = new ArrayList<>();
|
||||
while (rows.next()) {
|
||||
if (result.size() >= MAX_ROWS) {
|
||||
throw new SQLException("DB2 execution plan exceeds the limit of " + MAX_ROWS + " " + table + " rows");
|
||||
}
|
||||
Map<String, String> row = new LinkedHashMap<>();
|
||||
for (int index = 0; index < fields.length; index++) {
|
||||
String value = rows.getString(index + 1);
|
||||
if (value != null) {
|
||||
// Preserve predicate text; CHAR fields and numeric strings have no useful padding.
|
||||
row.put(fields[index], "text".equals(fields[index]) ? value : value.stripTrailing());
|
||||
}
|
||||
}
|
||||
result.add(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SQLException diagnostic(SQLException error) {
|
||||
String hint = "";
|
||||
for (SQLException current = error; current != null; current = current.getNextException()) {
|
||||
if (current.getErrorCode() == -219) {
|
||||
hint = " DB2 Explain tables are missing or incompatible. Ask an administrator to create them for the connecting user with CALL SYSPROC.SYSINSTALLOBJECTS('EXPLAIN', 'C', '', CURRENT USER), or migrate existing Explain tables and grant INSERT/SELECT privileges.";
|
||||
break;
|
||||
}
|
||||
if (current.getErrorCode() == -551 || current.getErrorCode() == -552) {
|
||||
hint = " Ask an administrator to grant Explain/object privileges and INSERT/SELECT access to the Explain tables.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hint.isEmpty()) {
|
||||
return error;
|
||||
}
|
||||
SQLException result = new SQLException(error.getMessage() + hint, error.getSQLState(), error.getErrorCode(), error);
|
||||
result.setNextException(error);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JdbcExecutor.TrackedStatement<Statement> statement(Connection connection, int timeoutSecs,
|
||||
JdbcExecutor executor, JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
if (operation != null) operation.checkCancelled();
|
||||
return configure(executor.trackStatement(connection.createStatement()), timeoutSecs, operation);
|
||||
}
|
||||
|
||||
private static JdbcExecutor.TrackedStatement<PreparedStatement> prepared(Connection connection, String sql,
|
||||
int timeoutSecs, JdbcExecutor executor, JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
operation.checkCancelled();
|
||||
return configure(executor.trackStatement(connection.prepareStatement(sql)), timeoutSecs, operation);
|
||||
}
|
||||
|
||||
private static <S extends Statement> JdbcExecutor.TrackedStatement<S> configure(
|
||||
JdbcExecutor.TrackedStatement<S> tracked, int timeoutSecs, JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
try {
|
||||
if (operation != null) operation.checkCancelled();
|
||||
timeout(tracked.statement(), timeoutSecs);
|
||||
return tracked;
|
||||
} catch (SQLException | RuntimeException error) {
|
||||
try {
|
||||
tracked.close();
|
||||
} catch (SQLException closeError) {
|
||||
error.addSuppressed(closeError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private static void timeout(Statement statement, int timeoutSecs) throws SQLException {
|
||||
if (timeoutSecs >= 0) {
|
||||
statement.setQueryTimeout(timeoutSecs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void execute(Connection connection, String sql, int timeoutSecs, JdbcExecutor executor,
|
||||
JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
try (var tracked = statement(connection, timeoutSecs, executor, operation)) {
|
||||
if (operation != null) operation.checkCancelled();
|
||||
tracked.statement().execute(sql);
|
||||
}
|
||||
}
|
||||
private static String qualified(String schema, String table) {
|
||||
return quote(schema) + "." + quote(table);
|
||||
}
|
||||
|
||||
private static String quote(String value) {
|
||||
return JdbcIdentifiers.INSTANCE.doubleQuote(value);
|
||||
}
|
||||
|
||||
/** Rust checks read-only SQL too; this protects direct Agent/RPC callers and trailing terminators. */
|
||||
static String singleQuery(String sql) {
|
||||
if (sql == null || sql.isBlank()) {
|
||||
throw new IllegalArgumentException("DB2 execution plan SQL must not be empty");
|
||||
}
|
||||
StringBuilder significant = new StringBuilder();
|
||||
int terminator = -1;
|
||||
for (int index = 0; index < sql.length(); index++) {
|
||||
char ch = sql.charAt(index);
|
||||
if (Character.isWhitespace(ch)) {
|
||||
significant.append(' ');
|
||||
} else if (ch == '-' && index + 1 < sql.length() && sql.charAt(index + 1) == '-') {
|
||||
index += 2;
|
||||
while (index < sql.length() && sql.charAt(index) != '\n' && sql.charAt(index) != '\r') {
|
||||
index++;
|
||||
}
|
||||
significant.append(' ');
|
||||
} else if (ch == '/' && index + 1 < sql.length() && sql.charAt(index + 1) == '*') {
|
||||
int depth = 1;
|
||||
index += 2;
|
||||
while (index < sql.length() && depth > 0) {
|
||||
if (index + 1 < sql.length() && sql.charAt(index) == '/' && sql.charAt(index + 1) == '*') {
|
||||
depth++;
|
||||
index += 2;
|
||||
} else if (index + 1 < sql.length() && sql.charAt(index) == '*' && sql.charAt(index + 1) == '/') {
|
||||
depth--;
|
||||
index += 2;
|
||||
} else {
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (depth != 0) {
|
||||
throw new IllegalArgumentException("Unterminated DB2 SQL comment");
|
||||
}
|
||||
index--;
|
||||
significant.append(' ');
|
||||
} else {
|
||||
if (terminator >= 0) {
|
||||
throw new IllegalArgumentException("DB2 execution plans require a single SQL statement");
|
||||
}
|
||||
if (ch == ';') {
|
||||
terminator = index;
|
||||
} else if (ch == '\'' || ch == '"') {
|
||||
char quote = ch;
|
||||
significant.append(" quoted ");
|
||||
boolean closed = false;
|
||||
while (++index < sql.length()) {
|
||||
if (sql.charAt(index) == quote) {
|
||||
if (index + 1 < sql.length() && sql.charAt(index + 1) == quote) {
|
||||
index++;
|
||||
} else {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!closed) {
|
||||
throw new IllegalArgumentException("Unterminated DB2 SQL quoted value");
|
||||
}
|
||||
} else {
|
||||
significant.append(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
String code = significant.toString().stripLeading();
|
||||
if (!code.matches("(?is)^(SELECT|WITH|VALUES)\\b.*")) {
|
||||
throw new IllegalArgumentException("DB2 estimated execution plans accept SELECT, WITH, or VALUES queries");
|
||||
}
|
||||
// End a possible trailing line comment before SQL assembled around the source can continue.
|
||||
return (terminator >= 0 ? sql.substring(0, terminator) : sql).trim() + "\n";
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package com.dbx.agent.db2;
|
||||
|
||||
import com.dbx.agent.JdbcIdentifiers;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Opt-in real DB2 LUW checks. Credentials stay in DBX_DB2_JDBC_URL/USER/PASSWORD.
|
||||
* The sequence check needs CREATEIN/DROPIN on the connecting user's schema.
|
||||
*/
|
||||
@EnabledIfEnvironmentVariable(named = "DBX_DB2_JDBC_URL", matches = ".+")
|
||||
class Db2ExplainPlanIntegrationTest {
|
||||
@Test
|
||||
void realPlansRoundTripAndRollbackWithoutChangingSessionSchema() throws Exception {
|
||||
try (Connection connection = connect()) {
|
||||
String originalSchema = scalar(connection, "VALUES CURRENT SCHEMA").trim();
|
||||
String explainSchema = explainSchema(connection);
|
||||
long before = requestCount(connection, explainSchema);
|
||||
Db2Agent agent = agent(connection);
|
||||
String businessSchema = System.getenv("DBX_DB2_SCHEMA");
|
||||
List<String> queries = List.of(
|
||||
"SELECT IBMREQD FROM SYSIBM.SYSDUMMY1;",
|
||||
"WITH x AS (SELECT IBMREQD FROM SYSIBM.SYSDUMMY1) SELECT * FROM x",
|
||||
"SELECT a.IBMREQD FROM SYSIBM.SYSDUMMY1 a JOIN SYSIBM.SYSDUMMY1 b ON a.IBMREQD = b.IBMREQD WHERE a.IBMREQD = 'Y'"
|
||||
);
|
||||
int index = 0;
|
||||
for (String sql : queries) {
|
||||
String raw = agent.getExplainInfo(sql, null, businessSchema, 30, "explain");
|
||||
JsonObject plan = JsonParser.parseString(raw).getAsJsonObject();
|
||||
assertFalse(plan.getAsJsonArray("operators").isEmpty());
|
||||
assertFalse(plan.getAsJsonArray("streams").isEmpty());
|
||||
assertEquals(originalSchema, scalar(connection, "VALUES CURRENT SCHEMA").trim());
|
||||
assertTrue(connection.getAutoCommit());
|
||||
assertEquals(before, requestCount(connection, explainSchema), "Explain rows must be rolled back");
|
||||
String directory = System.getenv("DBX_DB2_PROOF_DIR");
|
||||
if (directory != null && !directory.isBlank()) {
|
||||
Path path = Path.of(directory);
|
||||
Files.createDirectories(path);
|
||||
Files.writeString(path.resolve("jdbc-plan-" + index + ".json"), raw);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void explainDoesNotConsumeSequenceValueFromTheBusinessStatement() throws Exception {
|
||||
try (Connection connection = connect()) {
|
||||
String schema = scalar(connection, "VALUES SESSION_USER").trim();
|
||||
String name = "DBXEP_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
|
||||
String sequence = JdbcIdentifiers.INSTANCE.doubleQuote(schema) + "." + JdbcIdentifiers.INSTANCE.doubleQuote(name);
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("CREATE SEQUENCE " + sequence + " AS BIGINT START WITH 1 INCREMENT BY 1 NO CACHE");
|
||||
try {
|
||||
assertEquals("1", scalar(connection, "VALUES NEXT VALUE FOR " + sequence).trim());
|
||||
agent(connection).getExplainInfo("SELECT NEXT VALUE FOR " + sequence + " FROM SYSIBM.SYSDUMMY1", null, null, 30, null);
|
||||
assertEquals("2", scalar(connection, "VALUES NEXT VALUE FOR " + sequence).trim(),
|
||||
"Generating the estimated plan must not execute the sequence expression");
|
||||
} finally {
|
||||
statement.execute("DROP SEQUENCE " + sequence);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentConnectionsReturnDistinctRequestsAndLeaveNoPersistedExplainRows() throws Exception {
|
||||
String explainSchema;
|
||||
long before;
|
||||
try (Connection connection = connect()) {
|
||||
explainSchema = explainSchema(connection);
|
||||
before = requestCount(connection, explainSchema);
|
||||
}
|
||||
List<Callable<String>> tasks = new ArrayList<>();
|
||||
for (int index = 0; index < 16; index++) {
|
||||
int value = index;
|
||||
tasks.add(() -> {
|
||||
try (Connection connection = connect()) {
|
||||
JsonObject plan = JsonParser.parseString(agent(connection).getExplainInfo(
|
||||
"SELECT " + value + " AS MARKER FROM SYSIBM.SYSDUMMY1", null,
|
||||
System.getenv("DBX_DB2_SCHEMA"), 30, null
|
||||
)).getAsJsonObject();
|
||||
assertFalse(plan.getAsJsonArray("operators").isEmpty());
|
||||
return plan.get("requestTag").getAsString();
|
||||
}
|
||||
});
|
||||
}
|
||||
HashSet<String> tags = new HashSet<>();
|
||||
try (var executor = Executors.newFixedThreadPool(4)) {
|
||||
for (var future : executor.invokeAll(tasks)) tags.add(future.get());
|
||||
}
|
||||
assertEquals(tasks.size(), tags.size());
|
||||
try (Connection connection = connect()) {
|
||||
assertEquals(before, requestCount(connection, explainSchema));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingTransactionRemainsOpenWhenExplainIsRejected() throws Exception {
|
||||
try (Connection connection = connect()) {
|
||||
connection.setAutoCommit(false);
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> agent(connection).getExplainInfo("SELECT * FROM SYSIBM.SYSDUMMY1", null, null, 30, null));
|
||||
assertFalse(connection.getAutoCommit());
|
||||
assertEquals("Y", scalar(connection, "SELECT IBMREQD FROM SYSIBM.SYSDUMMY1").trim());
|
||||
connection.rollback();
|
||||
}
|
||||
}
|
||||
|
||||
private static Db2Agent agent(Connection connection) {
|
||||
Db2Agent agent = new Db2Agent();
|
||||
TestSupport.setPrivateConnection(agent, connection);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private static Connection connect() throws Exception {
|
||||
return DriverManager.getConnection(System.getenv("DBX_DB2_JDBC_URL"),
|
||||
System.getenv("DBX_DB2_USER"), System.getenv("DBX_DB2_PASSWORD"));
|
||||
}
|
||||
|
||||
private static String scalar(Connection connection, String sql) throws Exception {
|
||||
try (Statement statement = connection.createStatement(); ResultSet rows = statement.executeQuery(sql)) {
|
||||
assertTrue(rows.next());
|
||||
return rows.getString(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static String explainSchema(Connection connection) throws Exception {
|
||||
String user = scalar(connection, "VALUES SESSION_USER").trim();
|
||||
try (var statement = connection.prepareStatement("SELECT TABSCHEMA FROM SYSCAT.TABLES "
|
||||
+ "WHERE TABNAME = 'EXPLAIN_INSTANCE' AND TABSCHEMA IN (?, 'SYSTOOLS') "
|
||||
+ "ORDER BY CASE WHEN TABSCHEMA = ? THEN 0 ELSE 1 END")) {
|
||||
statement.setString(1, user);
|
||||
statement.setString(2, user);
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
assertTrue(rows.next(), "The integration database must have Explain tables installed");
|
||||
return rows.getString(1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long requestCount(Connection connection, String schema) throws Exception {
|
||||
try (var statement = connection.prepareStatement("SELECT COUNT(*) FROM "
|
||||
+ JdbcIdentifiers.INSTANCE.doubleQuote(schema) + ".EXPLAIN_INSTANCE WHERE EXPLAIN_REQUESTER = SESSION_USER");
|
||||
ResultSet rows = statement.executeQuery()) {
|
||||
assertTrue(rows.next());
|
||||
return rows.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package com.dbx.agent.db2;
|
||||
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class Db2ExplainPlanTest {
|
||||
@Test
|
||||
void agentReturnsTaggedPlanWithoutExecutingBusinessQuery() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
JsonObject plan = JsonParser.parseString(jdbc.agent().getExplainInfo(
|
||||
"SELECT * FROM ORDERS WHERE NOTE = ';'; -- trailing comment", "DBX", "APP", 9, "explain"
|
||||
)).getAsJsonObject();
|
||||
assertEquals(1, plan.get("version").getAsInt());
|
||||
assertEquals("db2", plan.get("databaseType").getAsString());
|
||||
assertTrue(plan.get("requestTag").getAsString().matches("[0-9a-f]{20}"));
|
||||
assertEquals("12345678901234567890.123", plan.getAsJsonArray("operators").get(0)
|
||||
.getAsJsonObject().get("totalCost").getAsString());
|
||||
JsonObject stream = plan.getAsJsonArray("streams").get(0).getAsJsonObject();
|
||||
assertEquals("D", stream.get("sourceType").getAsString());
|
||||
assertEquals("-1", stream.get("sourceId").getAsString());
|
||||
assertEquals("ORDERS", stream.get("objectName").getAsString());
|
||||
assertEquals("(Q1.NOTE = ';')", plan.getAsJsonArray("predicates").get(0)
|
||||
.getAsJsonObject().get("text").getAsString());
|
||||
assertEquals(1, jdbc.executed.stream().filter(sql -> sql.startsWith("EXPLAIN PLAN")).count());
|
||||
assertTrue(jdbc.executed.stream().noneMatch(sql -> sql.startsWith("SELECT * FROM ORDERS")));
|
||||
assertTrue(jdbc.executed.stream().anyMatch(sql -> sql.endsWith("FOR SELECT * FROM ORDERS WHERE NOTE = ';'\n")));
|
||||
assertEquals(List.of(false, true), jdbc.autocommitChanges);
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.commits);
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
assertEquals(0, jdbc.openStatements);
|
||||
assertEquals(0, jdbc.openResultSets);
|
||||
assertTrue(jdbc.timeouts.stream().allMatch(timeout -> timeout == 9));
|
||||
for (String query : jdbc.prepared) {
|
||||
if (query.contains(" r JOIN ")) {
|
||||
for (String key : List.of("EXPLAIN_REQUESTER", "EXPLAIN_TIME", "SOURCE_NAME", "SOURCE_SCHEMA",
|
||||
"SOURCE_VERSION", "EXPLAIN_LEVEL", "STMTNO", "SECTNO")) {
|
||||
assertTrue(query.contains("r." + key + " = s." + key), key);
|
||||
}
|
||||
assertTrue(query.contains("s.EXPLAIN_REQUESTER = ? AND s.QUERYTAG = ?"));
|
||||
assertTrue(query.contains("s.EXPLAIN_LEVEL = 'P'"));
|
||||
}
|
||||
}
|
||||
assertTrue(jdbc.boundTags.stream().allMatch(tag -> tag.equals(plan.get("requestTag").getAsString())));
|
||||
assertTrue(jdbc.prepared.stream().filter(sql -> sql.contains("EXPLAIN_") && !sql.contains("SYSCAT"))
|
||||
.allMatch(sql -> sql.contains("\"AUTH_USER\".")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void systoolsFallbackIsIndependentOfBusinessSchemaAndIncludesAliases() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.explainSchema = "SYSTOOLS";
|
||||
jdbc.agent().getExplainInfo("WITH x AS (SELECT 1 FROM SYSIBM.SYSDUMMY1) SELECT * FROM x", null, "Mixed \"Schema", -1, null);
|
||||
assertTrue(jdbc.executed.contains("SET SCHEMA \"Mixed \"\"Schema\""));
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
assertTrue(jdbc.timeouts.isEmpty());
|
||||
assertTrue(jdbc.prepared.stream().filter(sql -> sql.contains(" r JOIN ")).allMatch(sql -> sql.contains("\"SYSTOOLS\".")));
|
||||
assertFalse(jdbc.prepared.get(0).contains("TYPE"));
|
||||
assertEquals("AUTH_USER", jdbc.catalogRequester);
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingTransactionIsRejectedWithoutRollbackOrCommit() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.autocommit = false;
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1 FROM SYSIBM.SYSDUMMY1", null, "APP", 0, "explain"));
|
||||
assertTrue(error.getMessage().contains("isolated connection"));
|
||||
assertEquals(0, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.commits);
|
||||
assertTrue(jdbc.executed.isEmpty());
|
||||
assertTrue(jdbc.autocommitChanges.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsSupportedQueryPrefixes() {
|
||||
for (String sql : Arrays.asList("SELECT * FROM \"APP\".\"ORDERS\"",
|
||||
"WITH q AS (SELECT 1 FROM SYSIBM.SYSDUMMY1) SELECT * FROM q", "VALUES 1",
|
||||
"/* plan */ select * FROM APP.ORDERS",
|
||||
"-- plan\nwith q AS (SELECT 1 FROM SYSIBM.SYSDUMMY1) SELECT * FROM q",
|
||||
"/* plan */ values (1)",
|
||||
"/* outer /* inner */ trailing */ SELECT 1 FROM SYSIBM.SYSDUMMY1",
|
||||
"/* outer /* inner */ DELETE */ VALUES ('a;''b')",
|
||||
"WITH q AS (/* outer /* inner */ UPDATE */ SELECT 1 FROM SYSIBM.SYSDUMMY1) SELECT * FROM q")) {
|
||||
assertEquals(sql + "\n", Db2ExplainPlan.singleQuery(sql));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidInputsAndModesBeforeChangingConnection() {
|
||||
for (String sql : Arrays.asList(null, "", "DELETE FROM APP.ORDERS", "SELECT 1; DROP TABLE APP.ORDERS",
|
||||
"TABLE APP.ORDERS", "table \"APP\".\"ORDERS\";", "/* plan */ TABLE APP.ORDERS",
|
||||
"SELECT 1;;", "SELECT 'unterminated", "SELECT 1 /* unterminated", "SELECT 1 /* outer /* inner */")) {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
assertThrows(IllegalArgumentException.class, () -> jdbc.agent().getExplainInfo(sql, null, null, 5, "explain"));
|
||||
assertTrue(jdbc.autocommitChanges.isEmpty());
|
||||
assertTrue(jdbc.executed.isEmpty());
|
||||
}
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, null, 5, "autotrace"));
|
||||
assertTrue(jdbc.autocommitChanges.isEmpty());
|
||||
assertEquals("/* leading /* nested */ comment */ VALUES ('a;''b')\n",
|
||||
Db2ExplainPlan.singleQuery("/* leading /* nested */ comment */ VALUES ('a;''b'); -- end"));
|
||||
assertEquals("SELECT \"semi;column\" FROM T -- comment\n",
|
||||
Db2ExplainPlan.singleQuery("SELECT \"semi;column\" FROM T -- comment"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesSqlCodeStateAndChainWhileAddingPermissionHint() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
SQLException nativeError = new SQLException("SQL0551N not authorized", "42501", -551);
|
||||
nativeError.setNextException(new SQLException("Additional diagnostic", "42501", -551));
|
||||
jdbc.explainError = nativeError;
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT * FROM ORDERS", null, "APP", 5, "explain"));
|
||||
SQLException diagnostic = sqlCause(error);
|
||||
assertEquals(-551, diagnostic.getErrorCode());
|
||||
assertEquals("42501", diagnostic.getSQLState());
|
||||
assertSame(nativeError, diagnostic.getCause());
|
||||
assertSame(nativeError, diagnostic.getNextException());
|
||||
assertTrue(error.getMessage().contains("INSERT/SELECT"));
|
||||
assertEquals(List.of(false, true), jdbc.autocommitChanges);
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.openStatements);
|
||||
assertEquals(0, jdbc.openResultSets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTablesGiveInitializationInstructionAndReleaseTransaction() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.explainSchema = null;
|
||||
jdbc.explainError = new SQLException("SQL0219N Explain table does not exist", "42704", -219);
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, "APP", 0, "explain"));
|
||||
assertTrue(error.getMessage().contains("SYSINSTALLOBJECTS"));
|
||||
assertTrue(error.getMessage().contains("CURRENT USER"));
|
||||
assertEquals(-219, sqlCause(error).getErrorCode());
|
||||
assertEquals("42704", sqlCause(error).getSQLState());
|
||||
assertTrue(jdbc.executed.stream().anyMatch(sql -> sql.startsWith("EXPLAIN")));
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertTrue(jdbc.autocommit);
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollbackFailureClosesConnectionWithoutEnablingAutocommit() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.rollbackError = new SQLException("rollback failed", "08006", -30081);
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, "APP", 1, "explain"));
|
||||
assertEquals("08006", sqlCause(error).getSQLState());
|
||||
assertTrue(jdbc.closed);
|
||||
assertEquals(List.of(false), jdbc.autocommitChanges);
|
||||
assertEquals(0, jdbc.commits);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupFailureIsSuppressedUnderOriginalDatabaseError() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.explainError = new SQLException("SQL0104N unexpected token", "42601", -104);
|
||||
jdbc.rollbackError = new SQLException("rollback failed", "08006", -30081);
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, null, 1, "explain"));
|
||||
assertSame(jdbc.explainError, sqlCause(error));
|
||||
assertEquals(1, jdbc.explainError.getSuppressed().length);
|
||||
assertSame(jdbc.rollbackError, jdbc.explainError.getSuppressed()[0]);
|
||||
assertTrue(jdbc.closed);
|
||||
assertEquals(List.of(false), jdbc.autocommitChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinctRequestsUseDistinctTagsAndNeverCommit() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
Db2Agent agent = jdbc.agent();
|
||||
String first = JsonParser.parseString(agent.getExplainInfo("SELECT 1", null, null, 0, null))
|
||||
.getAsJsonObject().get("requestTag").getAsString();
|
||||
String second = JsonParser.parseString(agent.getExplainInfo("SELECT 2", null, null, 0, null))
|
||||
.getAsJsonObject().get("requestTag").getAsString();
|
||||
assertNotEquals(first, second);
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.commits);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingAmbiguousOrEmptyPlanInsteadOfSilentlyReturningAnotherRequest() {
|
||||
for (int count : List.of(0, 2)) {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.statementCount = count;
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, null, 0, null));
|
||||
assertEquals(1, jdbc.rollbacks);
|
||||
assertTrue(jdbc.autocommit);
|
||||
assertEquals(0, jdbc.openResultSets);
|
||||
}
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.operatorCount = 0;
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, null, 0, null));
|
||||
assertTrue(error.getMessage().contains("no operators"));
|
||||
assertEquals(1, jdbc.rollbacks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationReachesBlockedNativeExplainAndCleansUpItsTransaction() throws Exception {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.explainStarted = new CountDownLatch(1);
|
||||
jdbc.cancelSignal = new CountDownLatch(1);
|
||||
JdbcExecutor executor = JdbcExecutor.current();
|
||||
Db2Agent agent = jdbc.agent();
|
||||
try (var worker = Executors.newSingleThreadExecutor()) {
|
||||
var pending = worker.submit(() -> agent.getExplainInfo("SELECT * FROM ORDERS", null, "APP", 0, "explain"));
|
||||
try {
|
||||
assertTrue(jdbc.explainStarted.await(3, TimeUnit.SECONDS), "Native Explain entered its blocking call");
|
||||
assertTrue(executor.hasActiveStatements(), "Native Statement registered with the session executor");
|
||||
executor.cancelActiveStatements();
|
||||
ExecutionException error = assertThrows(ExecutionException.class, () -> pending.get(2, TimeUnit.SECONDS));
|
||||
assertEquals("57014", sqlCause(error).getSQLState());
|
||||
assertEquals(1, jdbc.cancelCalls);
|
||||
assertFalse(executor.hasActiveStatements());
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.commits);
|
||||
assertTrue(jdbc.autocommit);
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
assertEquals(0, jdbc.openStatements);
|
||||
assertEquals(0, jdbc.openResultSets);
|
||||
} finally {
|
||||
jdbc.cancelSignal.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void cancellationInNativeStatementGapPreventsExplainAndStillRestoresSchema() throws Exception {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.gapStarted = new CountDownLatch(1);
|
||||
jdbc.gapResume = new CountDownLatch(1);
|
||||
JdbcExecutor executor = JdbcExecutor.current();
|
||||
Db2Agent agent = jdbc.agent();
|
||||
try (var worker = Executors.newSingleThreadExecutor()) {
|
||||
var pending = worker.submit(() -> agent.getExplainInfo("SELECT * FROM ORDERS", null, "APP", 0, "explain"));
|
||||
try {
|
||||
assertTrue(jdbc.gapStarted.await(3, TimeUnit.SECONDS));
|
||||
assertFalse(executor.hasActiveStatements(), "Previous schema Statement already closed");
|
||||
executor.cancelActiveStatements();
|
||||
jdbc.gapResume.countDown();
|
||||
ExecutionException error = assertThrows(ExecutionException.class, () -> pending.get(2, TimeUnit.SECONDS));
|
||||
assertInstanceOf(CancellationException.class, error.getCause());
|
||||
assertTrue(jdbc.executed.stream().noneMatch(sql -> sql.startsWith("EXPLAIN")));
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertTrue(jdbc.autocommit);
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
assertEquals(0, jdbc.openStatements);
|
||||
assertFalse(executor.hasActiveStatements());
|
||||
} finally {
|
||||
jdbc.gapResume.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
private static SQLException sqlCause(Throwable error) {
|
||||
while (!(error instanceof SQLException) && error.getCause() != null) error = error.getCause();
|
||||
return assertInstanceOf(SQLException.class, error);
|
||||
}
|
||||
|
||||
/** JDBC is the mocked boundary; production Agent and serialization run unchanged. */
|
||||
private static final class JdbcBoundary {
|
||||
boolean autocommit = true;
|
||||
boolean closed;
|
||||
String schema = "SESSION_DEFAULT";
|
||||
String explainSchema = "AUTH_USER";
|
||||
String catalogRequester;
|
||||
int rollbacks;
|
||||
int commits;
|
||||
int openStatements;
|
||||
int openResultSets;
|
||||
int statementCount = 1;
|
||||
int operatorCount = 1;
|
||||
SQLException explainError;
|
||||
SQLException rollbackError;
|
||||
CountDownLatch explainStarted;
|
||||
CountDownLatch cancelSignal;
|
||||
int cancelCalls;
|
||||
CountDownLatch gapStarted;
|
||||
CountDownLatch gapResume;
|
||||
int nativeStatementCreations;
|
||||
final List<Boolean> autocommitChanges = new ArrayList<>();
|
||||
final List<String> executed = new ArrayList<>();
|
||||
final List<String> prepared = new ArrayList<>();
|
||||
final List<Integer> timeouts = new ArrayList<>();
|
||||
final List<String> boundTags = new ArrayList<>();
|
||||
|
||||
Db2Agent agent() {
|
||||
Db2Agent agent = new Db2Agent();
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (object, method, args) -> {
|
||||
return switch (method.getName()) {
|
||||
case "isClosed" -> closed;
|
||||
case "getAutoCommit" -> autocommit;
|
||||
case "setAutoCommit" -> {
|
||||
autocommit = (Boolean) args[0];
|
||||
autocommitChanges.add(autocommit);
|
||||
yield null;
|
||||
}
|
||||
case "rollback" -> {
|
||||
rollbacks++;
|
||||
if (rollbackError != null) throw rollbackError;
|
||||
yield null;
|
||||
}
|
||||
case "commit" -> { commits++; yield null; }
|
||||
case "close" -> { closed = true; yield null; }
|
||||
case "createStatement" -> {
|
||||
nativeStatementCreations++;
|
||||
if (nativeStatementCreations == 3 && gapStarted != null) {
|
||||
gapStarted.countDown();
|
||||
if (!gapResume.await(5, TimeUnit.SECONDS)) throw new SQLException("Native gap was not released");
|
||||
}
|
||||
yield statement(null);
|
||||
}
|
||||
case "prepareStatement" -> {
|
||||
String query = (String) args[0];
|
||||
prepared.add(query);
|
||||
yield statement(query);
|
||||
}
|
||||
default -> defaultValue(method.getReturnType());
|
||||
};
|
||||
}));
|
||||
return agent;
|
||||
}
|
||||
|
||||
Statement statement(String query) {
|
||||
openStatements++;
|
||||
Map<Integer, String> params = new LinkedHashMap<>();
|
||||
return proxy(PreparedStatement.class, (object, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "setQueryTimeout": timeouts.add((Integer) args[0]); return null;
|
||||
case "setMaxRows": return null;
|
||||
case "cancel": cancelCalls++; if (cancelSignal != null) cancelSignal.countDown(); return null;
|
||||
case "setString": params.put((Integer) args[0], (String) args[1]); return null;
|
||||
case "close": openStatements--; return null;
|
||||
case "execute":
|
||||
String sql = (String) args[0];
|
||||
executed.add(sql);
|
||||
if (sql.startsWith("EXPLAIN") && explainStarted != null) {
|
||||
explainStarted.countDown();
|
||||
if (!cancelSignal.await(5, TimeUnit.SECONDS)) throw new SQLException("Native Statement was not cancelled", "HYT00");
|
||||
throw new SQLException("Native Statement cancelled", "57014", -952);
|
||||
}
|
||||
if (sql.startsWith("EXPLAIN") && explainError != null) throw explainError;
|
||||
if (sql.startsWith("SET SCHEMA")) {
|
||||
schema = sql.substring("SET SCHEMA ".length() + 1, sql.length() - 1).replace("\"\"", "\"");
|
||||
}
|
||||
return false;
|
||||
case "executeQuery":
|
||||
if (query == null) {
|
||||
executed.add((String) args[0]);
|
||||
return rows(new String[][]{{"AUTH_USER", schema}});
|
||||
}
|
||||
if (query.contains("SYSCAT.TABLES")) {
|
||||
catalogRequester = params.get(1);
|
||||
return rows(explainSchema == null ? new String[0][] : new String[][]{{explainSchema}});
|
||||
}
|
||||
boundTags.add(params.get(2));
|
||||
if (!query.contains(" r JOIN ")) {
|
||||
String[][] values = new String[statementCount][];
|
||||
Arrays.fill(values, new String[]{"AUTH_USER", "2026-10-07 01:02:03.123456", "SQLC2P31", "NULLID", "", "P", "1", "201"});
|
||||
return rows(values);
|
||||
}
|
||||
if (query.contains("\"EXPLAIN_OPERATOR\"")) {
|
||||
return rows(operatorCount == 0 ? new String[0][] :
|
||||
new String[][]{{"1", "TBSCAN", "12345678901234567890.123", "1.25", "999", "2.125"}});
|
||||
}
|
||||
if (query.contains("\"EXPLAIN_STREAM\"")) {
|
||||
return rows(new String[][]{{"0", "D", "-1", "O", "1", "200.5", "APP", "ORDERS"}});
|
||||
}
|
||||
if (query.contains("\"EXPLAIN_PREDICATE\"")) {
|
||||
return rows(new String[][]{{"1", "(Q1.NOTE = ';')", "SARG "}});
|
||||
}
|
||||
throw new AssertionError(query);
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ResultSet rows(String[][] values) {
|
||||
openResultSets++;
|
||||
int[] cursor = {-1};
|
||||
return proxy(ResultSet.class, (object, method, args) -> {
|
||||
return switch (method.getName()) {
|
||||
case "next" -> ++cursor[0] < values.length;
|
||||
case "getString" -> values[cursor[0]][(Integer) args[0] - 1];
|
||||
case "close" -> { openResultSets--; yield null; }
|
||||
default -> defaultValue(method.getReturnType());
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == boolean.class) return false;
|
||||
if (type == int.class) return 0;
|
||||
if (type == long.class) return 0L;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -1408,6 +1408,7 @@ dependencies = [
|
||||
"dbx-sql-core",
|
||||
"dbx-types",
|
||||
"deadpool-postgres",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"log",
|
||||
"openssl",
|
||||
|
||||
@@ -1,16 +1,84 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConfiguredJdbcAgent;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.JdbcAgentProfile;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import java.nio.charset.Charset;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
private Charset dataCharset;
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
String url = super.buildJdbcUrl(params);
|
||||
LegacyTextDecoder.charsetFromUrl(url); // Fail invalid configuration before opening a connection.
|
||||
return LegacyTextDecoder.jdbcUrl(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
super.afterConnect(params, connection);
|
||||
dataCharset = LegacyTextDecoder.charsetFromUrl(super.buildJdbcUrl(params));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object resultValue(ResultSet resultSet, int index, int sqlType) {
|
||||
Object value = super.resultValue(resultSet, index, sqlType);
|
||||
return switch (sqlType) {
|
||||
case Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR,
|
||||
Types.NCHAR, Types.NVARCHAR, Types.LONGNVARCHAR, Types.CLOB, Types.NCLOB ->
|
||||
value instanceof String text ? decodeLegacyText(text) : value;
|
||||
default -> value;
|
||||
};
|
||||
}
|
||||
|
||||
private String decodeLegacyText(String value) {
|
||||
return LegacyTextDecoder.decode(value, dataCharset);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
List<ColumnInfo> columns = super.getColumns(schema, table);
|
||||
for (ColumnInfo column : columns) column.setComment(decodeLegacyText(column.getComment()));
|
||||
return columns;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema) {
|
||||
return listTables(schema, MetadataListConstraints.NONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema, MetadataListConstraints constraints) {
|
||||
List<TableInfo> tables = super.listTables(schema, constraints);
|
||||
for (TableInfo table : tables) table.setComment(decodeLegacyText(table.getComment()));
|
||||
return tables;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ObjectInfo> listObjects(String schema) {
|
||||
return listObjects(schema, MetadataListConstraints.NONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ObjectInfo> listObjects(String schema, MetadataListConstraints constraints) {
|
||||
List<ObjectInfo> objects = super.listObjects(schema, constraints);
|
||||
for (ObjectInfo object : objects) object.setComment(decodeLegacyText(object.getComment()));
|
||||
return objects;
|
||||
}
|
||||
static final String PROCEDURE_SOURCE_SQL = """
|
||||
SELECT
|
||||
CASE WHEN METADATA_ROW = 1 THEN PROCEDURE_SOURCE ELSE NULL END AS PROCEDURE_SOURCE,
|
||||
@@ -81,7 +149,7 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
while (resultSet.next()) {
|
||||
found = true;
|
||||
if (body == null) {
|
||||
body = resultSet.getString("PROCEDURE_SOURCE");
|
||||
body = decodeLegacyText(resultSet.getString("PROCEDURE_SOURCE"));
|
||||
}
|
||||
String parameterName = trimToNull(resultSet.getString("PARAMETER_NAME"));
|
||||
if (parameterName == null) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Explicit, read-only recovery of legacy text stored as Latin-1 byte containers. */
|
||||
final class LegacyTextDecoder {
|
||||
static Charset charsetFromUrl(String url) {
|
||||
int query = url.indexOf('?');
|
||||
String charset = null;
|
||||
if (query >= 0) {
|
||||
for (String parameter : url.substring(query + 1).split("[&;]")) {
|
||||
String[] pair = parameter.split("=", 2);
|
||||
if (URLDecoder.decode(pair[0], StandardCharsets.UTF_8).equalsIgnoreCase("dataCharset")) {
|
||||
charset = pair.length == 2 ? URLDecoder.decode(pair[1], StandardCharsets.UTF_8).trim() : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
return charset == null || charset.isEmpty() ? null : Charset.forName(charset);
|
||||
}
|
||||
|
||||
static String jdbcUrl(String url) {
|
||||
int query = url.indexOf('?');
|
||||
if (query < 0) return url;
|
||||
java.util.List<String> parameters = new java.util.ArrayList<>();
|
||||
boolean removed = false;
|
||||
for (String parameter : url.substring(query + 1).split("[&;]")) {
|
||||
String key = URLDecoder.decode(parameter.split("=", 2)[0], StandardCharsets.UTF_8);
|
||||
if (key.equalsIgnoreCase("dataCharset")) removed = true;
|
||||
else if (!parameter.isEmpty()) parameters.add(parameter);
|
||||
}
|
||||
if (!removed) return url;
|
||||
return url.substring(0, query) + (parameters.isEmpty() ? "" : "?" + String.join("&", parameters));
|
||||
}
|
||||
|
||||
static String decode(String value, Charset charset) {
|
||||
if (value == null || charset == null) return value;
|
||||
boolean highByte = false;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char character = value.charAt(i);
|
||||
// Never truncate Unicode characters or replacement characters into bytes.
|
||||
if (character > 255) return value;
|
||||
highByte |= character > 127;
|
||||
}
|
||||
if (!highByte) return value;
|
||||
try {
|
||||
return charset.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(value.getBytes(StandardCharsets.ISO_8859_1))).toString();
|
||||
} catch (CharacterCodingException ignored) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LegacyTextDecoderTest {
|
||||
private static final Charset GBK = Charset.forName("GBK");
|
||||
|
||||
@Test
|
||||
void recoversUserTestRowAndColumnComments() {
|
||||
assertEquals("草稿", LegacyTextDecoder.decode("²Ý¸å", GBK));
|
||||
assertEquals("明细状态", LegacyTextDecoder.decode("Ã÷ϸ״̬", GBK));
|
||||
assertEquals("子流水", LegacyTextDecoder.decode("×ÓÁ÷Ë®", GBK));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesDefaultUnicodeAsciiNullAndMalformedData() {
|
||||
assertEquals("²Ý¸å", LegacyTextDecoder.decode("²Ý¸å", null));
|
||||
assertEquals("中文😀", LegacyTextDecoder.decode("中文😀", GBK));
|
||||
assertEquals("bad�", LegacyTextDecoder.decode("bad�", GBK));
|
||||
assertEquals("id", LegacyTextDecoder.decode("id", GBK));
|
||||
assertNull(LegacyTextDecoder.decode(null, GBK));
|
||||
assertEquals("é", LegacyTextDecoder.decode("é", GBK)); // Incomplete GBK sequence.
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsExplicitBig5AndGb18030() {
|
||||
for (String name : new String[] {"Big5", "GB18030"}) {
|
||||
Charset charset = Charset.forName(name);
|
||||
String latin1 = new String("中文".getBytes(charset), StandardCharsets.ISO_8859_1);
|
||||
assertEquals("中文", LegacyTextDecoder.decode(latin1, charset));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void separatesDbxReadOptionFromJdbcPropertiesIncludingFullUrls() {
|
||||
String url = "jdbc:firebirdsql://localhost:3050/C:/test.fdb?charSet=ISO8859_1&dataCharset=GBK&sqlDialect=3";
|
||||
assertEquals(GBK, LegacyTextDecoder.charsetFromUrl(url));
|
||||
assertEquals("jdbc:firebirdsql://localhost:3050/C:/test.fdb?charSet=ISO8859_1&sqlDialect=3", LegacyTextDecoder.jdbcUrl(url));
|
||||
assertEquals(GBK, LegacyTextDecoder.charsetFromUrl("jdbc:firebirdsql:x?DATACHARSET=%47%42%4b"));
|
||||
assertEquals("jdbc:firebirdsql:x", LegacyTextDecoder.jdbcUrl("jdbc:firebirdsql:x?dataCharset=GBK"));
|
||||
assertNull(LegacyTextDecoder.charsetFromUrl("jdbc:firebirdsql:x?dataCharset="));
|
||||
assertEquals("jdbc:firebirdsql:x?charSet=GBK;sqlDialect=3", LegacyTextDecoder.jdbcUrl("jdbc:firebirdsql:x?charSet=GBK;sqlDialect=3"));
|
||||
assertThrows(IllegalArgumentException.class, () -> LegacyTextDecoder.charsetFromUrl("jdbc:firebirdsql:x?dataCharset=not-a-charset"));
|
||||
}
|
||||
}
|
||||
@@ -457,6 +457,12 @@ public final class GoldendbAgent extends AbstractJdbcAgent {
|
||||
case Types.BIT:
|
||||
value = rs.getBoolean(index);
|
||||
break;
|
||||
case Types.BINARY:
|
||||
case Types.VARBINARY:
|
||||
case Types.LONGVARBINARY:
|
||||
case Types.BLOB:
|
||||
value = JdbcExecutor.bytesToHex(rs.getBytes(index));
|
||||
break;
|
||||
default:
|
||||
value = rs.getString(index);
|
||||
break;
|
||||
|
||||
@@ -2,6 +2,14 @@ package com.dbx.agent.goldendb;
|
||||
|
||||
import com.dbx.agent.DatabaseAgent;
|
||||
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Types;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class GoldendbAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
@Override
|
||||
@@ -13,4 +21,37 @@ class GoldendbAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
protected String resultSetSql() {
|
||||
return "CALL sample_proc()";
|
||||
}
|
||||
|
||||
@Test
|
||||
void binaryResultsPreserveBytesInsteadOfReturningAmbiguousText() {
|
||||
GoldendbAgent agent = new GoldendbAgent();
|
||||
byte[][] values = {
|
||||
"deadbeef".getBytes(StandardCharsets.US_ASCII),
|
||||
"0x00ff".getBytes(StandardCharsets.US_ASCII),
|
||||
new byte[]{0, (byte) 0xff, (byte) 0x80},
|
||||
new byte[0]
|
||||
};
|
||||
String[] expected = {"0x6465616462656566", "0x307830306666", "0x00ff80", "0x"};
|
||||
for (int sqlType : new int[]{Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY, Types.BLOB}) {
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
assertEquals(expected[i], agent.resultValue(binaryResultSet(values[i]), 1, sqlType));
|
||||
}
|
||||
assertNull(agent.resultValue(binaryResultSet(null), 1, sqlType));
|
||||
}
|
||||
}
|
||||
|
||||
private static ResultSet binaryResultSet(byte[] bytes) {
|
||||
return (ResultSet) Proxy.newProxyInstance(
|
||||
GoldendbAgentTest.class.getClassLoader(),
|
||||
new Class<?>[]{ResultSet.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getBytes".equals(method.getName())) {
|
||||
assertEquals(1, args[0]);
|
||||
return bytes;
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) return bytes == null;
|
||||
throw new AssertionError("Unexpected ResultSet call: " + method.getName());
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.mongodb.MongoNamespace;
|
||||
import com.mongodb.MongoCredential;
|
||||
import com.mongodb.MongoClientSettings;
|
||||
import com.mongodb.ServerAddress;
|
||||
import com.mongodb.connection.ClusterConnectionMode;
|
||||
import com.mongodb.bulk.BulkWriteError;
|
||||
import com.mongodb.bulk.WriteConcernError;
|
||||
import com.mongodb.client.AggregateIterable;
|
||||
@@ -153,7 +154,17 @@ public final class MongoAgent {
|
||||
|
||||
MongoClientSettings.Builder builder = MongoClientSettings.builder();
|
||||
if (connectionString != null && !connectionString.isBlank()) {
|
||||
builder.applyConnectionString(new ConnectionString(connectionString));
|
||||
ConnectionString cs = new ConnectionString(connectionString);
|
||||
builder.applyConnectionString(cs);
|
||||
// Driver 3.12 predates the directConnection URI option (introduced in driver 4.x)
|
||||
// and silently ignores it. When directConnection=true is present in the query of a
|
||||
// single-host URI, force ClusterConnectionMode.SINGLE so the driver connects directly
|
||||
// to that host instead of discovering replica-set members that may be unreachable
|
||||
// (e.g. through an SSH tunnel). We preserve requiredReplicaSetName as parsed because
|
||||
// ClusterSettings in 3.12 accepts it in SINGLE mode to validate the replica set name.
|
||||
if (!cs.isSrvProtocol() && cs.getHosts().size() == 1 && hasDirectConnectionTrue(connectionString)) {
|
||||
builder.applyToClusterSettings(settings -> settings.mode(ClusterConnectionMode.SINGLE));
|
||||
}
|
||||
} else {
|
||||
builder.applyToClusterSettings(
|
||||
settings -> settings.hosts(Collections.singletonList(new ServerAddress(host, port))));
|
||||
@@ -169,6 +180,46 @@ public final class MongoAgent {
|
||||
return builder;
|
||||
}
|
||||
|
||||
static boolean hasDirectConnectionTrue(String connectionString) {
|
||||
if (connectionString == null) {
|
||||
return false;
|
||||
}
|
||||
int hashStart = connectionString.indexOf('#');
|
||||
int queryStart = connectionString.indexOf('?');
|
||||
if (queryStart < 0 || (hashStart >= 0 && queryStart > hashStart)) {
|
||||
return false;
|
||||
}
|
||||
int queryEnd = hashStart >= 0 ? hashStart : connectionString.length();
|
||||
String query = connectionString.substring(queryStart + 1, queryEnd);
|
||||
if (query.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Boolean directConnection = null;
|
||||
for (String param : query.split("[&;]")) {
|
||||
if (param.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int eq = param.indexOf('=');
|
||||
String key = eq >= 0 ? param.substring(0, eq) : param;
|
||||
String val = eq >= 0 ? param.substring(eq + 1) : "";
|
||||
String decodedKey = decodeUriComponent(key);
|
||||
String decodedVal = decodeUriComponent(val);
|
||||
if ("directConnection".equalsIgnoreCase(decodedKey)) {
|
||||
directConnection = "true".equalsIgnoreCase(decodedVal);
|
||||
}
|
||||
}
|
||||
return Boolean.TRUE.equals(directConnection);
|
||||
}
|
||||
|
||||
private static String decodeUriComponent(String s) {
|
||||
try {
|
||||
return URLDecoder.decode(s, StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private static MongoClient openClient(JsonObject params) {
|
||||
JsonObject connObj = params.has("connection") && params.get("connection").isJsonObject()
|
||||
? params.getAsJsonObject("connection")
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.mongodb.MongoBulkWriteException;
|
||||
import com.mongodb.MongoCommandException;
|
||||
import com.mongodb.MongoClientSettings;
|
||||
import com.mongodb.ServerAddress;
|
||||
import com.mongodb.connection.ClusterConnectionMode;
|
||||
import com.mongodb.bulk.BulkWriteError;
|
||||
import com.mongodb.bulk.BulkWriteResult;
|
||||
import com.mongodb.bulk.WriteConcernError;
|
||||
@@ -1520,6 +1521,118 @@ class MongoAgentTest {
|
||||
assertEquals("admin", MongoAgent.authenticationDatabase(connection));
|
||||
}
|
||||
|
||||
// ─── Direct connection: configureBuilder directConnection parsing ───
|
||||
|
||||
@Test
|
||||
void configureBuilderWithDirectConnectionTrueSetsSingleModeAndKeepsReplicaSetName() {
|
||||
JsonObject connection = new JsonObject();
|
||||
connection.addProperty(
|
||||
"connection_string",
|
||||
"mongodb://u:p@127.0.0.1:27017/admin?replicaSet=cardetail&directConnection=true&authSource=admin");
|
||||
|
||||
MongoClientSettings settings = MongoAgent.configureBuilder(connection).build();
|
||||
|
||||
assertEquals(ClusterConnectionMode.SINGLE, settings.getClusterSettings().getMode());
|
||||
assertEquals("cardetail", settings.getClusterSettings().getRequiredReplicaSetName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void configureBuilderWithCaseInsensitiveDirectConnectionSetsSingleMode() {
|
||||
JsonObject connection = new JsonObject();
|
||||
connection.addProperty(
|
||||
"connection_string",
|
||||
"mongodb://u:p@127.0.0.1:27017/admin?replicaSet=cardetail&DirectConnection=TRUE&authSource=admin");
|
||||
|
||||
MongoClientSettings settings = MongoAgent.configureBuilder(connection).build();
|
||||
|
||||
assertEquals(ClusterConnectionMode.SINGLE, settings.getClusterSettings().getMode());
|
||||
assertEquals("cardetail", settings.getClusterSettings().getRequiredReplicaSetName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void configureBuilderWithReplicaSetWithoutDirectConnectionDefaultsToMultipleMode() {
|
||||
JsonObject connection = new JsonObject();
|
||||
connection.addProperty(
|
||||
"connection_string",
|
||||
"mongodb://u:p@127.0.0.1:27017/admin?replicaSet=cardetail&authSource=admin");
|
||||
|
||||
MongoClientSettings settings = MongoAgent.configureBuilder(connection).build();
|
||||
|
||||
assertEquals(ClusterConnectionMode.MULTIPLE, settings.getClusterSettings().getMode());
|
||||
assertEquals("cardetail", settings.getClusterSettings().getRequiredReplicaSetName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void configureBuilderWithDirectConnectionFalseAndReplicaSetLeavesMultipleMode() {
|
||||
JsonObject connection = new JsonObject();
|
||||
connection.addProperty(
|
||||
"connection_string",
|
||||
"mongodb://u:p@127.0.0.1:27017/admin?directConnection=false&replicaSet=cardetail&authSource=admin");
|
||||
|
||||
MongoClientSettings settings = MongoAgent.configureBuilder(connection).build();
|
||||
|
||||
assertEquals(ClusterConnectionMode.MULTIPLE, settings.getClusterSettings().getMode());
|
||||
assertEquals("cardetail", settings.getClusterSettings().getRequiredReplicaSetName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void configureBuilderSingleHostNoOptionsRemainsUnchanged() {
|
||||
JsonObject connection = new JsonObject();
|
||||
connection.addProperty("connection_string", "mongodb://127.0.0.1:27017");
|
||||
|
||||
MongoClientSettings settings = MongoAgent.configureBuilder(connection).build();
|
||||
|
||||
assertEquals(ClusterConnectionMode.SINGLE, settings.getClusterSettings().getMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void configureBuilderTwoHostsWithDirectConnectionTrueRemainsMultipleMode() {
|
||||
JsonObject connection = new JsonObject();
|
||||
connection.addProperty(
|
||||
"connection_string",
|
||||
"mongodb://127.0.0.1:27017,127.0.0.1:27018/?replicaSet=cardetail&directConnection=true");
|
||||
|
||||
MongoClientSettings settings = MongoAgent.configureBuilder(connection).build();
|
||||
|
||||
assertEquals(ClusterConnectionMode.MULTIPLE, settings.getClusterSettings().getMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void configureBuilderWithPercentEncodedDirectConnectionSetsSingleMode() {
|
||||
JsonObject connection = new JsonObject();
|
||||
connection.addProperty(
|
||||
"connection_string",
|
||||
"mongodb://u:p@127.0.0.1:27017/admin?replicaSet=cardetail&%64irect%43onnection=%74rue&authSource=admin");
|
||||
|
||||
MongoClientSettings settings = MongoAgent.configureBuilder(connection).build();
|
||||
|
||||
assertEquals(ClusterConnectionMode.SINGLE, settings.getClusterSettings().getMode());
|
||||
assertEquals("cardetail", settings.getClusterSettings().getRequiredReplicaSetName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasDirectConnectionTrueHandlesVariousQueryFormats() {
|
||||
assertTrue(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?directConnection=true"));
|
||||
assertTrue(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?DirectConnection=TRUE"));
|
||||
assertTrue(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?directconnection=true&other=1"));
|
||||
assertTrue(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?other=1&directConnection=true"));
|
||||
assertTrue(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?directConnection=false&directConnection=true"));
|
||||
assertTrue(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?directConnection=true#fragment"));
|
||||
assertTrue(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?%64irect%43onnection=%74rue"));
|
||||
|
||||
assertFalse(MongoAgent.hasDirectConnectionTrue(null));
|
||||
assertFalse(MongoAgent.hasDirectConnectionTrue(""));
|
||||
assertFalse(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017"));
|
||||
assertFalse(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017?"));
|
||||
assertFalse(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?replicaSet=rs"));
|
||||
assertFalse(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?directConnection=false"));
|
||||
assertFalse(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?directConnection=1"));
|
||||
assertFalse(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?directConnection="));
|
||||
assertFalse(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/?directConnection=true&directConnection=false"));
|
||||
assertFalse(MongoAgent.hasDirectConnectionTrue("mongodb://127.0.0.1:27017/#fragment?directConnection=true"));
|
||||
assertFalse(MongoAgent.hasDirectConnectionTrue("mongodb://directConnection=true@127.0.0.1:27017/db"));
|
||||
}
|
||||
|
||||
// ─── TLS: configureBuilder JSON parsing ───
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
nebula "github.com/vesoft-inc/nebula-go/v3"
|
||||
)
|
||||
|
||||
func appendDisplayText(parts *[]any, text string) {
|
||||
if len(*parts) > 0 {
|
||||
if previous, ok := (*parts)[len(*parts)-1].(string); ok && len(previous)+len(text) <= 4096 {
|
||||
(*parts)[len(*parts)-1] = previous + text
|
||||
return
|
||||
}
|
||||
}
|
||||
*parts = append(*parts, text)
|
||||
}
|
||||
|
||||
// Retain scalar text and ordered references instead of reparsing displayed nGQL.
|
||||
func appendGraphDisplay(value *nebula.ValueWrapper, parts *[]any) {
|
||||
switch {
|
||||
case value.IsVertex():
|
||||
if node, err := value.AsNode(); err == nil {
|
||||
*parts = append(*parts, map[string]string{"nodeId": graphPlaceholder(node.GetID()).ID})
|
||||
return
|
||||
}
|
||||
case value.IsEdge():
|
||||
if edge, err := value.AsRelationship(); err == nil {
|
||||
*parts = append(*parts, map[string]string{"edgeId": graphEdgeFromNebula(edge).ID})
|
||||
return
|
||||
}
|
||||
case value.IsPath():
|
||||
if path, err := value.AsPath(); err == nil {
|
||||
nodes, edges := path.GetNodes(), path.GetRelationships()
|
||||
if len(nodes) == len(edges)+1 {
|
||||
appendDisplayText(parts, "<")
|
||||
*parts = append(*parts, map[string]string{"nodeId": graphPlaceholder(nodes[0].GetID()).ID})
|
||||
for index, edge := range edges {
|
||||
identity := graphEdgeFromNebula(edge)
|
||||
forward := graphPlaceholder(nodes[index].GetID()).ID == identity.Source
|
||||
if forward {
|
||||
appendDisplayText(parts, "-")
|
||||
} else {
|
||||
appendDisplayText(parts, "<-")
|
||||
}
|
||||
*parts = append(*parts, map[string]any{"edgeId": identity.ID, "path": true})
|
||||
if forward {
|
||||
appendDisplayText(parts, "->")
|
||||
} else {
|
||||
appendDisplayText(parts, "-")
|
||||
}
|
||||
*parts = append(*parts, map[string]string{"nodeId": graphPlaceholder(nodes[index+1].GetID()).ID})
|
||||
}
|
||||
appendDisplayText(parts, ">")
|
||||
return
|
||||
}
|
||||
}
|
||||
case value.IsList():
|
||||
if values, err := value.AsList(); err == nil {
|
||||
appendGraphDisplayList(values, parts, "[", "]")
|
||||
return
|
||||
}
|
||||
case value.IsSet():
|
||||
if values, err := value.AsDedupList(); err == nil {
|
||||
appendGraphDisplayList(values, parts, "{", "}")
|
||||
return
|
||||
}
|
||||
case value.IsMap():
|
||||
if values, err := value.AsMap(); err == nil {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
appendDisplayText(parts, "{")
|
||||
for index, key := range keys {
|
||||
if index > 0 {
|
||||
appendDisplayText(parts, ", ")
|
||||
}
|
||||
appendDisplayText(parts, key+": ")
|
||||
item := values[key]
|
||||
appendGraphDisplay(&item, parts)
|
||||
}
|
||||
appendDisplayText(parts, "}")
|
||||
return
|
||||
}
|
||||
}
|
||||
if value.IsFloat() {
|
||||
appendDisplayText(parts, normalizeValue(value).(string))
|
||||
} else {
|
||||
appendDisplayText(parts, value.String())
|
||||
}
|
||||
}
|
||||
|
||||
func appendGraphDisplayList(values []nebula.ValueWrapper, parts *[]any, open, close string) {
|
||||
appendDisplayText(parts, open)
|
||||
for index := range values {
|
||||
if index > 0 {
|
||||
appendDisplayText(parts, ", ")
|
||||
}
|
||||
appendGraphDisplay(&values[index], parts)
|
||||
}
|
||||
appendDisplayText(parts, close)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
wire "github.com/vesoft-inc/nebula-go/v3/nebula"
|
||||
)
|
||||
|
||||
func TestGraphDisplayPartsPreserveOrderedEntitiesAndScalarTokens(t *testing.T) {
|
||||
large := int64(9007199254740993)
|
||||
small := 0.000001
|
||||
a := &wire.Vertex{Vid: &wire.Value{SVal: []byte("a")}, Tags: []*wire.Tag{{Name: []byte("Person"), Props: map[string]*wire.Value{"count": {IVal: &large}}}}}
|
||||
b := &wire.Vertex{Vid: &wire.Value{SVal: []byte("b")}}
|
||||
edge := &wire.Edge{Src: a.Vid, Dst: b.Vid, Name: []byte("LINK"), Type: 1, Ranking: 7, Props: map[string]*wire.Value{"weight": {FVal: &small}}}
|
||||
path := &wire.Path{Src: a, Steps: []*wire.Step{{Dst: b, Type: -1, Name: edge.Name, Ranking: edge.Ranking, Props: edge.Props}}}
|
||||
values := []*wire.Value{
|
||||
{PVal: path},
|
||||
{LVal: &wire.NList{Values: []*wire.Value{{VVal: a}, {SVal: []byte("literal(a)")}, {IVal: &large}, {FVal: &small}, {EVal: edge}}}},
|
||||
{UVal: &wire.NSet{Values: []*wire.Value{{VVal: a}, {EVal: edge}}}},
|
||||
{MVal: &wire.NMap{Kvs: map[string]*wire.Value{"nested": {LVal: &wire.NList{Values: []*wire.Value{{VVal: a}, {VVal: a}}}}, "scalar": {IVal: &large}}}},
|
||||
}
|
||||
rows, _, err := readRows(resultSet(t, []string{"path", "list", "set", "map"}, [][]*wire.Value{values}), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for column, value := range rows[0] {
|
||||
cell := value.(*graphCell)
|
||||
if len(cell.DisplayParts) == 0 {
|
||||
t.Fatalf("column %d has no editable display references", column)
|
||||
}
|
||||
nodes := make(map[string]bool)
|
||||
edges := make(map[string]bool)
|
||||
for _, node := range cell.Nodes {
|
||||
nodes[node.ID] = true
|
||||
}
|
||||
for _, edge := range cell.Edges {
|
||||
edges[edge.ID] = true
|
||||
}
|
||||
for _, part := range cell.DisplayParts {
|
||||
encoded, err := json.Marshal(part)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := part.(string); ok {
|
||||
continue
|
||||
}
|
||||
var reference struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
EdgeID string `json:"edgeId"`
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &reference); err != nil || (!nodes[reference.NodeID] && !edges[reference.EdgeID]) {
|
||||
t.Fatalf("unresolvable display reference: %s", encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
pathCell := rows[0][0].(*graphCell)
|
||||
if !reflect.DeepEqual(pathCell.DisplayParts[2], "<-") || !reflect.DeepEqual(pathCell.DisplayParts[4], "-") {
|
||||
t.Fatalf("reverse path order lost: %#v", pathCell.DisplayParts)
|
||||
}
|
||||
listJSON, _ := json.Marshal(rows[0][1].(*graphCell).DisplayParts)
|
||||
if !strings.Contains(string(listJSON), "literal(a)") || !strings.Contains(string(listJSON), "9007199254740993") || !strings.Contains(string(listJSON), "1e-06") || strings.Contains(string(listJSON), "1e-06.0") {
|
||||
t.Fatalf("scalar text or exact numbers changed: %s", listJSON)
|
||||
}
|
||||
mapCell := rows[0][3].(*graphCell)
|
||||
var references []string
|
||||
for _, part := range mapCell.DisplayParts {
|
||||
if ref, ok := part.(map[string]string); ok {
|
||||
references = append(references, ref["nodeId"])
|
||||
}
|
||||
}
|
||||
if len(references) != 2 || references[0] != references[1] {
|
||||
t.Fatalf("repeated aliases lost: %#v", mapCell.DisplayParts)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -111,6 +113,65 @@ func TestReadRowsPreservesScalarAndGraphValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRowsFloatRoundTripDoesNotUseSDKDisplay(t *testing.T) {
|
||||
for _, number := range []float64{0.000001, -0.000001, 1e20, 1.25e-7, math.SmallestNonzeroFloat64, math.MaxFloat64} {
|
||||
t.Run(strconv.FormatFloat(number, 'g', -1, 64), func(t *testing.T) {
|
||||
result := resultSet(t, []string{"dbx_value"}, [][]*wire.Value{{{FVal: &number}}})
|
||||
rows, types, err := readRows(result, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := rows[0][0].(string)
|
||||
got, err := strconv.ParseFloat(text, 64)
|
||||
if err != nil || got != number || types[0] != "float" {
|
||||
t.Fatalf("float round-trip lost: value=%q parsed=%v error=%v types=%v", text, got, err, types)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRowsPreservesGraphIdentityAndProperties(t *testing.T) {
|
||||
large := int64(9007199254740993)
|
||||
rank := wire.EdgeRanking(7)
|
||||
src := &wire.Value{SVal: []byte("person:1")}
|
||||
dst := &wire.Value{SVal: []byte("company:1")}
|
||||
person := &wire.Vertex{Vid: src, Tags: []*wire.Tag{{Name: []byte("Person"), Props: map[string]*wire.Value{
|
||||
"name": {SVal: []byte("Ada")}, "visits": {IVal: &large},
|
||||
}}}}
|
||||
company := &wire.Vertex{Vid: dst, Tags: []*wire.Tag{{Name: []byte("Company")}}}
|
||||
edge := &wire.Edge{Src: src, Dst: dst, Type: 1, Name: []byte("WORK_IN"), Ranking: rank, Props: map[string]*wire.Value{"since": {IVal: &large}}}
|
||||
path := &wire.Path{Src: person, Steps: []*wire.Step{{Dst: company, Type: 1, Name: []byte("WORK_IN"), Ranking: rank, Props: edge.Props}}}
|
||||
reversed := &wire.Edge{Src: src, Dst: dst, Type: -1, Name: []byte("WORK_IN"), Ranking: rank}
|
||||
result := resultSet(t, []string{"vertex", "edge", "path", "items", "reverse_edge"}, [][]*wire.Value{{
|
||||
{VVal: person}, {EVal: edge}, {PVal: path}, {LVal: &wire.NList{Values: []*wire.Value{{VVal: person}, {EVal: edge}}}}, {EVal: reversed},
|
||||
}})
|
||||
rows, _, err := readRows(result, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vertex := rows[0][0].(*graphCell)
|
||||
if vertex.Kind != "vertex" || vertex.Nodes[0].VID.Value != "person:1" || vertex.Nodes[0].Properties[1].Value != "9007199254740993" {
|
||||
t.Fatalf("vertex lost identity or large integer: %#v", vertex)
|
||||
}
|
||||
relationship := rows[0][1].(*graphCell)
|
||||
if relationship.Kind != "edge" || relationship.Edges[0].SourceVID.Value != "person:1" || relationship.Edges[0].TargetVID.Value != "company:1" || relationship.Edges[0].Rank != "7" {
|
||||
t.Fatalf("edge lost direction or rank: %#v", relationship)
|
||||
}
|
||||
if pathCell := rows[0][2].(*graphCell); pathCell.Kind != "path" || len(pathCell.Nodes) != 2 || len(pathCell.Edges) != 1 {
|
||||
t.Fatalf("path was not preserved: %#v", pathCell)
|
||||
}
|
||||
if items := rows[0][3].(*graphCell); len(items.Nodes) < 1 || len(items.Edges) != 1 {
|
||||
t.Fatalf("nested graph values were not preserved: %#v", items)
|
||||
}
|
||||
if reverse := rows[0][4].(*graphCell); reverse.Edges[0].SourceVID.Value != "company:1" || reverse.Edges[0].TargetVID.Value != "person:1" {
|
||||
t.Fatalf("reverse edge direction was lost: %#v", reverse)
|
||||
}
|
||||
data, err := json.Marshal(rows)
|
||||
if err != nil || !strings.Contains(string(data), `"__dbx_graph_cell":"nebula-v1"`) {
|
||||
t.Fatalf("graph envelope cannot cross the agent protocol: %s, %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchQueryPageAdvancesWithoutReexecuting(t *testing.T) {
|
||||
session := &agentSession{cursors: map[string]*queryCursor{
|
||||
"page-1": {result: queryResult{Columns: []string{"n"}, ColumnTypes: []string{"int"}, Rows: [][]any{{"1"}, {"2"}, {"3"}}}, offset: 1},
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -43,6 +45,45 @@ type queryCursor struct {
|
||||
offset int
|
||||
}
|
||||
|
||||
type graphVID struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type graphProperty struct {
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type graphNode struct {
|
||||
ID string `json:"id"`
|
||||
VID graphVID `json:"vid"`
|
||||
Labels []string `json:"labels"`
|
||||
Properties []graphProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type graphEdge struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
SourceVID graphVID `json:"sourceVid"`
|
||||
TargetVID graphVID `json:"targetVid"`
|
||||
Type string `json:"type"`
|
||||
Rank string `json:"rank"`
|
||||
Properties []graphProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type graphCell struct {
|
||||
Marker string `json:"__dbx_graph_cell"`
|
||||
Kind string `json:"kind"`
|
||||
Display string `json:"display"`
|
||||
Nodes []graphNode `json:"nodes"`
|
||||
Edges []graphEdge `json:"edges"`
|
||||
DisplayParts []any `json:"displayParts,omitempty"`
|
||||
}
|
||||
|
||||
func (s *agentSession) dispatch(method string, params map[string]json.RawMessage) (any, error) {
|
||||
switch method {
|
||||
case "connection_info":
|
||||
@@ -239,9 +280,162 @@ func normalizeValue(value *nebula.ValueWrapper) any {
|
||||
return text
|
||||
}
|
||||
}
|
||||
if value.IsFloat() {
|
||||
if number, err := value.AsFloat(); err == nil {
|
||||
return strconv.FormatFloat(number, 'g', -1, 64)
|
||||
}
|
||||
}
|
||||
if graph := graphCellForValue(value); graph != nil {
|
||||
return graph
|
||||
}
|
||||
return value.String()
|
||||
}
|
||||
|
||||
func graphCellForValue(value *nebula.ValueWrapper) *graphCell {
|
||||
if !value.IsVertex() && !value.IsEdge() && !value.IsPath() && !value.IsList() && !value.IsSet() && !value.IsMap() {
|
||||
return nil
|
||||
}
|
||||
cell := &graphCell{Marker: "nebula-v1", Kind: value.GetType(), Display: value.String(), Nodes: []graphNode{}, Edges: []graphEdge{}}
|
||||
appendGraphValue(value, cell)
|
||||
if len(cell.Nodes) == 0 && len(cell.Edges) == 0 {
|
||||
return nil
|
||||
}
|
||||
appendGraphDisplay(value, &cell.DisplayParts)
|
||||
return cell
|
||||
}
|
||||
|
||||
func appendGraphValue(value *nebula.ValueWrapper, cell *graphCell) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case value.IsVertex():
|
||||
node, err := value.AsNode()
|
||||
if err == nil {
|
||||
cell.Nodes = append(cell.Nodes, graphNodeFromNebula(node))
|
||||
}
|
||||
case value.IsEdge():
|
||||
edge, err := value.AsRelationship()
|
||||
if err == nil {
|
||||
cell.Edges = append(cell.Edges, graphEdgeFromNebula(edge))
|
||||
cell.Nodes = append(cell.Nodes, graphPlaceholder(edge.GetSrcVertexID()), graphPlaceholder(edge.GetDstVertexID()))
|
||||
}
|
||||
case value.IsPath():
|
||||
path, err := value.AsPath()
|
||||
if err == nil {
|
||||
for _, node := range path.GetNodes() {
|
||||
cell.Nodes = append(cell.Nodes, graphNodeFromNebula(node))
|
||||
}
|
||||
for _, edge := range path.GetRelationships() {
|
||||
cell.Edges = append(cell.Edges, graphEdgeFromNebula(edge))
|
||||
}
|
||||
}
|
||||
case value.IsList():
|
||||
items, err := value.AsList()
|
||||
if err == nil {
|
||||
for i := range items {
|
||||
appendGraphValue(&items[i], cell)
|
||||
}
|
||||
}
|
||||
case value.IsSet():
|
||||
items, err := value.AsDedupList()
|
||||
if err == nil {
|
||||
for i := range items {
|
||||
appendGraphValue(&items[i], cell)
|
||||
}
|
||||
}
|
||||
case value.IsMap():
|
||||
items, err := value.AsMap()
|
||||
if err == nil {
|
||||
for _, item := range items {
|
||||
appendGraphValue(&item, cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func graphIdentity(parts ...string) string {
|
||||
encoded, _ := json.Marshal(parts)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func graphVIDFromNebula(value nebula.ValueWrapper) graphVID {
|
||||
if value.IsString() {
|
||||
text, _ := value.AsString()
|
||||
return graphVID{Type: "string", Value: text}
|
||||
}
|
||||
if value.IsInt() {
|
||||
number, _ := value.AsInt()
|
||||
return graphVID{Type: "int", Value: strconv.FormatInt(number, 10)}
|
||||
}
|
||||
return graphVID{Type: value.GetType(), Value: value.String()}
|
||||
}
|
||||
|
||||
func graphPlaceholder(value nebula.ValueWrapper) graphNode {
|
||||
vid := graphVIDFromNebula(value)
|
||||
return graphNode{ID: graphIdentity("vertex", vid.Type, vid.Value), VID: vid, Labels: []string{}, Properties: []graphProperty{}}
|
||||
}
|
||||
|
||||
func graphPropertyFromNebula(owner, name string, value *nebula.ValueWrapper) graphProperty {
|
||||
property := graphProperty{Owner: owner, Name: name, Type: "null"}
|
||||
if value == nil || value.IsNull() {
|
||||
return property
|
||||
}
|
||||
property.Type = value.GetType()
|
||||
switch {
|
||||
case value.IsString():
|
||||
property.Value, _ = value.AsString()
|
||||
case value.IsBool():
|
||||
property.Value, _ = value.AsBool()
|
||||
case value.IsInt():
|
||||
number, _ := value.AsInt()
|
||||
property.Value = strconv.FormatInt(number, 10)
|
||||
case value.IsFloat():
|
||||
number, _ := value.AsFloat()
|
||||
property.Value = strconv.FormatFloat(number, 'g', -1, 64)
|
||||
default:
|
||||
property.Value = value.String()
|
||||
}
|
||||
return property
|
||||
}
|
||||
|
||||
func graphNodeFromNebula(node *nebula.Node) graphNode {
|
||||
result := graphPlaceholder(node.GetID())
|
||||
result.Labels = append([]string{}, node.GetTags()...)
|
||||
for _, tag := range result.Labels {
|
||||
props, err := node.Properties(tag)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for name, value := range props {
|
||||
result.Properties = append(result.Properties, graphPropertyFromNebula(tag, name, value))
|
||||
}
|
||||
}
|
||||
sort.Slice(result.Properties, func(i, j int) bool {
|
||||
if result.Properties[i].Owner == result.Properties[j].Owner {
|
||||
return result.Properties[i].Name < result.Properties[j].Name
|
||||
}
|
||||
return result.Properties[i].Owner < result.Properties[j].Owner
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func graphEdgeFromNebula(edge *nebula.Relationship) graphEdge {
|
||||
src := graphPlaceholder(edge.GetSrcVertexID())
|
||||
dst := graphPlaceholder(edge.GetDstVertexID())
|
||||
rank := strconv.FormatInt(edge.GetRanking(), 10)
|
||||
result := graphEdge{
|
||||
ID: graphIdentity("edge", edge.GetEdgeName(), src.ID, dst.ID, rank),
|
||||
Source: src.ID, Target: dst.ID, SourceVID: src.VID, TargetVID: dst.VID,
|
||||
Type: edge.GetEdgeName(), Rank: rank, Properties: []graphProperty{},
|
||||
}
|
||||
for name, value := range edge.Properties() {
|
||||
result.Properties = append(result.Properties, graphPropertyFromNebula("", name, value))
|
||||
}
|
||||
sort.Slice(result.Properties, func(i, j int) bool { return result.Properties[i].Name < result.Properties[j].Name })
|
||||
return result
|
||||
}
|
||||
|
||||
type nebulaQueryError struct {
|
||||
code string
|
||||
message string
|
||||
|
||||
@@ -23,7 +23,7 @@ This module replaces the Neo4j JDBC Agent with the official Neo4j Go Driver.
|
||||
- Node labels use `CALL db.labels()`.
|
||||
- Properties use `db.schema.nodeTypeProperties()` with a sampled-node fallback.
|
||||
- Index uniqueness is derived from `SHOW INDEXES ... owningConstraint`, which is compatible with Neo4j 5.x.
|
||||
- Query row values retain the previous Agent behavior: scalar and graph values are returned as displayable strings, while null remains null.
|
||||
- Scalar query values remain displayable strings and null remains null. Graph values carry a versioned envelope with the original display text, typed identities and properties for the shared graph result view.
|
||||
|
||||
## Validation
|
||||
|
||||
|
||||
@@ -60,19 +60,13 @@ func openVerifiedRuntime(params connectParams) (*connectionRuntime, error) {
|
||||
_ = driver.Close(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
return &connectionRuntime{driver: driver, params: params, legacySingleDatabase: usesLegacySingleDatabase(ctx, driver)}, nil
|
||||
}
|
||||
|
||||
// usesLegacySingleDatabase reports whether the server negotiated Bolt 3 or
|
||||
// older. Those servers only expose the default database, and the driver refuses
|
||||
// to select a database name for them, so sessions must not send one. Probe
|
||||
// failures keep the modern behavior.
|
||||
func usesLegacySingleDatabase(ctx context.Context, driver neo4j.Driver) bool {
|
||||
info, err := driver.GetServerInfo(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
runtime := &connectionRuntime{driver: driver, params: params}
|
||||
if info, err := driver.GetServerInfo(ctx); err == nil {
|
||||
// Older Bolt versions supply numeric IDs in the element-ID fields.
|
||||
runtime.legacySingleDatabase = info.ProtocolVersion().Major < 4
|
||||
runtime.legacyGraphIDs = info.ProtocolVersion().Major < 5
|
||||
}
|
||||
return info.ProtocolVersion().Major < 4
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
func openDriver(params connectParams) (neo4j.Driver, error) {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
neo4j "github.com/neo4j/neo4j-go-driver/v6/neo4j"
|
||||
)
|
||||
|
||||
type graphVID struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type graphProperty struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type graphNode struct {
|
||||
ID string `json:"id"`
|
||||
VID graphVID `json:"vid"`
|
||||
Labels []string `json:"labels"`
|
||||
Properties []graphProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type graphEdge struct {
|
||||
ID string `json:"id"`
|
||||
VID graphVID `json:"vid"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
SourceVID graphVID `json:"sourceVid"`
|
||||
TargetVID graphVID `json:"targetVid"`
|
||||
Type string `json:"type"`
|
||||
Properties []graphProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type graphCell struct {
|
||||
*nodeCell
|
||||
Marker string `json:"__dbx_graph_cell"`
|
||||
Kind string `json:"kind"`
|
||||
Display any `json:"display"`
|
||||
Nodes []graphNode `json:"nodes"`
|
||||
Edges []graphEdge `json:"edges"`
|
||||
DisplayParts []any `json:"displayParts,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeGraphQueryValue(value any, legacyIDs bool) any {
|
||||
display := normalizeQueryValue(value)
|
||||
cell := &graphCell{Marker: "neo4j-v1", Kind: strings.ToLower(neo4jTypeName(value)), Display: display, Nodes: []graphNode{}, Edges: []graphEdge{}}
|
||||
if node, ok := value.(neo4j.Node); ok {
|
||||
tableCell := normalizeNodeCell(node)
|
||||
cell.nodeCell = &tableCell
|
||||
cell.Kind = "vertex"
|
||||
}
|
||||
if _, ok := value.(neo4j.Relationship); ok {
|
||||
cell.Kind = "edge"
|
||||
}
|
||||
appendGraphValue(value, cell, legacyIDs)
|
||||
if len(cell.Nodes) == 0 && len(cell.Edges) == 0 {
|
||||
return display
|
||||
}
|
||||
if cell.Kind != "vertex" && cell.Kind != "edge" {
|
||||
appendGraphDisplay(value, &cell.DisplayParts, legacyIDs)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
// Keep scalar JSON tokens intact while retaining references to editable entities.
|
||||
func appendDisplayText(parts *[]any, text string) {
|
||||
if len(*parts) > 0 {
|
||||
if previous, ok := (*parts)[len(*parts)-1].(string); ok && len(previous)+len(text) <= 4096 {
|
||||
(*parts)[len(*parts)-1] = previous + text
|
||||
return
|
||||
}
|
||||
}
|
||||
*parts = append(*parts, text)
|
||||
}
|
||||
|
||||
func appendGraphDisplay(value any, parts *[]any, legacyIDs bool) {
|
||||
switch typed := value.(type) {
|
||||
case neo4j.Node:
|
||||
*parts = append(*parts, map[string]string{"nodeId": graphIdentity("vertex", graphVIDFor(typed.ElementId, typed.Id, legacyIDs))})
|
||||
case neo4j.Relationship:
|
||||
*parts = append(*parts, map[string]string{"edgeId": graphIdentity("edge", graphVIDFor(typed.ElementId, typed.Id, legacyIDs))})
|
||||
case neo4j.Path:
|
||||
appendDisplayText(parts, `{"nodes":`)
|
||||
appendGraphDisplay(typed.Nodes, parts, legacyIDs)
|
||||
appendDisplayText(parts, `,"relationships":`)
|
||||
appendGraphDisplay(typed.Relationships, parts, legacyIDs)
|
||||
appendDisplayText(parts, "}")
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
appendDisplayText(parts, "{")
|
||||
for index, key := range keys {
|
||||
if index > 0 {
|
||||
appendDisplayText(parts, ",")
|
||||
}
|
||||
appendDisplayText(parts, formatJSONValue(key)+":")
|
||||
appendGraphDisplay(typed[key], parts, legacyIDs)
|
||||
}
|
||||
appendDisplayText(parts, "}")
|
||||
default:
|
||||
value := reflect.ValueOf(value)
|
||||
if value.IsValid() && (value.Kind() == reflect.Slice || value.Kind() == reflect.Array) {
|
||||
appendDisplayText(parts, "[")
|
||||
for i := 0; i < value.Len(); i++ {
|
||||
if i > 0 {
|
||||
appendDisplayText(parts, ",")
|
||||
}
|
||||
appendGraphDisplay(value.Index(i).Interface(), parts, legacyIDs)
|
||||
}
|
||||
appendDisplayText(parts, "]")
|
||||
} else {
|
||||
appendDisplayText(parts, formatJSONValue(typed))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func graphVIDFor(elementID string, legacyID int64, legacyIDs bool) graphVID {
|
||||
if legacyIDs {
|
||||
return graphVID{Type: "neo4j-id", Value: strconv.FormatInt(legacyID, 10)}
|
||||
}
|
||||
return graphVID{Type: "neo4j-element-id", Value: elementID}
|
||||
}
|
||||
|
||||
func graphIdentity(kind string, vid graphVID) string {
|
||||
encoded, _ := json.Marshal([]string{kind, vid.Type, vid.Value})
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func graphPlaceholder(elementID string, legacyID int64, legacyIDs bool) graphNode {
|
||||
vid := graphVIDFor(elementID, legacyID, legacyIDs)
|
||||
return graphNode{ID: graphIdentity("vertex", vid), VID: vid, Labels: []string{}, Properties: []graphProperty{}}
|
||||
}
|
||||
|
||||
func graphProperties(properties map[string]any) []graphProperty {
|
||||
result := make([]graphProperty, 0, len(properties))
|
||||
for name, value := range properties {
|
||||
property := graphProperty{Name: name, Type: neo4jTypeName(value), Value: normalizeQueryValue(value)}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
property.Type = "string"
|
||||
case bool:
|
||||
property.Type, property.Value = "bool", typed
|
||||
case int, int64:
|
||||
property.Type = "int"
|
||||
case float64:
|
||||
property.Type = "float"
|
||||
case []byte:
|
||||
property.Value = formatJSONValue(typed)
|
||||
}
|
||||
result = append(result, property)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
return result
|
||||
}
|
||||
|
||||
func appendGraphValue(value any, cell *graphCell, legacyIDs bool) {
|
||||
switch typed := value.(type) {
|
||||
case neo4j.Node:
|
||||
node := graphPlaceholder(typed.ElementId, typed.Id, legacyIDs)
|
||||
node.Labels = append(node.Labels, typed.Labels...)
|
||||
node.Properties = graphProperties(typed.Props)
|
||||
cell.Nodes = append(cell.Nodes, node)
|
||||
case neo4j.Relationship:
|
||||
source := graphPlaceholder(typed.StartElementId, typed.StartId, legacyIDs)
|
||||
target := graphPlaceholder(typed.EndElementId, typed.EndId, legacyIDs)
|
||||
vid := graphVIDFor(typed.ElementId, typed.Id, legacyIDs)
|
||||
cell.Nodes = append(cell.Nodes, source, target)
|
||||
cell.Edges = append(cell.Edges, graphEdge{ID: graphIdentity("edge", vid), VID: vid, Source: source.ID, Target: target.ID, SourceVID: source.VID, TargetVID: target.VID, Type: typed.Type, Properties: graphProperties(typed.Props)})
|
||||
case neo4j.Path:
|
||||
for _, node := range typed.Nodes {
|
||||
appendGraphValue(node, cell, legacyIDs)
|
||||
}
|
||||
for _, edge := range typed.Relationships {
|
||||
appendGraphValue(edge, cell, legacyIDs)
|
||||
}
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
appendGraphValue(typed[key], cell, legacyIDs)
|
||||
}
|
||||
case []byte:
|
||||
return
|
||||
default:
|
||||
value := reflect.ValueOf(value)
|
||||
if value.IsValid() && (value.Kind() == reflect.Slice || value.Kind() == reflect.Array) {
|
||||
for i := 0; i < value.Len(); i++ {
|
||||
appendGraphValue(value.Index(i).Interface(), cell, legacyIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
neo4j "github.com/neo4j/neo4j-go-driver/v6/neo4j"
|
||||
)
|
||||
|
||||
func TestGraphRecordPreservesScalarDisplayAndExactIdentity(t *testing.T) {
|
||||
node := neo4j.Node{Id: 9007199254740993, ElementId: "4:test:1", Labels: []string{"Person", "Employee"}, Props: map[string]any{"name": "Sample A", "count": int64(9007199254740993), "enabled": true}}
|
||||
for _, legacy := range []bool{false, true} {
|
||||
row := normalizeRecord(&neo4j.Record{Values: []any{node, int64(42), nil, true}}, 4, legacy)
|
||||
cell, ok := row[0].(*graphCell)
|
||||
if !ok || cell.Marker != "neo4j-v1" || cell.Kind != "vertex" || cell.Display != formatNode(node) {
|
||||
t.Fatalf("invalid graph cell: %#v", row[0])
|
||||
}
|
||||
if !reflect.DeepEqual(row[1:], []any{"42", nil, "true"}) {
|
||||
t.Fatalf("scalar results changed: %#v", row[1:])
|
||||
}
|
||||
vid := cell.Nodes[0].VID
|
||||
if legacy && (vid.Type != "neo4j-id" || vid.Value != "9007199254740993") || !legacy && (vid.Type != "neo4j-element-id" || vid.Value != "4:test:1") {
|
||||
t.Fatalf("identity lost: %#v", vid)
|
||||
}
|
||||
encoded, err := json.Marshal(cell)
|
||||
if err != nil || !strings.Contains(string(encoded), `"value":"9007199254740993"`) || !strings.Contains(string(encoded), `"value":true`) {
|
||||
t.Fatalf("property types or precision lost: %s, %v", encoded, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphPathsPreserveDirectionParallelEdgesAndSelfLoops(t *testing.T) {
|
||||
a := neo4j.Node{ElementId: "a", Labels: []string{"Person"}}
|
||||
b := neo4j.Node{ElementId: "b"}
|
||||
reverse := neo4j.Relationship{ElementId: "r1", StartElementId: "b", EndElementId: "a", Type: "KNOWS"}
|
||||
parallel := reverse
|
||||
parallel.ElementId = "r2"
|
||||
loop := neo4j.Relationship{ElementId: "a", StartElementId: "a", EndElementId: "a", Type: "SELF"}
|
||||
path := neo4j.Path{Nodes: []neo4j.Node{a, b}, Relationships: []neo4j.Relationship{reverse, parallel, loop}}
|
||||
cell := normalizeGraphQueryValue(map[string]any{"paths": []any{path}}, false).(*graphCell)
|
||||
if len(cell.DisplayParts) == 0 {
|
||||
t.Fatal("nested graph display references were lost")
|
||||
}
|
||||
if len(cell.Edges) != 3 || cell.Edges[0].Source != graphPlaceholder("b", 0, false).ID || cell.Edges[0].Target != graphPlaceholder("a", 0, false).ID {
|
||||
t.Fatalf("relationship direction lost: %#v", cell.Edges)
|
||||
}
|
||||
if cell.Edges[0].ID == cell.Edges[1].ID || cell.Edges[2].ID == cell.Nodes[0].ID || cell.Edges[2].Source != cell.Edges[2].Target {
|
||||
t.Fatal("parallel relationships, node IDs or self-loops collided")
|
||||
}
|
||||
if got := normalizeGraphQueryValue([]any{int64(1), "text"}, false); got != `[1,"text"]` {
|
||||
t.Fatalf("scalar collection changed: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphBinaryPropertyRetainsItsJSONRepresentation(t *testing.T) {
|
||||
for _, bytes := range [][]byte{{}, {0, 34, 92, 128, 255}, []byte(`{"looks":"like JSON"}`)} {
|
||||
node := neo4j.Node{ElementId: "binary", Labels: []string{"Sample"}, Props: map[string]any{"bytes": bytes, "name": "before"}}
|
||||
cell := normalizeGraphQueryValue(node, false).(*graphCell)
|
||||
property := cell.Nodes[0].Properties[0]
|
||||
value, ok := property.Value.(string)
|
||||
if !ok || !json.Valid([]byte(value)) || value != formatJSONValue(bytes) {
|
||||
t.Fatalf("binary property cannot be reconstructed: %#v, want %s", property, formatJSONValue(bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ type connectionRuntime struct {
|
||||
// legacySingleDatabase is set for servers that negotiated Bolt 3 or older:
|
||||
// they have a single default database and reject session database selection.
|
||||
legacySingleDatabase bool
|
||||
legacyGraphIDs bool
|
||||
references int
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -13,8 +13,8 @@ func TestNodeResultKeepsSourceWidthAndExactPropertyValues(t *testing.T) {
|
||||
node := neo4j.Node{Labels: []string{"Person"}, Props: map[string]any{
|
||||
"name": "Ada", "age": int64(9007199254740993), "active": true, "scores": []any{int64(1), int64(2)},
|
||||
}}
|
||||
row := normalizeRecord(&neo4j.Record{Values: []any{node, int64(7), nil}}, 3)
|
||||
cell, ok := row[0].(nodeCell)
|
||||
row := normalizeRecord(&neo4j.Record{Values: []any{node, int64(7), nil}}, 3, false)
|
||||
cell, ok := row[0].(*graphCell)
|
||||
if !ok || len(row) != 3 || row[1] != "7" || row[2] != nil {
|
||||
t.Fatalf("unexpected record: %#v", row)
|
||||
}
|
||||
@@ -24,11 +24,11 @@ func TestNodeResultKeepsSourceWidthAndExactPropertyValues(t *testing.T) {
|
||||
{Name: "name", Type: "String", Value: "Ada"},
|
||||
{Name: "scores", Type: "List", Value: "[1,2]"},
|
||||
}
|
||||
if cell.Marker != "v1" || cell.Display != formatNode(node) || !reflect.DeepEqual(cell.Properties, want) {
|
||||
if cell.nodeCell == nil || cell.nodeCell.Marker != "v1" || cell.Display != formatNode(node) || !reflect.DeepEqual(cell.nodeCell.Properties, want) {
|
||||
t.Fatalf("unexpected node cell: %#v", cell)
|
||||
}
|
||||
encoded, err := json.Marshal(row)
|
||||
if err != nil || !strings.Contains(string(encoded), `"value":"9007199254740993"`) {
|
||||
if err != nil || !strings.Contains(string(encoded), `"value":"9007199254740993"`) || !strings.Contains(string(encoded), `"__dbx_neo4j_node":"v1"`) || !strings.Contains(string(encoded), `"__dbx_graph_cell":"neo4j-v1"`) {
|
||||
t.Fatalf("inexact wire value: %s, err=%v", encoded, err)
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,16 @@ func TestEmptyNodeAndNonNodeValues(t *testing.T) {
|
||||
for _, value := range []any{nil, "(:Person {\"name\":\"literal\"})", true, int64(42),
|
||||
neo4j.Relationship{Type: "KNOWS"}, neo4j.Path{Nodes: []neo4j.Node{node}},
|
||||
[]any{node}, map[string]any{"node": node}} {
|
||||
row := normalizeRecord(&neo4j.Record{Values: []any{value}}, 1)
|
||||
if !reflect.DeepEqual(row[0], normalizeQueryValue(value)) {
|
||||
t.Fatalf("non-node result changed for %T: %#v", value, row)
|
||||
row := normalizeRecord(&neo4j.Record{Values: []any{value}}, 1, false)
|
||||
display := row[0]
|
||||
if cell, ok := display.(*graphCell); ok {
|
||||
if cell.nodeCell != nil {
|
||||
t.Fatalf("non-node result has node table metadata for %T: %#v", value, cell)
|
||||
}
|
||||
display = cell.Display
|
||||
}
|
||||
if !reflect.DeepEqual(display, normalizeQueryValue(value)) {
|
||||
t.Fatalf("non-node display changed for %T: %#v", value, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func (s *server) executeQuery(options queryOptions) (queryResult, error) {
|
||||
return queryResult{}, err
|
||||
}
|
||||
maxRows := effectiveMaxRows(options.MaxRows)
|
||||
rows, columns, columnTypes, truncated, err := readResultPage(ctx, result, maxRows)
|
||||
rows, columns, columnTypes, truncated, err := readResultPage(ctx, result, maxRows, s.runtime.legacyGraphIDs)
|
||||
if err != nil {
|
||||
return queryResult{}, err
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (s *server) executeQueryPage(options queryOptions, pageSize int) (queryPage
|
||||
s.endOperation(cancel)
|
||||
return queryPageResult{}, err
|
||||
}
|
||||
rows, columns, columnTypes, hasMore, err := readResultPage(ctx, result, pageSize)
|
||||
rows, columns, columnTypes, hasMore, err := readResultPage(ctx, result, pageSize, s.runtime.legacyGraphIDs)
|
||||
if err != nil {
|
||||
_ = session.Close(ctx)
|
||||
s.endOperation(cancel)
|
||||
@@ -103,7 +103,7 @@ func (s *server) fetchQueryPage(id string, pageSize int) (queryPageResult, error
|
||||
if pageSize > query.remaining {
|
||||
pageSize = query.remaining
|
||||
}
|
||||
rows, _, _, hasMore, err := readResultPage(query.ctx, query.result, pageSize)
|
||||
rows, _, _, hasMore, err := readResultPage(query.ctx, query.result, pageSize, s.runtime.legacyGraphIDs)
|
||||
if err != nil {
|
||||
s.closeQuerySession(id)
|
||||
return queryPageResult{}, err
|
||||
@@ -202,7 +202,7 @@ func (s *server) executeBatch(params map[string]json.RawMessage) (queryResult, e
|
||||
return emptyQueryResult(time.Since(started)), nil
|
||||
}
|
||||
|
||||
func readResultPage(ctx context.Context, result neo4j.Result, limit int) ([][]any, []string, []string, bool, error) {
|
||||
func readResultPage(ctx context.Context, result neo4j.Result, limit int, legacyGraphIDs bool) ([][]any, []string, []string, bool, error) {
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func readResultPage(ctx context.Context, result neo4j.Result, limit int) ([][]an
|
||||
} else {
|
||||
refineColumnTypes(columnTypes, record)
|
||||
}
|
||||
rows = append(rows, normalizeRecord(record, len(columns)))
|
||||
rows = append(rows, normalizeRecord(record, len(columns), legacyGraphIDs))
|
||||
}
|
||||
if err := result.Err(); err != nil {
|
||||
return nil, nil, nil, false, err
|
||||
@@ -237,17 +237,13 @@ func readResultPage(ctx context.Context, result neo4j.Result, limit int) ([][]an
|
||||
return rows, columns, columnTypes, hasMore, nil
|
||||
}
|
||||
|
||||
func normalizeRecord(record *neo4j.Record, width int) []any {
|
||||
func normalizeRecord(record *neo4j.Record, width int, legacyGraphIDs bool) []any {
|
||||
row := make([]any, width)
|
||||
if record == nil {
|
||||
return row
|
||||
}
|
||||
for index := 0; index < width && index < len(record.Values); index++ {
|
||||
if node, ok := record.Values[index].(neo4j.Node); ok {
|
||||
row[index] = normalizeNodeCell(node)
|
||||
} else {
|
||||
row[index] = normalizeQueryValue(record.Values[index])
|
||||
}
|
||||
row[index] = normalizeGraphQueryValue(record.Values[index], legacyGraphIDs)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.oceanbase:oceanbase-client:2.4.18'
|
||||
testImplementation 'com.google.code.gson:gson:2.12.1'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
+91
-7
@@ -10,6 +10,7 @@ import com.dbx.agent.CompletionAssistantRequest;
|
||||
import com.dbx.agent.CompletionAssistantResponse;
|
||||
import com.dbx.agent.ConfiguredJdbcAgent;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.ConstraintInfo;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.DdlBuilder;
|
||||
import com.dbx.agent.ExecuteQueryOptions;
|
||||
@@ -298,13 +299,15 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
return List.of();
|
||||
}
|
||||
String baseSql = """
|
||||
SELECT OBJECT_NAME, OBJECT_TYPE
|
||||
FROM ALL_OBJECTS
|
||||
WHERE OWNER = ? AND OBJECT_TYPE IN (%s)
|
||||
SELECT o.OBJECT_NAME, o.OBJECT_TYPE, c.COMMENTS
|
||||
FROM ALL_OBJECTS o
|
||||
LEFT JOIN ALL_TAB_COMMENTS c ON c.OWNER = o.OWNER AND c.TABLE_NAME = o.OBJECT_NAME
|
||||
AND o.OBJECT_TYPE IN ('TABLE', 'VIEW')
|
||||
WHERE o.OWNER = ? AND o.OBJECT_TYPE IN (%s)
|
||||
""".stripIndent().trim();
|
||||
MetadataSql query = oceanBaseMetadataSql(
|
||||
String.format(baseSql, placeholders(objectTypes.size())),
|
||||
"OBJECT_NAME, OBJECT_TYPE",
|
||||
"OBJECT_NAME, OBJECT_TYPE, COMMENTS",
|
||||
"OBJECT_NAME",
|
||||
"""
|
||||
ORDER BY CASE OBJECT_TYPE
|
||||
@@ -334,7 +337,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
while (rs.next()) {
|
||||
String objectType = rs.getString(2);
|
||||
result.add(new ObjectInfo(rs.getString(1),
|
||||
"PACKAGE BODY".equals(objectType) ? "PACKAGE_BODY" : objectType, owner, null));
|
||||
"PACKAGE BODY".equals(objectType) ? "PACKAGE_BODY" : objectType, owner, rs.getString(3)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,8 +683,11 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
args.addAll(objectTypes);
|
||||
String sql = baseSql;
|
||||
if (constraints.hasFilter()) {
|
||||
sql += " AND UPPER(" + nameColumn + ") LIKE ? ESCAPE '\\'";
|
||||
args.add(constraints.fuzzyLikePattern().toUpperCase(Locale.ROOT));
|
||||
sql += " AND (UPPER(" + nameColumn + ") LIKE ? ESCAPE '\\'"
|
||||
+ " OR UPPER(c.COMMENTS) LIKE ? ESCAPE '\\')";
|
||||
String pattern = constraints.fuzzyLikePattern().toUpperCase(Locale.ROOT);
|
||||
args.add(pattern);
|
||||
args.add(pattern);
|
||||
}
|
||||
sql += "\n" + orderSql;
|
||||
if (constraints.hasLimit()) {
|
||||
@@ -1317,6 +1323,84 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ConstraintInfo> listConstraints(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
String owner = normalizeSchema(schema);
|
||||
String tableName = normalizeObjectName(table);
|
||||
String sql = """
|
||||
SELECT c.CONSTRAINT_NAME, c.CONSTRAINT_TYPE, c.SEARCH_CONDITION,
|
||||
c.STATUS, c.VALIDATED, c.DEFERRABLE, c.DEFERRED, c.GENERATED,
|
||||
cc.COLUMN_NAME, tc.NULLABLE
|
||||
FROM ALL_TABLES t
|
||||
LEFT JOIN ALL_CONSTRAINTS c ON c.OWNER = t.OWNER AND c.TABLE_NAME = t.TABLE_NAME
|
||||
AND c.CONSTRAINT_TYPE IN ('P', 'U', 'C')
|
||||
LEFT JOIN ALL_CONS_COLUMNS cc ON cc.OWNER = c.OWNER
|
||||
AND cc.TABLE_NAME = c.TABLE_NAME AND cc.CONSTRAINT_NAME = c.CONSTRAINT_NAME
|
||||
LEFT JOIN ALL_TAB_COLUMNS tc ON tc.OWNER = t.OWNER
|
||||
AND tc.TABLE_NAME = t.TABLE_NAME AND tc.COLUMN_NAME = cc.COLUMN_NAME
|
||||
WHERE t.OWNER = ? AND t.TABLE_NAME = ?
|
||||
ORDER BY c.CONSTRAINT_NAME, cc.POSITION
|
||||
""".stripIndent().trim();
|
||||
Map<String, ConstraintInfo> result = new LinkedHashMap<>();
|
||||
try (var stmt = requireConnection().prepareStatement(sql)) {
|
||||
stmt.setString(1, owner);
|
||||
stmt.setString(2, tableName);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
boolean visible = false;
|
||||
while (rs.next()) {
|
||||
visible = true;
|
||||
String name = rs.getString("CONSTRAINT_NAME");
|
||||
if (name == null) continue;
|
||||
String column = rs.getString("COLUMN_NAME");
|
||||
String condition = rs.getString("SEARCH_CONDITION");
|
||||
// NOT NULL already appears in the columns tab, as for native Oracle.
|
||||
if ("C".equals(rs.getString("CONSTRAINT_TYPE"))
|
||||
&& "GENERATED NAME".equals(rs.getString("GENERATED"))
|
||||
&& "N".equals(rs.getString("NULLABLE"))
|
||||
&& column != null && condition != null
|
||||
&& condition.matches("\\s*" + Pattern.quote(quoteIdentifier(column))
|
||||
+ "\\s+(?i:IS\\s+NOT\\s+NULL)\\s*")) continue;
|
||||
ConstraintInfo constraint = result.get(name);
|
||||
if (constraint == null) {
|
||||
String type = switch (rs.getString("CONSTRAINT_TYPE")) {
|
||||
case "P" -> "PRIMARY KEY";
|
||||
case "U" -> "UNIQUE";
|
||||
default -> "CHECK";
|
||||
};
|
||||
constraint = new ConstraintInfo(name, type, condition,
|
||||
new ArrayList<>(),
|
||||
constraintState(rs.getString("DEFERRABLE"), "DEFERRABLE", "NOT DEFERRABLE"),
|
||||
constraintState(rs.getString("DEFERRED"), "DEFERRED", "IMMEDIATE"),
|
||||
constraintState(rs.getString("STATUS"), "ENABLED", "DISABLED"),
|
||||
constraintState(rs.getString("VALIDATED"), "VALIDATED", "NOT VALIDATED"));
|
||||
result.put(name, constraint);
|
||||
}
|
||||
if (column != null) constraint.columns().add(column);
|
||||
}
|
||||
if (!visible) throw new SQLException("Table does not exist or is not accessible", "42000");
|
||||
}
|
||||
}
|
||||
return result.values().stream().map(constraint -> {
|
||||
String definition = constraint.definition();
|
||||
if (definition == null) definition = "";
|
||||
if (!constraint.constraint_type().equals("CHECK")) {
|
||||
definition = constraint.constraint_type() + " (" + String.join(", ",
|
||||
constraint.columns().stream().map(OceanBaseOracleAgent::quoteIdentifier).toList()) + ")";
|
||||
}
|
||||
return new ConstraintInfo(constraint.name(), constraint.constraint_type(), definition,
|
||||
constraint.columns(), constraint.deferrable(), constraint.initially_deferred(),
|
||||
constraint.enabled(), constraint.valid());
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
private static Boolean constraintState(String value, String yes, String no) {
|
||||
if (yes.equals(value)) return true;
|
||||
if (no.equals(value)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TriggerInfo> listTriggers(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import com.dbx.agent.oceanbaseoracle.OceanBaseOracleAgent;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OceanBaseOracleConstraintRpcTest {
|
||||
@Test
|
||||
void returnsCompositePrimaryKeyThroughProductionRpc() {
|
||||
Fixture jdbc = new Fixture(List.of(
|
||||
row("PK Orders", "P", null, "Second", "ENABLED", "VALIDATED"),
|
||||
row("PK Orders", "P", null, "First\"Id", "ENABLED", "VALIDATED")
|
||||
));
|
||||
|
||||
JsonObject response = jdbc.request("Mixed.Owner", "Orders");
|
||||
|
||||
assertFalse(response.has("error"), response.toString());
|
||||
var constraints = response.getAsJsonArray("result");
|
||||
assertEquals(1, constraints.size());
|
||||
JsonObject key = constraints.get(0).getAsJsonObject();
|
||||
assertEquals("PK Orders", key.get("name").getAsString());
|
||||
assertEquals("PRIMARY KEY", key.get("constraint_type").getAsString());
|
||||
assertEquals(List.of("Second", "First\"Id"), key.getAsJsonArray("columns").asList()
|
||||
.stream().map(value -> value.getAsString()).toList());
|
||||
assertEquals("PRIMARY KEY (\"Second\", \"First\"\"Id\")", key.get("definition").getAsString());
|
||||
assertTrue(key.get("enabled").getAsBoolean());
|
||||
assertTrue(key.get("valid").getAsBoolean());
|
||||
assertEquals(List.of("Mixed.Owner", "Orders"), jdbc.parameters);
|
||||
assertTrue(jdbc.closed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesMultilineChecksAndReportsUnknownStatesWithoutInventingValues() {
|
||||
String expression = "\"Amount\" > 0\nAND \"Note\" <> 'line\n two'";
|
||||
Map<String, String> unknown = row("CK_UNKNOWN", "C", null, null, null, "UNRECOGNIZED");
|
||||
unknown.put("DEFERRABLE", null);
|
||||
unknown.put("DEFERRED", null);
|
||||
Fixture jdbc = new Fixture(List.of(
|
||||
row("CK_MULTI", "C", expression, "Amount", "DISABLED", "NOT VALIDATED"),
|
||||
row("CK_MULTI", "C", expression, "Note", "DISABLED", "NOT VALIDATED"), unknown,
|
||||
row("UQ_CODE", "U", null, "Code", "ENABLED", "NOT VALIDATED")
|
||||
));
|
||||
|
||||
JsonObject response = jdbc.request("APP", "ORDERS");
|
||||
|
||||
assertFalse(response.has("error"), response.toString());
|
||||
var constraints = response.getAsJsonArray("result");
|
||||
assertEquals(3, constraints.size());
|
||||
var check = constraints.get(0).getAsJsonObject();
|
||||
assertEquals(expression, check.get("definition").getAsString());
|
||||
assertFalse(check.get("enabled").getAsBoolean());
|
||||
assertFalse(check.get("valid").getAsBoolean());
|
||||
var unavailable = constraints.get(1).getAsJsonObject();
|
||||
assertEquals("", unavailable.get("definition").getAsString());
|
||||
for (String state : List.of("enabled", "valid", "deferrable", "initially_deferred")) {
|
||||
assertTrue(!unavailable.has(state) || unavailable.get(state).isJsonNull(), unavailable.toString());
|
||||
}
|
||||
assertEquals("UNIQUE (\"Code\")", constraints.get(2).getAsJsonObject().get("definition").getAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyVisibleTableIsDifferentFromInaccessibleTableOrQueryFailure() {
|
||||
Fixture empty = new Fixture(List.of(Map.of()));
|
||||
assertEquals(0, empty.request("APP", "EMPTY").getAsJsonArray("result").size());
|
||||
JsonObject hidden = new Fixture(List.of()).request("OTHER", "EMPTY");
|
||||
assertFalse(hidden.has("result"));
|
||||
assertTrue(hidden.getAsJsonObject("error").get("message").getAsString().contains("not accessible"));
|
||||
Fixture denied = new Fixture(List.of());
|
||||
denied.failure = new SQLException("ORA-01031: insufficient privileges", "42000", 1031);
|
||||
JsonObject failure = denied.request("APP", "EMPTY");
|
||||
assertFalse(failure.has("result"));
|
||||
assertTrue(failure.getAsJsonObject("error").get("message").getAsString().contains("ORA-01031"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesOnlyConfirmedSystemNotNullChecks() {
|
||||
Map<String, String> notNull = row("SYS_C01", "C", "\"Required\"\"Id\" IS NOT NULL",
|
||||
"Required\"Id", "ENABLED", "VALIDATED");
|
||||
notNull.put("GENERATED", "GENERATED NAME");
|
||||
notNull.put("NULLABLE", "N");
|
||||
Map<String, String> namedCheck = new java.util.HashMap<>(notNull);
|
||||
namedCheck.put("CONSTRAINT_NAME", "NAMED_NOT_NULL");
|
||||
namedCheck.put("GENERATED", "USER NAME");
|
||||
Map<String, String> userCheck = new java.util.HashMap<>(notNull);
|
||||
userCheck.put("CONSTRAINT_NAME", "SYS_C02");
|
||||
userCheck.put("SEARCH_CONDITION", "\"Required\"\"Id\" > 0");
|
||||
|
||||
var constraints = new Fixture(List.of(notNull, namedCheck, userCheck))
|
||||
.request("APP", "ORDERS").getAsJsonArray("result");
|
||||
|
||||
assertEquals(List.of("NAMED_NOT_NULL", "SYS_C02"), constraints.asList().stream()
|
||||
.map(value -> value.getAsJsonObject().get("name").getAsString()).toList());
|
||||
}
|
||||
|
||||
private static Map<String, String> row(String name, String kind, String condition,
|
||||
String column, String status, String validated) {
|
||||
Map<String, String> row = new java.util.HashMap<>();
|
||||
row.put("CONSTRAINT_NAME", name);
|
||||
row.put("CONSTRAINT_TYPE", kind);
|
||||
row.put("SEARCH_CONDITION", condition);
|
||||
row.put("COLUMN_NAME", column);
|
||||
row.put("STATUS", status);
|
||||
row.put("VALIDATED", validated);
|
||||
row.put("DEFERRABLE", "NOT DEFERRABLE");
|
||||
row.put("DEFERRED", "IMMEDIATE");
|
||||
return row;
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final List<String> parameters = new ArrayList<>();
|
||||
final JsonRpcServer server;
|
||||
boolean closed;
|
||||
SQLException failure;
|
||||
|
||||
Fixture(List<Map<String, String>> rows) {
|
||||
int[] index = {-1};
|
||||
ResultSet result = (ResultSet) Proxy.newProxyInstance(getClass().getClassLoader(),
|
||||
new Class<?>[]{ResultSet.class}, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "next" -> ++index[0] < rows.size();
|
||||
case "getString" -> rows.get(index[0]).get((String) args[0]);
|
||||
case "close" -> { closed = true; yield null; }
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
PreparedStatement statement = (PreparedStatement) Proxy.newProxyInstance(getClass().getClassLoader(),
|
||||
new Class<?>[]{PreparedStatement.class}, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "setString" -> { parameters.add((String) args[1]); yield null; }
|
||||
case "executeQuery" -> { if (failure != null) throw failure; yield result; }
|
||||
case "close" -> null;
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
Connection connection = (Connection) Proxy.newProxyInstance(getClass().getClassLoader(),
|
||||
new Class<?>[]{Connection.class}, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "prepareStatement" -> statement;
|
||||
case "isClosed" -> false;
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, connection);
|
||||
server = new JsonRpcServer(agent);
|
||||
}
|
||||
|
||||
JsonObject request(String schema, String table) {
|
||||
JsonObject params = new JsonObject();
|
||||
params.addProperty("schema", schema);
|
||||
params.addProperty("table", table);
|
||||
JsonObject request = new JsonObject();
|
||||
request.addProperty("id", 1);
|
||||
request.addProperty("method", "list_constraints");
|
||||
request.add("params", params);
|
||||
return JsonParser.parseString(server.handleRequest(request.toString())).getAsJsonObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -430,9 +430,9 @@ class OceanBaseOracleAgentTest {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
|
||||
new String[]{"OBJECT_NAME", "OBJECT_TYPE"},
|
||||
new String[]{"OBJECT_NAME", "OBJECT_TYPE", "COMMENTS"},
|
||||
new Object[][]{
|
||||
{"FORMAT_USER", "FUNCTION"}
|
||||
{"FORMAT_USER", "FUNCTION", null}
|
||||
}
|
||||
)));
|
||||
|
||||
|
||||
+41
-2
@@ -23,6 +23,44 @@ import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OceanBaseOracleObjectListTest {
|
||||
@Test
|
||||
void tableAndViewCommentsSurviveObjectListing() {
|
||||
JdbcFixture jdbc = new JdbcFixture();
|
||||
jdbc.rows(row("T", "TABLE", "订单备注"), row("V", "VIEW", "Customer view"),
|
||||
row("EMPTY", "TABLE", ""), row("NO_COMMENT", "VIEW", null));
|
||||
|
||||
assertEquals(List.of(
|
||||
new ObjectInfo("T", "TABLE", "APP", "订单备注"),
|
||||
new ObjectInfo("V", "VIEW", "APP", "Customer view"),
|
||||
new ObjectInfo("EMPTY", "TABLE", "APP", ""),
|
||||
new ObjectInfo("NO_COMMENT", "VIEW", "APP", null)
|
||||
), jdbc.agent.listObjects("APP"));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"订单", "Customer", "'", "%", "_", "\\"})
|
||||
void commentOnlyMatchesAreBoundBeforePaging(String filter) {
|
||||
JdbcFixture jdbc = new JdbcFixture();
|
||||
JdbcCall call = jdbc.rows(row("Z_LATE", "TABLE", "prefix " + filter + " suffix"));
|
||||
MetadataListConstraints constraints = constraints(filter, 1, 2, "TABLE");
|
||||
|
||||
assertEquals(List.of(new ObjectInfo("Z_LATE", "TABLE", "Mixed.Owner", "prefix " + filter + " suffix")),
|
||||
jdbc.agent.listObjects("Mixed.Owner", constraints));
|
||||
|
||||
assertTrue(call.sql.contains("OR UPPER(c.COMMENTS) LIKE ? ESCAPE '\\'"), call.sql);
|
||||
assertTrue(call.sql.indexOf("UPPER(c.COMMENTS)") < call.sql.indexOf("WHERE ROWNUM <= ?"), call.sql);
|
||||
String pattern = switch (filter) {
|
||||
case "订单" -> "%订%单%";
|
||||
case "Customer" -> "%C%U%S%T%O%M%E%R%";
|
||||
case "'" -> "%'%";
|
||||
case "%" -> "%\\%%";
|
||||
case "_" -> "%\\_%";
|
||||
default -> "%\\\\%";
|
||||
};
|
||||
assertEquals(List.of("Mixed.Owner", "TABLE", pattern, pattern, 3, 2), call.args);
|
||||
call.assertClosed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultListsIncludeBothPackageTypesAndKeepExistingTypeOrder() {
|
||||
JdbcFixture jdbc = new JdbcFixture();
|
||||
@@ -105,7 +143,7 @@ class OceanBaseOracleObjectListTest {
|
||||
pages.addAll(jdbc.agent.listObjects("APP",
|
||||
constraints("k_'%", 1, offset, "PACKAGE", "PACKAGE_BODY")));
|
||||
|
||||
assertEquals(List.of("APP", "PACKAGE", "PACKAGE BODY", "%K%\\_%'%\\%%", offset + 1, offset), call.args);
|
||||
assertEquals(List.of("APP", "PACKAGE", "PACKAGE BODY", "%K%\\_%'%\\%%", "%K%\\_%'%\\%%", offset + 1, offset), call.args);
|
||||
assertTrue(call.sql.contains("UPPER(OBJECT_NAME) LIKE ? ESCAPE '\\'"), call.sql);
|
||||
assertTrue(call.sql.contains("ROWNUM <= ?"), call.sql);
|
||||
assertTrue(call.sql.contains("WHERE DBX_RN > ?"), call.sql);
|
||||
@@ -355,7 +393,8 @@ class OceanBaseOracleObjectListTest {
|
||||
}
|
||||
yield cursor[0] < rows.length;
|
||||
}
|
||||
case "getString" -> rows[cursor[0]][(Integer) values[0] - 1];
|
||||
case "getString" -> (Integer) values[0] <= rows[cursor[0]].length
|
||||
? rows[cursor[0]][(Integer) values[0] - 1] : null;
|
||||
case "close" -> {
|
||||
resultClosed = true;
|
||||
yield null;
|
||||
|
||||
@@ -1075,6 +1075,9 @@ func oracleServerMajorVersionFromDBConn(ctx context.Context, db *sql.DB) (int, b
|
||||
}
|
||||
|
||||
func oracleServerMajorVersionFromDriverConn(driverConn any) (int, bool) {
|
||||
if guarded, ok := driverConn.(*oracleQueryConnection); ok {
|
||||
driverConn = guarded.oracleThinConnection
|
||||
}
|
||||
conn, ok := driverConn.(*go_ora.Connection)
|
||||
if !ok {
|
||||
return 0, false
|
||||
@@ -1276,7 +1279,7 @@ func openDBWithStringConverter(params connectParams, stringConverter converters.
|
||||
if stringConverter != nil {
|
||||
go_ora.SetStringConverter(connector, stringConverter, nil)
|
||||
}
|
||||
db := sql.OpenDB(connector)
|
||||
db := sql.OpenDB(oracleQueryConnector{connector})
|
||||
db.SetMaxOpenConns(4)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
@@ -3671,7 +3674,13 @@ func (s *server) getExplainInfo(sqlText, database, schema string, timeoutSecs in
|
||||
}
|
||||
|
||||
statementID := "DBX_" + strings.ToUpper(strconv.FormatInt(time.Now().UnixNano(), 36))
|
||||
defer cleanupOracleExplainPlan(conn, statementID)
|
||||
// EXPLAIN PLAN resolves its plan table in the session user's schema, while a bare
|
||||
// PLAN_TABLE inside DBMS_XPLAN.DISPLAY resolves through CURRENT_SCHEMA - and the
|
||||
// schema switch above changes CURRENT_SCHEMA. Name the table explicitly so the read
|
||||
// and the cleanup hit the same object EXPLAIN PLAN wrote to, otherwise DISPLAY answers
|
||||
// "cannot fetch plan for statement_id '...'".
|
||||
planTable := oracleExplainPlanTableName(ctx, conn)
|
||||
defer cleanupOracleExplainPlan(conn, statementID, planTable)
|
||||
statementSQL := trimStatementSQL(sqlText)
|
||||
explainArgs := oracleExplainPlanBindArgs(statementSQL)
|
||||
if _, err := conn.ExecContext(ctx, "EXPLAIN PLAN SET STATEMENT_ID = '"+statementID+"' FOR "+statementSQL, explainArgs...); err != nil {
|
||||
@@ -3679,7 +3688,7 @@ func (s *server) getExplainInfo(sqlText, database, schema string, timeoutSecs in
|
||||
}
|
||||
planRows, err := conn.QueryContext(
|
||||
ctx,
|
||||
"SELECT PLAN_TABLE_OUTPUT FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', :1, 'TYPICAL +PREDICATE'))",
|
||||
"SELECT PLAN_TABLE_OUTPUT FROM TABLE(DBMS_XPLAN.DISPLAY('"+planTable+"', :1, 'TYPICAL +PREDICATE'))",
|
||||
statementID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -3709,10 +3718,40 @@ func oracleExplainTargetSchema(database, schema, configuredDatabase string) stri
|
||||
return database
|
||||
}
|
||||
|
||||
func cleanupOracleExplainPlan(conn *sql.Conn, statementID string) {
|
||||
// oracleExplainPlanTableName returns the explicitly qualified name of the plan table that
|
||||
// EXPLAIN PLAN resolves for this session: the session user's own object named PLAN_TABLE when
|
||||
// it exists, otherwise the table behind the PUBLIC synonym (usually SYS.PLAN_TABLE$). Falls
|
||||
// back to the bare name when neither can be resolved.
|
||||
func oracleExplainPlanTableName(ctx context.Context, conn *sql.Conn) string {
|
||||
const probe = `SELECT
|
||||
(SELECT OWNER FROM ALL_OBJECTS WHERE OWNER = USER AND OBJECT_NAME = 'PLAN_TABLE' AND ROWNUM = 1),
|
||||
(SELECT OBJECT_NAME FROM ALL_OBJECTS WHERE OWNER = USER AND OBJECT_NAME = 'PLAN_TABLE' AND ROWNUM = 1),
|
||||
(SELECT TABLE_OWNER FROM ALL_SYNONYMS WHERE OWNER = 'PUBLIC' AND SYNONYM_NAME = 'PLAN_TABLE' AND ROWNUM = 1),
|
||||
(SELECT TABLE_NAME FROM ALL_SYNONYMS WHERE OWNER = 'PUBLIC' AND SYNONYM_NAME = 'PLAN_TABLE' AND ROWNUM = 1)
|
||||
FROM DUAL`
|
||||
var sessionOwner, sessionName, publicOwner, publicName sql.NullString
|
||||
if err := conn.QueryRowContext(ctx, probe).Scan(&sessionOwner, &sessionName, &publicOwner, &publicName); err != nil {
|
||||
return "PLAN_TABLE"
|
||||
}
|
||||
return oraclePlanTableName(sessionOwner.String, sessionName.String, publicOwner.String, publicName.String)
|
||||
}
|
||||
|
||||
// oraclePlanTableName picks the qualified plan table name from the probe results, preferring
|
||||
// the session user's own PLAN_TABLE because that is what EXPLAIN PLAN writes to.
|
||||
func oraclePlanTableName(sessionOwner, sessionName, publicOwner, publicName string) string {
|
||||
if strings.TrimSpace(sessionOwner) != "" && strings.TrimSpace(sessionName) != "" {
|
||||
return quoteIdentifier(strings.TrimSpace(sessionOwner)) + "." + quoteIdentifier(strings.TrimSpace(sessionName))
|
||||
}
|
||||
if strings.TrimSpace(publicOwner) != "" && strings.TrimSpace(publicName) != "" {
|
||||
return quoteIdentifier(strings.TrimSpace(publicOwner)) + "." + quoteIdentifier(strings.TrimSpace(publicName))
|
||||
}
|
||||
return "PLAN_TABLE"
|
||||
}
|
||||
|
||||
func cleanupOracleExplainPlan(conn *sql.Conn, statementID, planTable string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _ = conn.ExecContext(ctx, "DELETE FROM PLAN_TABLE WHERE STATEMENT_ID = :1", statementID)
|
||||
_, _ = conn.ExecContext(ctx, "DELETE FROM "+planTable+" WHERE STATEMENT_ID = :1", statementID)
|
||||
}
|
||||
|
||||
type oracleBindParam struct {
|
||||
@@ -3892,11 +3931,16 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes
|
||||
// projection so the remaining columns stay readable; panics on later pages
|
||||
// surface as errors instead.
|
||||
func (s *server) runPagedOracleSelect(sqlText string, opts queryOptions, pageSize int, start time.Time) (queryPageResult, *querySession, error) {
|
||||
// Until a cursor is handed to the caller, this function owns its rows,
|
||||
// including panics while reading Columns/ColumnTypes before the first page.
|
||||
var ownedRows *sql.Rows
|
||||
defer func() { s.closeRows(ownedRows) }()
|
||||
for attempt := 0; ; attempt++ {
|
||||
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded(sqlText, opts.TimeoutSecs, opts.DeferLOBs)
|
||||
if err != nil {
|
||||
return queryPageResult{}, nil, err
|
||||
}
|
||||
ownedRows = rows
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
s.closeRows(rows)
|
||||
@@ -3913,7 +3957,7 @@ func (s *server) runPagedOracleSelect(sqlText string, opts queryOptions, pageSiz
|
||||
if err != nil {
|
||||
s.closeRows(rows)
|
||||
var panicErr oracleDriverPanicError
|
||||
if attempt == 0 && !oracleSQLLocksRows(sqlText) && errors.As(err, &panicErr) {
|
||||
if attempt == 0 && !s.hasManualTransaction() && !oracleSQLLocksRows(sqlText) && errors.As(err, &panicErr) {
|
||||
if placeholder, ok := oraclePlaceholderRetrySQL(sqlText, s.loadOracleColumnMeta); ok {
|
||||
sqlText = placeholder
|
||||
continue
|
||||
@@ -3922,6 +3966,7 @@ func (s *server) runPagedOracleSelect(sqlText string, opts queryOptions, pageSiz
|
||||
return queryPageResult{}, nil, err
|
||||
}
|
||||
if result.HasMore {
|
||||
ownedRows = nil
|
||||
return result, session, nil
|
||||
}
|
||||
s.closeRows(rows)
|
||||
@@ -4257,7 +4302,7 @@ func columnTypeNames(rows *sql.Rows) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *server) queryRowsWithOracleValueRewriteIfNeeded(sqlText string, timeoutSecs int, deferLOBs bool) (*sql.Rows, error) {
|
||||
func (s *server) queryRowsWithOracleValueRewriteIfNeeded(sqlText string, timeoutSecs int, deferLOBs bool) (resultRows *sql.Rows, queryErr error) {
|
||||
// A statement that keeps row locks while it runs is rewritten before the
|
||||
// first attempt: when such a statement panics after the rows were locked,
|
||||
// the locks stay behind and the retry below blocks on them until the query
|
||||
@@ -4281,7 +4326,7 @@ func (s *server) queryRowsWithOracleValueRewriteIfNeeded(sqlText string, timeout
|
||||
// worth retrying through the value-rewrite fallback below; other
|
||||
// failures keep their own error.
|
||||
var panicErr oracleDriverPanicError
|
||||
if !errors.As(rewrittenErr, &panicErr) {
|
||||
if s.hasManualTransaction() || !errors.As(rewrittenErr, &panicErr) {
|
||||
return nil, rewrittenErr
|
||||
}
|
||||
}
|
||||
@@ -4289,7 +4334,7 @@ func (s *server) queryRowsWithOracleValueRewriteIfNeeded(sqlText string, timeout
|
||||
rows, err := s.queryRowsWithTimeout(sqlText, nil, timeoutSecs)
|
||||
if err != nil {
|
||||
var panicErr oracleDriverPanicError
|
||||
if errors.As(err, &panicErr) {
|
||||
if !s.hasManualTransaction() && errors.As(err, &panicErr) {
|
||||
rewritten, rewriteErr := rewriteOracleSelectSQL(sqlText, s.loadOracleColumnMeta, false)
|
||||
if rewriteErr == nil && rewritten != sqlText {
|
||||
rewrittenRows, rewrittenErr := s.queryRowsWithTimeout(rewritten, nil, timeoutSecs)
|
||||
@@ -4310,6 +4355,13 @@ func (s *server) queryRowsWithOracleValueRewriteIfNeeded(sqlText string, timeout
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Metadata access can panic too. Retain ownership until rows are actually
|
||||
// returned; the caller cannot clean up rows it has not received.
|
||||
defer func() {
|
||||
if rows != resultRows {
|
||||
s.closeRows(rows)
|
||||
}
|
||||
}()
|
||||
typeNames := columnTypeNames(rows)
|
||||
if !oracleColumnTypeNamesContainXMLType(typeNames) {
|
||||
return rows, nil
|
||||
|
||||
@@ -3254,6 +3254,7 @@ type oracleViewSourceQueryStep struct {
|
||||
panicText string
|
||||
nextPanicText string
|
||||
columnsPanicText string
|
||||
columnsPanicCall int
|
||||
}
|
||||
|
||||
type oracleViewSourceDriver struct {
|
||||
@@ -3319,6 +3320,7 @@ func (c *oracleViewSourceConn) QueryContext(
|
||||
values: step.rows,
|
||||
nextPanicText: step.nextPanicText,
|
||||
columnsPanicText: step.columnsPanicText,
|
||||
columnsPanicCall: step.columnsPanicCall,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3354,10 +3356,13 @@ type oracleViewSourceRows struct {
|
||||
next int
|
||||
nextPanicText string
|
||||
columnsPanicText string
|
||||
columnsPanicCall int
|
||||
columnsCalls int
|
||||
}
|
||||
|
||||
func (r *oracleViewSourceRows) Columns() []string {
|
||||
if r.columnsPanicText != "" {
|
||||
r.columnsCalls++
|
||||
if r.columnsPanicText != "" && (r.columnsPanicCall == 0 || r.columnsPanicCall == r.columnsCalls) {
|
||||
panic(r.columnsPanicText)
|
||||
}
|
||||
return r.columns
|
||||
@@ -3561,12 +3566,7 @@ func TestOracleCursorSurvivesDeadlineWindow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOracleQueryRowsRecoversDriverPanic(t *testing.T) {
|
||||
driverName := "oracle-test-panic-" + strings.ReplaceAll(t.Name(), "/", "-")
|
||||
sql.Register(driverName, &oraclePanicDriver{})
|
||||
db, err := sql.Open(driverName, "dsn")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := sql.OpenDB(oracleQueryConnector{oracleGuardTestConnector{&oraclePanicDriver{}}})
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
s := newServer()
|
||||
@@ -3579,6 +3579,9 @@ func TestOracleQueryRowsRecoversDriverPanic(t *testing.T) {
|
||||
if !errors.As(err, &panicErr) {
|
||||
t.Fatalf("expected oracle driver panic error, got %v", err)
|
||||
}
|
||||
if stats := db.Stats(); stats.InUse != 0 {
|
||||
t.Errorf("driver panic leaked a pool connection: in_use=%d open=%d", stats.InUse, stats.OpenConnections)
|
||||
}
|
||||
s.activeCancelMu.Lock()
|
||||
defer s.activeCancelMu.Unlock()
|
||||
if s.activeCancel != nil || s.activeTimer != nil || len(s.activeRows) != 0 {
|
||||
@@ -3780,6 +3783,9 @@ func TestRuntimeHandleLineRecoversRequestPanic(t *testing.T) {
|
||||
if string(resp.ID) != "5" {
|
||||
t.Fatalf("panic response must keep the request id, got %s", resp.ID)
|
||||
}
|
||||
if stats := db.Stats(); stats.InUse != 0 || len(session.activeRows) != 0 {
|
||||
t.Errorf("metadata panic leaked rows/connection: in_use=%d active_rows=%d", stats.InUse, len(session.activeRows))
|
||||
}
|
||||
|
||||
// The RPC stream must survive the panic and keep serving requests.
|
||||
followUp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":6,"method":"handshake","params":{}}`)
|
||||
@@ -3981,13 +3987,8 @@ type oracleManualTxDriver struct {
|
||||
|
||||
func openOracleManualTxTestDB(t *testing.T) (*sql.DB, *oracleManualTxDriver) {
|
||||
t.Helper()
|
||||
driverName := "oracle-test-manual-tx-" + strings.ReplaceAll(t.Name(), "/", "-") + "-" + time.Now().Format("150405.000000000")
|
||||
drv := &oracleManualTxDriver{}
|
||||
sql.Register(driverName, drv)
|
||||
db, err := sql.Open(driverName, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := sql.OpenDB(oracleQueryConnector{oracleGuardTestConnector{drv}})
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
@@ -4420,3 +4421,40 @@ func TestOracleTnsAliasName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOraclePlanTableNamePrefersSessionUserTable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sessionOwner, sessionName string
|
||||
publicOwner, publicName string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "session user table shadows the public synonym",
|
||||
sessionOwner: "U_BROKEN", sessionName: "PLAN_TABLE",
|
||||
publicOwner: "SYS", publicName: "PLAN_TABLE$",
|
||||
want: `"U_BROKEN"."PLAN_TABLE"`,
|
||||
},
|
||||
{
|
||||
name: "public synonym target when the schema has no own table",
|
||||
publicOwner: "SYS", publicName: "PLAN_TABLE$",
|
||||
want: `"SYS"."PLAN_TABLE$"`,
|
||||
},
|
||||
{
|
||||
name: "bare name when nothing resolves",
|
||||
want: "PLAN_TABLE",
|
||||
},
|
||||
{
|
||||
name: "quoted mixed case object is escaped",
|
||||
sessionOwner: "user_1", sessionName: `plan"table`,
|
||||
want: `"user_1"."plan""table"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := oraclePlanTableName(tc.sessionOwner, tc.sessionName, tc.publicOwner, tc.publicName); got != tc.want {
|
||||
t.Fatalf("oraclePlanTableName() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Preserve the thin driver's optional interfaces (notably transaction options,
|
||||
// cancellation and named values) while handling query panics below database/sql.
|
||||
// Recovering above sql.DB.QueryContext skips its releaseConn call; above
|
||||
// sql.Tx.QueryContext it also leaves the transaction's read lock held.
|
||||
type oracleThinConnection interface {
|
||||
driver.Conn
|
||||
driver.ConnBeginTx
|
||||
driver.ConnPrepareContext
|
||||
driver.QueryerContext
|
||||
driver.ExecerContext
|
||||
driver.Pinger
|
||||
driver.NamedValueChecker
|
||||
driver.SessionResetter
|
||||
}
|
||||
|
||||
type oracleQueryConnector struct{ driver.Connector }
|
||||
|
||||
func (c oracleQueryConnector) Connect(ctx context.Context) (driver.Conn, error) {
|
||||
conn, err := c.Connector.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
thin, ok := conn.(oracleThinConnection)
|
||||
if !ok {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("Oracle thin connection does not implement the required context interfaces")
|
||||
}
|
||||
return &oracleQueryConnection{oracleThinConnection: thin}, nil
|
||||
}
|
||||
|
||||
type oracleQueryConnection struct {
|
||||
oracleThinConnection
|
||||
// database/sql serializes access to a driver connection, including IsValid.
|
||||
failed error
|
||||
}
|
||||
|
||||
func (c *oracleQueryConnection) unusableError() error {
|
||||
return fmt.Errorf("Oracle connection is unusable after a driver panic; roll back the transaction or reconnect: %w", c.failed)
|
||||
}
|
||||
|
||||
func (c *oracleQueryConnection) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (rows driver.Rows, err error) {
|
||||
if c.failed != nil {
|
||||
return nil, c.unusableError()
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
c.failed = oracleDriverPanicError{value: recovered}
|
||||
rows, err = nil, c.unusableError()
|
||||
}
|
||||
}()
|
||||
return c.oracleThinConnection.QueryContext(ctx, query, args)
|
||||
}
|
||||
|
||||
// Invalidate via Validator, not ErrBadConn: ErrBadConn would tell database/sql
|
||||
// to replay the original statement, even when it has already acquired locks or
|
||||
// invoked a function with side effects. The original panic remains diagnostic.
|
||||
func (c *oracleQueryConnection) IsValid() bool { return c.failed == nil }
|
||||
|
||||
func (c *oracleQueryConnection) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
|
||||
if c.failed != nil {
|
||||
return nil, c.unusableError()
|
||||
}
|
||||
return c.oracleThinConnection.ExecContext(ctx, query, args)
|
||||
}
|
||||
|
||||
func (c *oracleQueryConnection) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) {
|
||||
if c.failed != nil {
|
||||
return nil, c.unusableError()
|
||||
}
|
||||
return c.oracleThinConnection.PrepareContext(ctx, query)
|
||||
}
|
||||
|
||||
func (c *oracleQueryConnection) Prepare(query string) (driver.Stmt, error) {
|
||||
return c.PrepareContext(context.Background(), query)
|
||||
}
|
||||
|
||||
func (c *oracleQueryConnection) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
|
||||
if c.failed != nil {
|
||||
return nil, c.unusableError()
|
||||
}
|
||||
tx, err := c.oracleThinConnection.BeginTx(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &oracleQueryTransaction{Tx: tx, conn: c}, nil
|
||||
}
|
||||
|
||||
func (c *oracleQueryConnection) Begin() (driver.Tx, error) {
|
||||
return c.BeginTx(context.Background(), driver.TxOptions{})
|
||||
}
|
||||
|
||||
type oracleQueryTransaction struct {
|
||||
driver.Tx
|
||||
conn *oracleQueryConnection
|
||||
}
|
||||
|
||||
func (t *oracleQueryTransaction) Commit() error {
|
||||
if t.conn.failed != nil {
|
||||
// sql.Tx considers Commit final even when it returns an error. Roll back
|
||||
// instead of committing a transaction whose query outcome is uncertain.
|
||||
err := t.Tx.Rollback()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w (rollback failed: %v)", t.conn.unusableError(), err)
|
||||
}
|
||||
return t.conn.unusableError()
|
||||
}
|
||||
return t.Tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Adapt the existing scripted drivers to the same interfaces as go-ora. The
|
||||
// production connector and database/sql still own all recovery and lifecycle.
|
||||
type oracleGuardTestConnector struct{ drv driver.Driver }
|
||||
|
||||
func (c oracleGuardTestConnector) Driver() driver.Driver { return c.drv }
|
||||
|
||||
func (c oracleGuardTestConnector) Connect(context.Context) (driver.Conn, error) {
|
||||
conn, err := c.drv.Open("")
|
||||
return &oracleGuardTestConn{Conn: conn}, err
|
||||
}
|
||||
|
||||
type oracleGuardTestConn struct{ driver.Conn }
|
||||
|
||||
func (c *oracleGuardTestConn) QueryContext(ctx context.Context, q string, args []driver.NamedValue) (driver.Rows, error) {
|
||||
return c.Conn.(driver.QueryerContext).QueryContext(ctx, q, args)
|
||||
}
|
||||
func (c *oracleGuardTestConn) ExecContext(ctx context.Context, q string, args []driver.NamedValue) (driver.Result, error) {
|
||||
return c.Conn.(driver.ExecerContext).ExecContext(ctx, q, args)
|
||||
}
|
||||
func (c *oracleGuardTestConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
|
||||
return c.Conn.(driver.ConnBeginTx).BeginTx(ctx, opts)
|
||||
}
|
||||
func (c *oracleGuardTestConn) PrepareContext(_ context.Context, q string) (driver.Stmt, error) {
|
||||
return c.Conn.Prepare(q)
|
||||
}
|
||||
func (c *oracleGuardTestConn) Ping(context.Context) error { return nil }
|
||||
func (c *oracleGuardTestConn) ResetSession(context.Context) error { return nil }
|
||||
func (c *oracleGuardTestConn) CheckNamedValue(*driver.NamedValue) error { return driver.ErrSkip }
|
||||
|
||||
type oracleGuardFaultDriver struct {
|
||||
oracleManualTxDriver
|
||||
queryCalls int
|
||||
closed int
|
||||
blocked chan struct{}
|
||||
}
|
||||
|
||||
func (d *oracleGuardFaultDriver) Open(string) (driver.Conn, error) {
|
||||
return &oracleGuardFaultConn{oracleManualTxConn: &oracleManualTxConn{driver: &d.oracleManualTxDriver}, fault: d}, nil
|
||||
}
|
||||
|
||||
type oracleGuardFaultConn struct {
|
||||
*oracleManualTxConn
|
||||
fault *oracleGuardFaultDriver
|
||||
}
|
||||
|
||||
func (c *oracleGuardFaultConn) QueryContext(ctx context.Context, q string, args []driver.NamedValue) (driver.Rows, error) {
|
||||
c.fault.queryCalls++
|
||||
if q == "SELECT WAIT_QUERY FROM DUAL" {
|
||||
close(c.fault.blocked)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if q == "SELECT WAIT_FETCH FROM DUAL" {
|
||||
return &oracleGuardBlockedRows{ctx: ctx, blocked: c.fault.blocked}, nil
|
||||
}
|
||||
if q == "SELECT FAULT FROM DUAL" {
|
||||
panic("injected query decode failure")
|
||||
}
|
||||
return c.oracleManualTxConn.QueryContext(ctx, q, args)
|
||||
}
|
||||
|
||||
type oracleGuardBlockedRows struct {
|
||||
ctx context.Context
|
||||
blocked chan struct{}
|
||||
}
|
||||
|
||||
func (*oracleGuardBlockedRows) Columns() []string { return []string{"A"} }
|
||||
func (*oracleGuardBlockedRows) Close() error { return nil }
|
||||
func (r *oracleGuardBlockedRows) Next([]driver.Value) error {
|
||||
close(r.blocked)
|
||||
<-r.ctx.Done()
|
||||
return r.ctx.Err()
|
||||
}
|
||||
|
||||
func TestRuntimeMetadataPanicReleasesRowsBeforeAndDuringFirstPage(t *testing.T) {
|
||||
for _, method := range []string{"execute_query", "execute_query_page", "start_table_read"} {
|
||||
for _, call := range []int{1, 2, 3} {
|
||||
t.Run(fmt.Sprintf("%s/column_call_%d", method, call), func(t *testing.T) {
|
||||
steps := []oracleViewSourceQueryStep{}
|
||||
for i := 0; i < 6; i++ {
|
||||
steps = append(steps,
|
||||
oracleViewSourceQueryStep{queryContains: "SELECT 1", args: []driver.Value{}, columnsPanicText: "injected metadata failure", columnsPanicCall: call},
|
||||
oracleViewSourceQueryStep{queryContains: "SELECT 1", args: []driver.Value{}, rows: [][]driver.Value{{int64(1)}}},
|
||||
)
|
||||
}
|
||||
db, _ := openOracleViewSourceTestDB(t, steps)
|
||||
db.SetMaxOpenConns(1)
|
||||
s := newServer()
|
||||
s.db = db
|
||||
runtime := newRuntimeServer()
|
||||
runtime.sessions["a"] = &agentSession{server: s}
|
||||
for i := 0; i < 6; i++ {
|
||||
request := fmt.Sprintf(`{"id":1,"method":%q,"params":{"agentSessionId":"a","sql":"SELECT 1 FROM DUAL","pageSize":2,"timeoutSecs":1}}`, method)
|
||||
resp, shutdown := runtime.handleLine(request)
|
||||
if shutdown || resp.Error == nil {
|
||||
t.Fatalf("expected metadata panic: %+v", resp)
|
||||
}
|
||||
if db.Stats().InUse != 0 || len(s.activeRows) != 0 || len(s.sessions) != 0 || len(s.tableReadSessions) != 0 {
|
||||
t.Fatalf("panic %d leaked cursor/connection: %+v", i, db.Stats())
|
||||
}
|
||||
resp, _ = runtime.handleLine(`{"id":2,"method":"execute_query","params":{"agentSessionId":"a","sql":"SELECT 1 FROM DUAL","timeoutSecs":1}}`)
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("same session is unusable: %v", resp.Error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCancellationRestoresQueryAndFetch(t *testing.T) {
|
||||
for _, phase := range []string{"QUERY", "FETCH"} {
|
||||
t.Run(phase, func(t *testing.T) {
|
||||
drv := &oracleGuardFaultDriver{blocked: make(chan struct{})}
|
||||
db := sql.OpenDB(oracleQueryConnector{oracleGuardTestConnector{drv}})
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
s := newServer()
|
||||
s.db = db
|
||||
runtime := newRuntimeServer()
|
||||
runtime.sessions["a"] = &agentSession{server: s}
|
||||
done := make(chan response, 1)
|
||||
go func() {
|
||||
request, _ := json.Marshal(map[string]any{"id": 1, "method": "execute_query_page", "params": map[string]any{"agentSessionId": "a", "sql": "SELECT WAIT_" + phase + " FROM DUAL", "pageSize": 2}})
|
||||
resp, _ := runtime.handleLine(string(request))
|
||||
done <- resp
|
||||
}()
|
||||
select {
|
||||
case <-drv.blocked:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("query did not reach blocking phase")
|
||||
}
|
||||
resp, _ := runtime.handleLine(`{"id":2,"method":"cancel_session","params":{"agentSessionId":"a"}}`)
|
||||
if resp.Error != nil {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
select {
|
||||
case resp := <-done:
|
||||
if resp.Error == nil {
|
||||
t.Fatal("cancel lost its error")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("cancel did not release session lock")
|
||||
}
|
||||
if db.Stats().InUse != 0 || len(s.activeRows) != 0 {
|
||||
t.Fatal("cancel leaked resources")
|
||||
}
|
||||
resp, _ = runtime.handleLine(`{"id":3,"method":"execute_query","params":{"agentSessionId":"a","sql":"SELECT 1 FROM DUAL","timeoutSecs":1}}`)
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("query after cancel failed: %v", resp.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func (c *oracleGuardFaultConn) Close() error {
|
||||
c.fault.closed++
|
||||
return c.oracleManualTxConn.Close()
|
||||
}
|
||||
|
||||
func TestOracleQueryPanicDoesNotExhaustPoolOrReplay(t *testing.T) {
|
||||
drv := &oracleGuardFaultDriver{}
|
||||
db := sql.OpenDB(oracleQueryConnector{oracleGuardTestConnector{drv}})
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
s := newServer()
|
||||
s.db = db
|
||||
for i := 0; i < 8; i++ {
|
||||
_, err := s.executeSelectOnce("SELECT 1 FROM DUAL", 10, 1, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := drv.queryCalls
|
||||
_, err = s.queryRowsWithTimeout("SELECT FAULT FROM DUAL", nil, 1)
|
||||
var panicErr oracleDriverPanicError
|
||||
if !errors.As(err, &panicErr) {
|
||||
t.Fatalf("lost panic: %v", err)
|
||||
}
|
||||
if drv.queryCalls != calls+1 {
|
||||
t.Fatal("statement was implicitly replayed")
|
||||
}
|
||||
if stats := db.Stats(); stats.InUse != 0 || stats.OpenConnections != 0 || len(s.activeRows) != 0 {
|
||||
t.Fatalf("failure %d leaked resources: %+v active_rows=%d", i, stats, len(s.activeRows))
|
||||
}
|
||||
if drv.closed != i+1 {
|
||||
t.Fatalf("poisoned connection was not closed: %d", drv.closed)
|
||||
}
|
||||
}
|
||||
if _, err := s.executeSelectOnce("SELECT 1 FROM DUAL", 10, 1, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOracleQueryPanicAllowsManualTransactionCleanup(t *testing.T) {
|
||||
for _, commit := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint("commit=", commit), func(t *testing.T) {
|
||||
drv := &oracleGuardFaultDriver{}
|
||||
db := sql.OpenDB(oracleQueryConnector{oracleGuardTestConnector{drv}})
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
s := newServer()
|
||||
s.db = db
|
||||
if err := s.beginManualTransaction(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.queryRowsWithTimeout("SELECT FAULT FROM DUAL", nil, 1); err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
if !s.hasManualTransaction() {
|
||||
t.Fatal("transaction state silently cleared")
|
||||
}
|
||||
calls := drv.queryCalls
|
||||
if _, err := s.executeQuery(queryOptions{SQL: "SELECT 1 FROM DUAL"}); err == nil {
|
||||
t.Fatal("poisoned transaction reused")
|
||||
}
|
||||
if _, err := s.executeQuery(queryOptions{SQL: "UPDATE t SET a=2"}); err == nil {
|
||||
t.Fatal("write ran in poisoned transaction")
|
||||
}
|
||||
if drv.queryCalls != calls || len(drv.execs) != 0 {
|
||||
t.Fatal("failed transaction reached driver again")
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
if commit {
|
||||
done <- s.commitManualTransaction()
|
||||
} else {
|
||||
done <- s.rollbackManualTransaction()
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if (err != nil) != commit {
|
||||
t.Fatalf("unexpected cleanup error: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("panic left transaction lock held")
|
||||
}
|
||||
if drv.committed || !drv.rolledBack || s.hasManualTransaction() || db.Stats().InUse != 0 {
|
||||
t.Fatal("incorrect transaction cleanup")
|
||||
}
|
||||
if _, err := s.executeSelectOnce("SELECT 1 FROM DUAL", 10, 1, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ public final class SapHanaAgent extends ConfiguredJdbcAgent {
|
||||
30015,
|
||||
false,
|
||||
Collections.emptySet(),
|
||||
Arrays.asList("COLUMN TABLE", "ROW TABLE", "TABLE", "VIEW")
|
||||
Arrays.asList("COLUMN TABLE", "ROW TABLE", "TABLE", "VIEW", "CALC VIEW", "JOIN VIEW", "OLAP VIEW", "HIERARCHY VIEW")
|
||||
);
|
||||
|
||||
public SapHanaAgent() {
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package com.dbx.agent.saphana;
|
||||
|
||||
import com.dbx.agent.CompletionAssistantCandidateKind;
|
||||
import com.dbx.agent.CompletionAssistantObjectKind;
|
||||
import com.dbx.agent.CompletionAssistantRequest;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SapHanaAgentMetadataTest {
|
||||
@Test
|
||||
void listsAllHanaViewTypesAndClassifiesThemAsViews() {
|
||||
List<TableInfo> tables = metadataAgent().listTables("_SYS_BIC");
|
||||
|
||||
assertEquals(6, tables.size());
|
||||
assertEquals(5, tables.stream().filter(table -> "VIEW".equals(table.getTable_type())).count());
|
||||
assertEquals(1, tables.stream().filter(table -> "TABLE".equals(table.getTable_type())).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewOnlyObjectListingIncludesModeledViews() {
|
||||
var objects = metadataAgent().listObjects("_SYS_BIC", new MetadataListConstraints(null, null, null, List.of("VIEW")));
|
||||
|
||||
assertEquals(5, objects.size());
|
||||
assertTrue(objects.stream().allMatch(object -> "VIEW".equals(object.getObject_type())));
|
||||
assertTrue(objects.stream().allMatch(object -> "_SYS_BIC".equals(object.getSchema())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewCompletionIncludesModeledViewsWithoutTables() throws ReflectiveOperationException {
|
||||
CompletionAssistantRequest request = new CompletionAssistantRequest();
|
||||
var schemaField = CompletionAssistantRequest.class.getDeclaredField("schema");
|
||||
schemaField.setAccessible(true);
|
||||
schemaField.set(request, "_SYS_BIC");
|
||||
var kindsField = CompletionAssistantRequest.class.getDeclaredField("object_kinds");
|
||||
kindsField.setAccessible(true);
|
||||
kindsField.set(request, List.of(CompletionAssistantObjectKind.VIEW));
|
||||
var result = metadataAgent().completionAssistantSearch(request);
|
||||
|
||||
assertEquals(5, result.getCandidates().size());
|
||||
assertTrue(result.getCandidates().stream().allMatch(candidate -> candidate.getKind() == CompletionAssistantCandidateKind.VIEW));
|
||||
}
|
||||
|
||||
private static SapHanaAgent metadataAgent() {
|
||||
DatabaseMetaData meta = proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getSearchStringEscape":
|
||||
return "\\";
|
||||
case "getTableTypes":
|
||||
return rows(List.of("TABLE", "VIEW", "CALC VIEW", "JOIN VIEW", "OLAP VIEW", "HIERARCHY VIEW", "SYNONYM")
|
||||
.stream().map(type -> Map.of("TABLE_TYPE", type)).toList());
|
||||
case "getTables":
|
||||
// Preserve JDBC type filtering: excluded types must never appear in this fixture's result.
|
||||
assertTrue(List.of("_SYS_BIC", "\\_SYS\\_BIC").contains(args[1]));
|
||||
List<String> requested = Arrays.asList((String[]) args[3]);
|
||||
assertTrue(!requested.contains("SYNONYM"));
|
||||
return rows(List.of("TABLE", "VIEW", "CALC VIEW", "JOIN VIEW", "OLAP VIEW", "HIERARCHY VIEW")
|
||||
.stream().filter(requested::contains)
|
||||
.map(type -> Map.of("TABLE_NAME", "package/" + type.replace(' ', '_'), "TABLE_TYPE", type)).toList());
|
||||
case "getProcedures":
|
||||
case "getFunctions":
|
||||
return rows(List.of());
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
Connection conn = proxy(Connection.class, (method, args) -> {
|
||||
if ("getMetaData".equals(method.getName())) return meta;
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
SapHanaAgent agent = new SapHanaAgent();
|
||||
TestSupport.setPrivateConnection(agent, conn);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private static ResultSet rows(List<Map<String, String>> values) {
|
||||
AtomicInteger index = new AtomicInteger(-1);
|
||||
return proxy(ResultSet.class, (method, args) -> {
|
||||
if ("next".equals(method.getName())) return index.incrementAndGet() < values.size();
|
||||
if ("getString".equals(method.getName())) return values.get(index.get()).get(args[0]);
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static <T> T proxy(Class<T> type, Handler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] { type },
|
||||
(proxy, method, args) -> handler.handle(method, args)));
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == boolean.class) return false;
|
||||
if (type == int.class) return 0;
|
||||
if (type == long.class) return 0L;
|
||||
return null;
|
||||
}
|
||||
|
||||
private interface Handler {
|
||||
Object handle(Method method, Object[] args) throws Throwable;
|
||||
}
|
||||
}
|
||||
+193
-9
@@ -1,6 +1,9 @@
|
||||
package com.dbx.agent.sqlserverlegacy;
|
||||
|
||||
import com.dbx.agent.ConfiguredJdbcAgent;
|
||||
import com.dbx.agent.ExecuteQueryOptions;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.dbx.agent.QueryResult;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DdlBuilder;
|
||||
@@ -63,6 +66,8 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
Arrays.asList("TABLE", "VIEW", "SYSTEM TABLE")
|
||||
);
|
||||
private volatile boolean sqlServer2000Mode;
|
||||
private volatile boolean manualTransactionSession;
|
||||
private volatile boolean manualTransactionExecuted;
|
||||
|
||||
public SqlServerLegacyAgent() {
|
||||
super(PROFILE);
|
||||
@@ -71,6 +76,119 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
enableLegacyTlsAlgorithms();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> beginManualTransaction(String schema) {
|
||||
Connection conn = getConnection();
|
||||
if (conn == null) throw new IllegalStateException("Not connected");
|
||||
// Metadata is advisory when an old JDBC driver cannot implement it.
|
||||
// A definitive false is different from an unavailable metadata method.
|
||||
return unchecked(() -> {
|
||||
java.sql.DatabaseMetaData metadata = null;
|
||||
boolean unsupported = false;
|
||||
try {
|
||||
metadata = conn.getMetaData();
|
||||
unsupported = !metadata.supportsTransactions();
|
||||
} catch (SQLException | AbstractMethodError unavailable) {
|
||||
// setAutoCommit is the authoritative operation in this case.
|
||||
}
|
||||
if (unsupported) throw new IllegalStateException("DBX_MANUAL_TRANSACTION_UNSUPPORTED: JDBC driver reports transactions are unsupported");
|
||||
if (!conn.getAutoCommit()) throw new IllegalStateException("Manual transaction already open");
|
||||
conn.setAutoCommit(false);
|
||||
manualTransactionSession = true;
|
||||
manualTransactionExecuted = false;
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", true);
|
||||
result.put("manualTransactionBatch", true);
|
||||
if (metadata != null) {
|
||||
try {
|
||||
result.put("productVersion", metadata.getDatabaseProductVersion());
|
||||
result.put("productType", metadata.getDatabaseProductName());
|
||||
result.put("driverName", metadata.getDriverName());
|
||||
result.put("driverVersion", metadata.getDriverVersion());
|
||||
} catch (SQLException | AbstractMethodError unavailable) {
|
||||
// Diagnostics must not turn a successful BEGIN into a failure.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> commitManualTransaction() {
|
||||
Connection conn = getConnection();
|
||||
if (conn == null) throw new IllegalStateException("Not connected");
|
||||
return unchecked(() -> {
|
||||
if (conn.getAutoCommit()) throw new IllegalStateException("No manual transaction open");
|
||||
if (manualTransactionExecuted && transactionCount(conn, 30) != 1) throw new IllegalStateException("DBX_MANUAL_TRANSACTION_STATE_LOST: commit state cannot be confirmed");
|
||||
conn.commit();
|
||||
// This is a dedicated, terminal session. Do not setAutoCommit(true):
|
||||
// a failure in that cleanup must not obscure an acknowledged COMMIT.
|
||||
return Collections.singletonMap("ok", (Object) true);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> rollbackManualTransaction() {
|
||||
Connection conn = getConnection();
|
||||
if (conn == null) throw new IllegalStateException("Not connected");
|
||||
return unchecked(() -> {
|
||||
if (conn.getAutoCommit()) throw new IllegalStateException("No manual transaction open");
|
||||
if (manualTransactionExecuted && transactionCount(conn, 30) == 0) throw new IllegalStateException("DBX_MANUAL_TRANSACTION_STATE_LOST: transaction already ended; rollback cannot be confirmed");
|
||||
conn.rollback();
|
||||
return Collections.singletonMap("ok", (Object) true);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean permitsAutomaticReconnect() {
|
||||
return !manualTransactionSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<QueryResult> executeQueryResults(String sql, String schema, ExecuteQueryOptions options) {
|
||||
Connection conn = getConnection();
|
||||
if (conn == null) throw new IllegalStateException("Not connected");
|
||||
return unchecked(() -> {
|
||||
if (!manualTransactionSession) {
|
||||
beforeQueryExecution(conn, options.getTimeoutSecs());
|
||||
return JdbcExecutor.current().executeAll(conn, sql, options.getMaxRows(),
|
||||
options.getFetchSize(), options.getTimeoutSecs(), resultValueReader());
|
||||
}
|
||||
if (conn.getAutoCommit()) throw new IllegalStateException("DBX_MANUAL_TRANSACTION_STATE_LOST: autoCommit changed");
|
||||
beforeQueryExecution(conn, options.getTimeoutSecs());
|
||||
int before = transactionCount(conn, options.getTimeoutSecs());
|
||||
if (before == 0 && !manualTransactionExecuted) {
|
||||
// JDBC may defer its physical BEGIN. Open the DBX transaction before
|
||||
// the first user batch, including PRINT/read-only procedures. Explicit
|
||||
// BEGIN with IMPLICIT_TRANSACTIONS ON would open two transactions.
|
||||
JdbcExecutor.current().executeAll(conn, "SET IMPLICIT_TRANSACTIONS OFF; BEGIN TRANSACTION", 1, null,
|
||||
options.getTimeoutSecs(), resultValueReader());
|
||||
before = transactionCount(conn, options.getTimeoutSecs());
|
||||
}
|
||||
if (before != 1) throw new IllegalStateException("DBX_MANUAL_TRANSACTION_STATE_LOST: transaction not active or nested");
|
||||
manualTransactionExecuted = true;
|
||||
List<QueryResult> results = JdbcExecutor.current().executeAll(conn, sql, options.getMaxRows(),
|
||||
options.getFetchSize(), options.getTimeoutSecs(), resultValueReader());
|
||||
int after = transactionCount(conn, options.getTimeoutSecs());
|
||||
if (after != 1) {
|
||||
throw new IllegalStateException("DBX_MANUAL_TRANSACTION_STATE_LOST: transaction ended or changed inside the batch");
|
||||
}
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
private static int transactionCount(Connection conn, int timeoutSecs) throws SQLException {
|
||||
try (java.sql.Statement stmt = conn.createStatement()) {
|
||||
stmt.setQueryTimeout(timeoutSecs > 0 ? timeoutSecs : 30);
|
||||
try (ResultSet rows = stmt.executeQuery("SELECT @@TRANCOUNT")) {
|
||||
if (!rows.next()) throw new SQLException("Missing SQL Server transaction status");
|
||||
int count = rows.getInt(1);
|
||||
while (rows.next()) { /* consume status response */ }
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsConnectionPooling() {
|
||||
// The mssql-jdbc -> jTDS fallback is session state on this Agent
|
||||
@@ -116,7 +234,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
throw fallbackError;
|
||||
}
|
||||
}
|
||||
throw withLegacyTlsDiagnostics(error);
|
||||
throw withLegacyTlsDiagnostics(error, jdbcDriverVersion(), requestedTlsProtocol(params));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +259,8 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
|
||||
@Override
|
||||
protected void afterDisconnect() {
|
||||
manualTransactionSession = false;
|
||||
manualTransactionExecuted = false;
|
||||
sqlServer2000Mode = false;
|
||||
}
|
||||
|
||||
@@ -305,7 +425,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
if (urlParams == null || urlParams.trim().isEmpty()) {
|
||||
return properties;
|
||||
}
|
||||
for (String pair : urlParams.trim().split("[&;]")) {
|
||||
for (String pair : connectionPropertyParts(urlParams.trim(), true)) {
|
||||
String value = pair.trim();
|
||||
int separator = value.indexOf('=');
|
||||
if (separator <= 0) {
|
||||
@@ -636,10 +756,70 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
Map<String, String> properties = baseConnectionProperties(params);
|
||||
properties.put("encrypt", "true");
|
||||
properties.put("trustServerCertificate", "true");
|
||||
properties.put("sslProtocol", "TLSv1");
|
||||
properties.put("sslProtocol", requestedTlsProtocol(params));
|
||||
return appendProperties(baseJdbcUrl(params), properties);
|
||||
}
|
||||
|
||||
private static String requestedTlsProtocol(ConnectParams params) {
|
||||
// Keep the legacy default for existing SQL Server 2000 connections, but
|
||||
// allow an explicit modern protocol when this driver is used on 2019/2022.
|
||||
String protocol = tlsProtocolProperty(params.getConnection_string(), "TLSv1", false);
|
||||
protocol = tlsProtocolProperty(params.getUrl_params(), protocol, true);
|
||||
switch (protocol.toUpperCase(Locale.ROOT)) {
|
||||
case "TLS": return "TLS";
|
||||
case "TLSV1": return "TLSv1";
|
||||
case "TLSV1.1": return "TLSv1.1";
|
||||
case "TLSV1.2": return "TLSv1.2";
|
||||
case "TLSV1.3": return "TLSv1.3";
|
||||
default: throw new IllegalArgumentException("Unsupported SQL Server sslProtocol; use TLS, TLSv1, TLSv1.1, TLSv1.2 or TLSv1.3");
|
||||
}
|
||||
}
|
||||
|
||||
private static String tlsProtocolProperty(String properties, String fallback, boolean urlParams) {
|
||||
if (properties == null) return fallback;
|
||||
String protocol = fallback;
|
||||
for (String property : connectionPropertyParts(properties, urlParams)) {
|
||||
String part = property.trim();
|
||||
while (part.startsWith("?")) part = part.substring(1).trim();
|
||||
int separator = part.indexOf('=');
|
||||
if (separator > 0 && "sslProtocol".equalsIgnoreCase(part.substring(0, separator).trim())) {
|
||||
String value = part.substring(separator + 1).trim();
|
||||
protocol = value.startsWith("{") && value.endsWith("}")
|
||||
? value.substring(1, value.length() - 1).replace("}}", "}") : value;
|
||||
}
|
||||
}
|
||||
return protocol;
|
||||
}
|
||||
private static List<String> connectionPropertyParts(String value, boolean ampersandDelimiter) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
int start = 0;
|
||||
boolean propertyValue = false;
|
||||
boolean valueStarted = false;
|
||||
boolean escaped = false;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char ch = value.charAt(i);
|
||||
if (escaped) {
|
||||
if (ch == '}') {
|
||||
if (i + 1 < value.length() && value.charAt(i + 1) == '}') i++;
|
||||
else escaped = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch == ';' || (ampersandDelimiter && ch == '&')) {
|
||||
parts.add(value.substring(start, i));
|
||||
start = i + 1;
|
||||
propertyValue = false;
|
||||
valueStarted = false;
|
||||
} else if (!propertyValue && ch == '=') {
|
||||
propertyValue = true;
|
||||
} else if (propertyValue && !valueStarted && !Character.isWhitespace(ch)) {
|
||||
valueStarted = true;
|
||||
escaped = ch == '{';
|
||||
}
|
||||
}
|
||||
parts.add(value.substring(start));
|
||||
return parts;
|
||||
}
|
||||
static String relaxedDisabledAlgorithms(String current) {
|
||||
if (current == null || current.trim().isEmpty()) {
|
||||
return "";
|
||||
@@ -659,15 +839,15 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
}
|
||||
|
||||
static String legacyTlsDiagnostics() {
|
||||
return legacyTlsDiagnostics(jdbcDriverVersion());
|
||||
return legacyTlsDiagnostics(jdbcDriverVersion(), "TLSv1");
|
||||
}
|
||||
|
||||
private static String legacyTlsDiagnostics(String jdbcVersion) {
|
||||
private static String legacyTlsDiagnostics(String jdbcVersion, String protocol) {
|
||||
String disabledAlgorithms = Security.getProperty(TLS_DISABLED_ALGORITHMS_KEY);
|
||||
return "DBX SQL Server legacy TLS diagnostics: java=" + System.getProperty("java.version", "unknown")
|
||||
+ ", javaVendor=" + System.getProperty("java.vendor", "unknown")
|
||||
+ ", jdbc=" + jdbcVersion
|
||||
+ ", sslProtocol=TLSv1"
|
||||
+ ", sslProtocol=" + protocol
|
||||
+ ", tlsV1Disabled=" + isDisabled(disabledAlgorithms, "TLSV1")
|
||||
+ ", tlsRsaDisabled=" + isDisabled(disabledAlgorithms, "TLS_RSA_*")
|
||||
+ ", rsaPkcs1Sha1HandshakeDisabled="
|
||||
@@ -681,9 +861,13 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
}
|
||||
|
||||
static SQLException withLegacyTlsDiagnostics(SQLException error, String jdbcVersion) {
|
||||
return withLegacyTlsDiagnostics(error, jdbcVersion, "TLSv1");
|
||||
}
|
||||
|
||||
static SQLException withLegacyTlsDiagnostics(SQLException error, String jdbcVersion, String protocol) {
|
||||
String message = error.getMessage() == null ? error.toString() : error.getMessage();
|
||||
return new SQLException(
|
||||
message + "\n\n" + legacyTlsDiagnostics(jdbcVersion),
|
||||
message + "\n\n" + legacyTlsDiagnostics(jdbcVersion, protocol),
|
||||
error.getSQLState(),
|
||||
error.getErrorCode(),
|
||||
error
|
||||
@@ -774,7 +958,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
|
||||
private static String sanitizeSqlServerUrl(String value) {
|
||||
String trimmed = trimSqlServerUrl(value);
|
||||
String[] parts = trimmed.split(";");
|
||||
String[] parts = connectionPropertyParts(trimmed, false).toArray(new String[0]);
|
||||
if (parts.length <= 1) {
|
||||
return trimmed;
|
||||
}
|
||||
@@ -804,7 +988,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
return properties;
|
||||
}
|
||||
|
||||
for (String pair : urlParams.trim().split("[&;]")) {
|
||||
for (String pair : connectionPropertyParts(urlParams.trim(), true)) {
|
||||
String value = pair.trim();
|
||||
while (value.startsWith("?") || value.startsWith("&") || value.startsWith(";")) {
|
||||
value = value.substring(1).trim();
|
||||
|
||||
+242
-3
@@ -27,6 +27,209 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
class SqlServerLegacyAgentTest {
|
||||
@Test
|
||||
void autoCommitBatchesCollectEveryResultAndUpdateCountOnce() {
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
java.util.concurrent.atomic.AtomicInteger index = new java.util.concurrent.atomic.AtomicInteger();
|
||||
java.util.concurrent.atomic.AtomicInteger executions = new java.util.concurrent.atomic.AtomicInteger();
|
||||
java.util.concurrent.atomic.AtomicInteger drained = new java.util.concurrent.atomic.AtomicInteger();
|
||||
ResultSet first = autoCommitRows(drained, 101, 102);
|
||||
ResultSet last = autoCommitRows(new java.util.concurrent.atomic.AtomicInteger(), 303);
|
||||
java.sql.Statement statement = proxy(java.sql.Statement.class, (method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "execute": executions.incrementAndGet(); return true;
|
||||
case "setMaxRows": throw new AssertionError("Do not cap the server's batch execution");
|
||||
case "getResultSet": return index.get() == 0 ? first : last;
|
||||
case "getMoreResults": return index.incrementAndGet() == 2;
|
||||
case "getUpdateCount": return index.get() == 1 ? 7 : -1;
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
|
||||
if (method.getName().equals("createStatement")) return statement;
|
||||
if (method.getName().equals("getAutoCommit")) return true;
|
||||
if (method.getName().equals("commit") || method.getName().equals("rollback")) {
|
||||
throw new AssertionError("Auto-commit query must not commit/rollback implicitly");
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}));
|
||||
|
||||
List<com.dbx.agent.QueryResult> results = agent.executeQueryResults(
|
||||
"SELECT 101; UPDATE t SET n=7; SELECT 303", null, new com.dbx.agent.ExecuteQueryOptions(1, null, 5));
|
||||
Assertions.assertEquals(1, executions.get());
|
||||
Assertions.assertEquals(3, results.size());
|
||||
Assertions.assertEquals(101, results.get(0).getRows().get(0).get(0));
|
||||
Assertions.assertTrue(results.get(0).getTruncated());
|
||||
Assertions.assertEquals(2, drained.get());
|
||||
Assertions.assertEquals(7, results.get(1).getAffected_rows());
|
||||
Assertions.assertEquals(303, results.get(2).getRows().get(0).get(0));
|
||||
Assertions.assertTrue(agent.permitsAutomaticReconnect());
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoCommitBatchPropagatesErrorsAfterTheFirstResult() {
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
java.util.concurrent.atomic.AtomicInteger executions = new java.util.concurrent.atomic.AtomicInteger();
|
||||
java.sql.Statement statement = proxy(java.sql.Statement.class, (method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "execute": executions.incrementAndGet(); return true;
|
||||
case "getResultSet": return autoCommitRows(new java.util.concurrent.atomic.AtomicInteger(), 101);
|
||||
case "getMoreResults": throw new SQLException("late batch error");
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
|
||||
if (method.getName().equals("createStatement")) return statement;
|
||||
if (method.getName().equals("getAutoCommit")) return true;
|
||||
return defaultValue(method.getReturnType());
|
||||
}));
|
||||
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> agent.executeQueryResults(
|
||||
"SELECT 101; RAISERROR('late batch error',16,1)", null, new com.dbx.agent.ExecuteQueryOptions(1, null, 5)));
|
||||
Assertions.assertTrue(error.getMessage().contains("late batch error"));
|
||||
Assertions.assertEquals(1, executions.get());
|
||||
}
|
||||
|
||||
private static ResultSet autoCommitRows(java.util.concurrent.atomic.AtomicInteger drained, int... values) {
|
||||
java.sql.ResultSetMetaData metadata = proxy(java.sql.ResultSetMetaData.class, (method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getColumnCount": return 1;
|
||||
case "getColumnLabel": return "n";
|
||||
case "getColumnType": return Types.INTEGER;
|
||||
case "getColumnTypeName": return "int";
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
java.util.concurrent.atomic.AtomicInteger row = new java.util.concurrent.atomic.AtomicInteger(-1);
|
||||
return proxy(ResultSet.class, (method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "next": if (row.incrementAndGet() >= values.length) return false; drained.incrementAndGet(); return true;
|
||||
case "getMetaData": return metadata;
|
||||
case "getObject":
|
||||
case "getInt": return values[row.get()];
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static final class ManualFixture {
|
||||
final SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
final java.util.concurrent.atomic.AtomicInteger count = new java.util.concurrent.atomic.AtomicInteger();
|
||||
final java.util.concurrent.atomic.AtomicInteger begins = new java.util.concurrent.atomic.AtomicInteger();
|
||||
final java.util.concurrent.atomic.AtomicInteger rollbacks = new java.util.concurrent.atomic.AtomicInteger();
|
||||
final java.util.concurrent.atomic.AtomicBoolean automatic = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
ManualFixture() {
|
||||
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) ->
|
||||
method.getName().equals("supportsTransactions") ? true : defaultValue(method.getReturnType()));
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getMetaData": return metadata;
|
||||
case "getAutoCommit": return automatic.get();
|
||||
case "setAutoCommit": automatic.set((boolean) args[0]); return null;
|
||||
case "rollback": rollbacks.incrementAndGet(); count.set(0); return null;
|
||||
case "createStatement":
|
||||
return proxy(java.sql.Statement.class, (stmtMethod, stmtArgs) -> {
|
||||
switch (stmtMethod.getName()) {
|
||||
case "executeQuery":
|
||||
java.util.concurrent.atomic.AtomicBoolean first = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
return proxy(ResultSet.class, (rowMethod, rowArgs) -> {
|
||||
if (rowMethod.getName().equals("next")) return first.getAndSet(false);
|
||||
if (rowMethod.getName().equals("getInt")) return count.get();
|
||||
return defaultValue(rowMethod.getReturnType());
|
||||
});
|
||||
case "execute":
|
||||
String sql = (String) stmtArgs[0];
|
||||
if (sql.contains("BEGIN TRANSACTION")) { begins.incrementAndGet(); count.set(1); }
|
||||
if (sql.contains("HIDDEN_COMMIT_ERROR")) { count.set(0); throw new SQLException("failure after hidden commit"); }
|
||||
return false;
|
||||
case "getUpdateCount": return -1;
|
||||
default: return defaultValue(stmtMethod.getReturnType());
|
||||
}
|
||||
});
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
}));
|
||||
agent.beginManualTransaction(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void lazyJdbcBeginOpensOnePhysicalTransactionBeforePrintAndReadOnlyExec() {
|
||||
ManualFixture fixture = new ManualFixture();
|
||||
Assertions.assertEquals(0, fixture.count.get());
|
||||
fixture.agent.executeQueryResults("PRINT 'hello'", null, new com.dbx.agent.ExecuteQueryOptions(1, null, 5));
|
||||
fixture.agent.executeQueryResults("EXEC('SELECT 1')", null, new com.dbx.agent.ExecuteQueryOptions(1, null, 5));
|
||||
Assertions.assertEquals(1, fixture.begins.get());
|
||||
Assertions.assertEquals(1, fixture.count.get());
|
||||
Assertions.assertFalse(fixture.agent.permitsAutomaticReconnect());
|
||||
fixture.agent.rollbackManualTransaction();
|
||||
Assertions.assertEquals(1, fixture.rollbacks.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hiddenCommitFollowedByErrorCannotBeReportedAsSuccessfulRollback() {
|
||||
ManualFixture fixture = new ManualFixture();
|
||||
Assertions.assertThrows(RuntimeException.class, () -> fixture.agent.executeQueryResults(
|
||||
"HIDDEN_COMMIT_ERROR", null, new com.dbx.agent.ExecuteQueryOptions(1, null, 5)));
|
||||
Assertions.assertEquals(0, fixture.count.get());
|
||||
Assertions.assertThrows(IllegalStateException.class, () -> fixture.agent.rollbackManualTransaction());
|
||||
Assertions.assertEquals(0, fixture.rollbacks.get());
|
||||
Assertions.assertFalse(fixture.agent.permitsAutomaticReconnect());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualSessionDisablesReconnectAndCommitAckDoesNotDependOnAutocommitCleanup() {
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
java.util.concurrent.atomic.AtomicBoolean automatic = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
java.util.concurrent.atomic.AtomicInteger commits = new java.util.concurrent.atomic.AtomicInteger();
|
||||
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
if (method.getName().equals("supportsTransactions")) return true;
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getMetaData": return metadata;
|
||||
case "getAutoCommit": return automatic.get();
|
||||
case "setAutoCommit":
|
||||
if ((boolean) args[0]) throw new SQLException("cleanup failed");
|
||||
automatic.set(false); return null;
|
||||
case "commit": commits.incrementAndGet(); return null;
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
}));
|
||||
Assertions.assertTrue(agent.permitsAutomaticReconnect());
|
||||
Assertions.assertEquals(true, agent.beginManualTransaction(null).get("manualTransactionBatch"));
|
||||
Assertions.assertFalse(agent.permitsAutomaticReconnect());
|
||||
Assertions.assertEquals(true, agent.commitManualTransaction().get("ok"));
|
||||
Assertions.assertEquals(1, commits.get());
|
||||
Assertions.assertFalse(agent.permitsAutomaticReconnect());
|
||||
agent.disconnect();
|
||||
Assertions.assertTrue(agent.permitsAutomaticReconnect());
|
||||
}
|
||||
|
||||
@Test
|
||||
void metadataUnsupportedRejectsBeforeChangingAutocommitButUnavailableMetadataUsesActualBegin() {
|
||||
SqlServerLegacyAgent unsupported = new SqlServerLegacyAgent();
|
||||
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> defaultValue(method.getReturnType()));
|
||||
TestSupport.setPrivateConnection(unsupported, proxy(Connection.class, (method, args) -> {
|
||||
if (method.getName().equals("getMetaData")) return metadata;
|
||||
if (method.getName().equals("setAutoCommit")) throw new AssertionError("Unsupported BEGIN must not change the connection");
|
||||
return defaultValue(method.getReturnType());
|
||||
}));
|
||||
Assertions.assertThrows(IllegalStateException.class, () -> unsupported.beginManualTransaction(null));
|
||||
SqlServerLegacyAgent unavailable = new SqlServerLegacyAgent();
|
||||
java.util.concurrent.atomic.AtomicBoolean automatic = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
TestSupport.setPrivateConnection(unavailable, proxy(Connection.class, (method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getMetaData": throw new SQLException("metadata unavailable");
|
||||
case "getAutoCommit": return automatic.get();
|
||||
case "setAutoCommit": automatic.set((boolean) args[0]); return null;
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
}));
|
||||
Assertions.assertEquals(true, unavailable.beginManualTransaction(null).get("ok"));
|
||||
Assertions.assertFalse(automatic.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServer2000PreloginFailuresTriggerTheOldDriverFallback() {
|
||||
// Real mssql-jdbc prelogin rejection for SQL Server 2000.
|
||||
@@ -368,7 +571,7 @@ class SqlServerLegacyAgentTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyTlsUrlUsesSqlServerTlsV1Properties() {
|
||||
void legacyTlsUrlHonorsExplicitProtocolAndRetainsEncryption() {
|
||||
ConnectParams params = new ConnectParams(
|
||||
"db.example.com",
|
||||
14330,
|
||||
@@ -381,7 +584,7 @@ class SqlServerLegacyAgentTest {
|
||||
);
|
||||
|
||||
Assertions.assertEquals(
|
||||
"jdbc:sqlserver://db.example.com:14330;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1",
|
||||
"jdbc:sqlserver://db.example.com:14330;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1.2",
|
||||
SqlServerLegacyAgent.legacyTlsUrl(params)
|
||||
);
|
||||
}
|
||||
@@ -458,11 +661,47 @@ class SqlServerLegacyAgentTest {
|
||||
);
|
||||
|
||||
Assertions.assertEquals(
|
||||
"jdbc:sqlserver://db.example.com:1433;databaseName=custom;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1",
|
||||
"jdbc:sqlserver://db.example.com:1433;databaseName=custom;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1.2",
|
||||
SqlServerLegacyAgent.legacyTlsUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyTlsUrlParametersOverrideUrlProtocolCaseInsensitively() {
|
||||
ConnectParams params = new ConnectParams("ignored", 0, "", "sa", "secret",
|
||||
"SSLPROTOCOL=tlsv1.2", "jdbc:sqlserver://db.example.com:1433;sslProtocol=TLSv1", false);
|
||||
Assertions.assertEquals("jdbc:sqlserver://db.example.com:1433;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1.2",
|
||||
SqlServerLegacyAgent.legacyTlsUrl(params));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyTlsUrlRejectsInvalidProtocolRatherThanSilentlyDowngrading() {
|
||||
ConnectParams params = new ConnectParams("db.example.com", 1433, "", "sa", "secret",
|
||||
"sslProtocol=not-a-protocol", "", false);
|
||||
Assertions.assertThrows(IllegalArgumentException.class, () -> SqlServerLegacyAgent.legacyTlsUrl(params));
|
||||
}
|
||||
@Test
|
||||
void legacyTlsUrlSupportsDriverTlsV13() {
|
||||
ConnectParams params = new ConnectParams("db.example.com", 1433, "", "sa", "secret",
|
||||
"sslProtocol=tlsv1.3", "", false);
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.legacyTlsUrl(params).endsWith(";sslProtocol=TLSv1.3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyTlsUrlPreservesBracedValuesAndIgnoresEmbeddedProtocolProperties() {
|
||||
ConnectParams params = new ConnectParams("ignored", 0, "", "sa", "secret", "",
|
||||
"jdbc:sqlserver://db.example.com:1433;applicationName={app;sslProtocol=invalid};sslProtocol={TLSv1.2}", false);
|
||||
Assertions.assertEquals("jdbc:sqlserver://db.example.com:1433;applicationName={app;sslProtocol=invalid};encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1.2",
|
||||
SqlServerLegacyAgent.legacyTlsUrl(params));
|
||||
params.setConnection_string("jdbc:sqlserver://db.example.com:1433;applicationName={app}};sslProtocol=invalid}");
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.legacyTlsUrl(params).endsWith(";sslProtocol=TLSv1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyTlsDiagnosticsReportsExplicitProtocol() {
|
||||
SQLException error = SqlServerLegacyAgent.withLegacyTlsDiagnostics(new SQLException("connection failed"), "13.2", "TLSv1.2");
|
||||
Assertions.assertTrue(error.getMessage().contains("sslProtocol=TLSv1.2"));
|
||||
}
|
||||
@Test
|
||||
void relaxedDisabledAlgorithmsRemovesOnlyLegacyTlsEntries() {
|
||||
String current =
|
||||
|
||||
@@ -1,499 +1,28 @@
|
||||
package com.dbx.agent.sundb;
|
||||
|
||||
import com.dbx.agent.AbstractJdbcAgent;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.ExecuteQueryOptions;
|
||||
import com.dbx.agent.ForeignKeyInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.dbx.agent.JdbcIdentifiers;
|
||||
import com.dbx.agent.ConfiguredJdbcAgent;
|
||||
import com.dbx.agent.JdbcAgentProfile;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.MetadataSqlSupport;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.QueryResult;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
/**
|
||||
* 科蓝 SUNDB Agent。
|
||||
*
|
||||
* <p>SUNDB 不是 MySQL 兼容库:服务端会以 16062(SQLState 42000)拒绝
|
||||
* {@code SHOW DATABASES}、{@code SHOW INDEX} 这类 MySQL 专有语句,因此元数据
|
||||
* 统一交给厂商 JDBC 驱动实现的 {@link java.sql.DatabaseMetaData}(驱动内部使用
|
||||
* SUNDB 的 {@code INFORMATION_SCHEMA.DBC_*} 视图)读取,不再手写 MySQL 风格的
|
||||
* information_schema 查询。schema 切换沿用驱动的 {@code SET SCHEMA}。
|
||||
*/
|
||||
public final class SundbAgent extends ConfiguredJdbcAgent {
|
||||
/** 默认端口与 plugins/connection-types/sundb.yaml 的 defaultPort 保持一致。 */
|
||||
public static final JdbcAgentProfile SUNDB_PROFILE = new JdbcAgentProfile(
|
||||
"csii.sundb.jdbc.SundbDriver",
|
||||
"jdbc:sundb://{host}:{port}/{database}",
|
||||
22581
|
||||
);
|
||||
|
||||
public final class SundbAgent extends AbstractJdbcAgent {
|
||||
@Override
|
||||
protected String driverClass() {
|
||||
return "csii.sundb.jdbc.SundbDriver";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
return buildUrl(params);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
return unchecked(() -> {
|
||||
List<DatabaseInfo> result = new ArrayList<>();
|
||||
try (var stmt = requireConnected().createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SHOW DATABASES")) {
|
||||
while (rs.next()) {
|
||||
result.add(new DatabaseInfo(rs.getString(1)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> listSchemas() {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (DatabaseInfo database : listDatabases()) {
|
||||
result.add(database.getName());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema) {
|
||||
return unchecked(() -> {
|
||||
List<TableInfo> result = new ArrayList<>();
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME"
|
||||
)) {
|
||||
stmt.setString(1, schema);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String tableType = rs.getString("TABLE_TYPE");
|
||||
if ("BASE TABLE".equals(tableType)) {
|
||||
tableType = "TABLE";
|
||||
}
|
||||
result.add(new TableInfo(rs.getString("TABLE_NAME"), tableType, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema, MetadataListConstraints constraints) {
|
||||
MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints);
|
||||
if (isUnconstrained(normalized)) {
|
||||
return listTables(schema);
|
||||
}
|
||||
if (!normalized.includesTableLikeTypes()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
return queryConstrainedTables(schema, normalized);
|
||||
} catch (RuntimeException e) {
|
||||
return normalized.filterTables(listTables(schema));
|
||||
}
|
||||
}
|
||||
|
||||
private List<TableInfo> queryConstrainedTables(String schema, MetadataListConstraints constraints) {
|
||||
return unchecked(() -> {
|
||||
List<TableInfo> result = new ArrayList<>();
|
||||
List<Object> args = new ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder("SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?");
|
||||
args.add(schema);
|
||||
appendInformationSchemaTableTypePredicate(sql, args, constraints);
|
||||
MetadataSqlSupport.appendNameFilter(sql, args, "TABLE_NAME", constraints);
|
||||
sql.append(" ORDER BY TABLE_NAME");
|
||||
MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints);
|
||||
try (var stmt = requireConnected().prepareStatement(sql.toString())) {
|
||||
MetadataSqlSupport.bind(stmt, args);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new TableInfo(rs.getString("TABLE_NAME"), normalizeTableType(rs.getString("TABLE_TYPE")), null));
|
||||
}
|
||||
}
|
||||
}
|
||||
return constraints.withoutPaging().filterTables(result);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ObjectInfo> listObjects(String schema) {
|
||||
return unchecked(() -> {
|
||||
List<ObjectInfo> result = new ArrayList<>();
|
||||
for (TableInfo table : listTables(schema)) {
|
||||
result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment(), table.getValid()));
|
||||
}
|
||||
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME"
|
||||
)) {
|
||||
stmt.setString(1, schema);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new ObjectInfo(rs.getString(1), rs.getString(2), schema, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ObjectInfo> listObjects(String schema, MetadataListConstraints constraints) {
|
||||
MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints);
|
||||
if (isUnconstrained(normalized)) {
|
||||
return listObjects(schema);
|
||||
}
|
||||
if (!includesSupportedObjects(normalized)) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
return queryConstrainedObjects(schema, normalized);
|
||||
} catch (RuntimeException e) {
|
||||
return normalized.filterObjects(listObjects(schema));
|
||||
}
|
||||
}
|
||||
|
||||
private List<ObjectInfo> queryConstrainedObjects(String schema, MetadataListConstraints constraints) {
|
||||
return unchecked(() -> {
|
||||
List<ObjectInfo> result = new ArrayList<>();
|
||||
List<String> branches = new ArrayList<>();
|
||||
List<Object> args = new ArrayList<>();
|
||||
if (constraints.includesTableLikeTypes()) {
|
||||
StringBuilder tableSql = new StringBuilder("SELECT TABLE_NAME AS OBJECT_NAME, TABLE_TYPE AS OBJECT_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?");
|
||||
args.add(schema);
|
||||
appendInformationSchemaTableTypePredicate(tableSql, args, constraints);
|
||||
MetadataSqlSupport.appendNameFilter(tableSql, args, "TABLE_NAME", constraints);
|
||||
branches.add(tableSql.toString());
|
||||
}
|
||||
if (constraints.objectTypeAllowed("PROCEDURE") || constraints.objectTypeAllowed("FUNCTION")) {
|
||||
StringBuilder routineSql = new StringBuilder("SELECT ROUTINE_NAME AS OBJECT_NAME, ROUTINE_TYPE AS OBJECT_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?");
|
||||
args.add(schema);
|
||||
appendRoutineTypePredicate(routineSql, args, constraints);
|
||||
MetadataSqlSupport.appendNameFilter(routineSql, args, "ROUTINE_NAME", constraints);
|
||||
branches.add(routineSql.toString());
|
||||
}
|
||||
if (branches.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
StringBuilder sql = new StringBuilder("SELECT OBJECT_NAME, OBJECT_TYPE FROM (")
|
||||
.append(String.join(" UNION ALL ", branches))
|
||||
.append(") metadata_objects ORDER BY CASE OBJECT_TYPE WHEN 'BASE TABLE' THEN 0 WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'PROCEDURE' THEN 2 WHEN 'FUNCTION' THEN 3 ELSE 9 END, OBJECT_NAME");
|
||||
MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints);
|
||||
try (var stmt = requireConnected().prepareStatement(sql.toString())) {
|
||||
MetadataSqlSupport.bind(stmt, args);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new ObjectInfo(rs.getString(1), normalizeTableType(rs.getString(2)), schema, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
return constraints.withoutPaging().filterObjects(result);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectSource getObjectSource(String schema, String name, String objectType) {
|
||||
return unchecked(() -> {
|
||||
String sql = switch (objectType.toUpperCase(Locale.ROOT)) {
|
||||
case "VIEW" -> "SHOW CREATE VIEW `" + name.replace("`", "``") + "`";
|
||||
case "PROCEDURE" -> "SHOW CREATE PROCEDURE `" + name.replace("`", "``") + "`";
|
||||
case "FUNCTION" -> "SHOW CREATE FUNCTION `" + name.replace("`", "``") + "`";
|
||||
default -> throw new IllegalArgumentException("Unsupported object type: " + objectType);
|
||||
};
|
||||
|
||||
String source = "";
|
||||
try (var stmt = requireConnected().createStatement();
|
||||
ResultSet rs = stmt.executeQuery(sql)) {
|
||||
if (rs.next()) {
|
||||
int index = "VIEW".equals(objectType.toUpperCase(Locale.ROOT)) ? 1 : 2;
|
||||
String value = rs.getString(index + 1);
|
||||
source = value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
return new ObjectSource(name, objectType, schema, source);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
Set<String> primaryKeys = new HashSet<>();
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"""
|
||||
SELECT COLUMN_NAME
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = ?
|
||||
AND TABLE_NAME = ?
|
||||
AND CONSTRAINT_NAME = 'PRIMARY'
|
||||
"""
|
||||
)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
primaryKeys.add(rs.getString("COLUMN_NAME"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"""
|
||||
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT,
|
||||
NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION
|
||||
"""
|
||||
)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String columnName = rs.getString("COLUMN_NAME");
|
||||
result.add(new ColumnInfo(
|
||||
columnName,
|
||||
rs.getString("COLUMN_TYPE"),
|
||||
"YES".equals(rs.getString("IS_NULLABLE")),
|
||||
rs.getString("COLUMN_DEFAULT"),
|
||||
primaryKeys.contains(columnName),
|
||||
rs.getString("EXTRA"),
|
||||
emptyToNull(rs.getString("COLUMN_COMMENT")),
|
||||
integerOrNull(rs, "NUMERIC_PRECISION"),
|
||||
integerOrNull(rs, "NUMERIC_SCALE"),
|
||||
numberToIntOrNull(rs, "CHARACTER_MAXIMUM_LENGTH")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IndexInfo> listIndexes(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
try (var stmt = requireConnected().createStatement();
|
||||
ResultSet rs = stmt.executeQuery(
|
||||
"SHOW INDEX FROM " + JdbcIdentifiers.INSTANCE.backtick(table)
|
||||
+ " FROM " + JdbcIdentifiers.INSTANCE.backtick(schema)
|
||||
)) {
|
||||
Map<String, List<IndexColumn>> indexMap = new LinkedHashMap<>();
|
||||
Map<String, Boolean> uniqueMap = new HashMap<>();
|
||||
Map<String, String> typeMap = new HashMap<>();
|
||||
|
||||
while (rs.next()) {
|
||||
String indexName = rs.getString("Key_name");
|
||||
String columnName = rs.getString("Column_name");
|
||||
int sequence = rs.getInt("Seq_in_index");
|
||||
int nonUnique = rs.getInt("Non_unique");
|
||||
String indexType = rs.getString("Index_type");
|
||||
|
||||
indexMap.computeIfAbsent(indexName, ignored -> new ArrayList<>())
|
||||
.add(new IndexColumn(sequence, columnName));
|
||||
uniqueMap.put(indexName, nonUnique == 0);
|
||||
typeMap.put(indexName, indexType);
|
||||
}
|
||||
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
for (Map.Entry<String, List<IndexColumn>> entry : indexMap.entrySet()) {
|
||||
String name = entry.getKey();
|
||||
List<String> columns = entry.getValue().stream()
|
||||
.sorted(Comparator.comparingInt(IndexColumn::sequence))
|
||||
.map(IndexColumn::name)
|
||||
.toList();
|
||||
result.add(new IndexInfo(
|
||||
name,
|
||||
columns,
|
||||
uniqueMap.getOrDefault(name, false),
|
||||
"PRIMARY".equals(name),
|
||||
null,
|
||||
typeMap.get(name),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
List<ForeignKeyInfo> result = new ArrayList<>();
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"""
|
||||
SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = ?
|
||||
AND TABLE_NAME = ?
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
ORDER BY CONSTRAINT_NAME
|
||||
"""
|
||||
)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new ForeignKeyInfo(
|
||||
rs.getString("CONSTRAINT_NAME"),
|
||||
rs.getString("COLUMN_NAME"),
|
||||
rs.getString("REFERENCED_TABLE_NAME"),
|
||||
rs.getString("REFERENCED_COLUMN_NAME")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TriggerInfo> listTriggers(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
List<TriggerInfo> result = new ArrayList<>();
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"""
|
||||
SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING
|
||||
FROM information_schema.TRIGGERS
|
||||
WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ?
|
||||
ORDER BY TRIGGER_NAME
|
||||
"""
|
||||
)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new TriggerInfo(
|
||||
rs.getString("TRIGGER_NAME"),
|
||||
rs.getString("EVENT_MANIPULATION"),
|
||||
rs.getString("ACTION_TIMING")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
|
||||
return JdbcExecutor.current().execute(
|
||||
requireConnected(),
|
||||
sql,
|
||||
schema,
|
||||
this::setSchemaSQL,
|
||||
options.getMaxRows(),
|
||||
options.getFetchSize(),
|
||||
options.getTimeoutSecs(),
|
||||
this::resultValue
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String setSchemaSQL(String schema) {
|
||||
return "USE " + JdbcIdentifiers.INSTANCE.backtick(schema);
|
||||
}
|
||||
|
||||
private static String buildUrl(ConnectParams params) {
|
||||
return "jdbc:sundb://" + params.getHost() + ":" + params.getPort() + "/" + params.getDatabase();
|
||||
}
|
||||
|
||||
private static boolean isUnconstrained(MetadataListConstraints constraints) {
|
||||
return !constraints.hasFilter() && !constraints.hasLimit() && !constraints.hasOffset() && !constraints.hasObjectTypes();
|
||||
}
|
||||
|
||||
private static boolean includesSupportedObjects(MetadataListConstraints constraints) {
|
||||
return constraints.includesTableLikeTypes()
|
||||
|| constraints.objectTypeAllowed("PROCEDURE")
|
||||
|| constraints.objectTypeAllowed("FUNCTION");
|
||||
}
|
||||
|
||||
private static void appendInformationSchemaTableTypePredicate(StringBuilder sql, List<Object> args, MetadataListConstraints constraints) {
|
||||
if (!constraints.hasObjectTypes()) {
|
||||
return;
|
||||
}
|
||||
List<String> types = new ArrayList<>();
|
||||
if (constraints.tableTypeAllowed("TABLE")) {
|
||||
types.add("BASE TABLE");
|
||||
}
|
||||
if (constraints.tableTypeAllowed("VIEW")) {
|
||||
types.add("VIEW");
|
||||
}
|
||||
if (types.isEmpty()) {
|
||||
sql.append(" AND 1 = 0");
|
||||
return;
|
||||
}
|
||||
sql.append(" AND TABLE_TYPE IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")");
|
||||
args.addAll(types);
|
||||
}
|
||||
|
||||
private static void appendRoutineTypePredicate(StringBuilder sql, List<Object> args, MetadataListConstraints constraints) {
|
||||
if (!constraints.hasObjectTypes()) {
|
||||
return;
|
||||
}
|
||||
List<String> types = new ArrayList<>();
|
||||
if (constraints.objectTypeAllowed("PROCEDURE")) {
|
||||
types.add("PROCEDURE");
|
||||
}
|
||||
if (constraints.objectTypeAllowed("FUNCTION")) {
|
||||
types.add("FUNCTION");
|
||||
}
|
||||
if (types.isEmpty()) {
|
||||
sql.append(" AND 1 = 0");
|
||||
return;
|
||||
}
|
||||
sql.append(" AND ROUTINE_TYPE IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")");
|
||||
args.addAll(types);
|
||||
}
|
||||
|
||||
private static String normalizeTableType(String tableType) {
|
||||
return "BASE TABLE".equals(tableType) ? "TABLE" : tableType;
|
||||
}
|
||||
|
||||
private static Integer integerOrNull(ResultSet rs, String column) throws Exception {
|
||||
Object value = rs.getObject(column);
|
||||
return value instanceof Integer ? (Integer) value : null;
|
||||
}
|
||||
|
||||
private static Integer numberToIntOrNull(ResultSet rs, String column) throws Exception {
|
||||
Object value = rs.getObject(column);
|
||||
return value instanceof Number ? ((Number) value).intValue() : null;
|
||||
}
|
||||
|
||||
private static String emptyToNull(String value) {
|
||||
return value == null || value.isEmpty() ? null : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object resultValue(ResultSet rs, int index, int sqlType) {
|
||||
try {
|
||||
Object value = switch (sqlType) {
|
||||
case Types.BIGINT -> rs.getLong(index);
|
||||
case Types.INTEGER, Types.SMALLINT, Types.TINYINT -> rs.getInt(index);
|
||||
case Types.FLOAT, Types.REAL -> rs.getFloat(index);
|
||||
case Types.DOUBLE -> rs.getDouble(index);
|
||||
case Types.DECIMAL, Types.NUMERIC -> rs.getBigDecimal(index);
|
||||
case Types.BOOLEAN, Types.BIT -> rs.getBoolean(index);
|
||||
default -> rs.getString(index);
|
||||
};
|
||||
return rs.wasNull() ? null : value;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private record IndexColumn(int sequence, String name) {
|
||||
public SundbAgent() {
|
||||
super(SUNDB_PROFILE);
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
+282
-28
@@ -1,53 +1,307 @@
|
||||
package com.dbx.agent.sundb;
|
||||
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.ConfiguredJdbcAgent;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import com.dbx.agent.test.JdbcAgentFake;
|
||||
import com.dbx.agent.test.JdbcMetadataSqlFake;
|
||||
import java.util.Collections;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* SUNDB 服务端只接受自家语法({@code SHOW DATABASES} 会以 16062 报错),
|
||||
* 因此元数据必须交给厂商驱动的 {@link DatabaseMetaData}(内部查询
|
||||
* {@code INFORMATION_SCHEMA.DBC_*} 视图),而不是手写 MySQL 语句。
|
||||
*/
|
||||
class SundbAgentMetadataTest {
|
||||
@Test
|
||||
void quotesSchemaAndTableIdentifiersInIndexMetadataSql() {
|
||||
void loadsDatabasesAndSchemasFromDriverMetadata() {
|
||||
List<String> statements = new ArrayList<>();
|
||||
List<String> metadataRequests = new ArrayList<>();
|
||||
SundbAgent agent = new SundbAgent();
|
||||
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
|
||||
|
||||
agent.listIndexes("bad`schema", "bad`table");
|
||||
|
||||
Assertions.assertEquals(
|
||||
Collections.singletonList("SHOW INDEX FROM `bad``table` FROM `bad``schema`"),
|
||||
JdbcMetadataSqlFake.statements
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
connection(statements, metadata(emptyRows(), schemas("APP", "PUBLIC"), emptyRows(), emptyRows(), metadataRequests))
|
||||
);
|
||||
setConfiguredDatabase(agent, "SUNDB");
|
||||
|
||||
List<String> databases = agent.listDatabases().stream().map(DatabaseInfo::getName).toList();
|
||||
List<String> schemas = agent.listSchemas();
|
||||
|
||||
// 驱动的 getCatalogs() 是 WHERE 1=0 的占位查询,数据库名来自连接上的 database。
|
||||
Assertions.assertEquals(List.of("SUNDB"), databases);
|
||||
Assertions.assertEquals(List.of("APP", "PUBLIC"), schemas);
|
||||
Assertions.assertEquals(List.of("getSchemas:SUNDB"), metadataRequests);
|
||||
Assertions.assertTrue(statements.isEmpty(), "元数据不应执行手写 SQL: " + statements);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constrainedTableMetadataPushesFilterTypesAndPaging() {
|
||||
void listsTablesThroughDriverMetadataWithSunDbTableTypes() {
|
||||
List<String> statements = new ArrayList<>();
|
||||
List<String> metadataRequests = new ArrayList<>();
|
||||
SundbAgent agent = new SundbAgent();
|
||||
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
connection(
|
||||
statements,
|
||||
metadata(
|
||||
emptyRows(),
|
||||
emptyRows(),
|
||||
rows(new String[] {"TABLE_TYPE"}, new Object[][] {{"BASE TABLE"}, {"VIEW"}}),
|
||||
rows(
|
||||
new String[] {"TABLE_NAME", "TABLE_TYPE", "REMARKS"},
|
||||
new Object[][] {{"ITEMS", "BASE TABLE", null}, {"ITEM_VIEW", "VIEW", "视图"}}
|
||||
),
|
||||
metadataRequests
|
||||
)
|
||||
)
|
||||
);
|
||||
setConfiguredDatabase(agent, "SUNDB");
|
||||
|
||||
agent.listTables("app", new MetadataListConstraints("ord", 25, 50, List.of("TABLE", "VIEW")));
|
||||
List<TableInfo> tables = agent.listTables("APP");
|
||||
|
||||
String sql = JdbcMetadataSqlFake.statements.get(0);
|
||||
Assertions.assertTrue(sql.contains("FROM information_schema.TABLES"), sql);
|
||||
Assertions.assertTrue(sql.contains("TABLE_TYPE IN (?, ?)"), sql);
|
||||
Assertions.assertTrue(sql.contains("UPPER(TABLE_NAME) LIKE ? ESCAPE '\\\\'"), sql);
|
||||
Assertions.assertTrue(sql.endsWith("LIMIT 25 OFFSET 50"), sql);
|
||||
// 只有 DBC_TABLE_TYPE 真实支持的取值才会传给驱动。
|
||||
Assertions.assertEquals(List.of("getTables:BASE TABLE", "getTables:VIEW"), metadataRequests);
|
||||
Assertions.assertEquals(List.of("ITEMS", "ITEM_VIEW"), tables.stream().map(TableInfo::getName).toList());
|
||||
Assertions.assertEquals(List.of("TABLE", "VIEW"), tables.stream().map(TableInfo::getTable_type).toList());
|
||||
Assertions.assertEquals("视图", tables.get(1).getComment());
|
||||
Assertions.assertTrue(statements.isEmpty(), "元数据不应执行手写 SQL: " + statements);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constrainedObjectMetadataPushesRoutineTypesAndPaging() {
|
||||
void retriesSchemasWithoutTheCatalogFilterThatMatchesNoSunDbRows() {
|
||||
// 驱动的 getCatalog() 返回 null,所以 schema 查询会用连接上的 database 作为 catalog 过滤;
|
||||
// SUNDB 的 CATALOG_NAME 与该值不一致时会返回空集,此时必须退回不带 catalog 的 getSchemas(),
|
||||
// 否则对象树会停在空的 schema 节点上(库下的表永远加载不出来)。
|
||||
List<String> statements = new ArrayList<>();
|
||||
List<String> metadataRequests = new ArrayList<>();
|
||||
SundbAgent agent = new SundbAgent();
|
||||
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
|
||||
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getCatalogs":
|
||||
return emptyRows();
|
||||
case "getSchemas":
|
||||
metadataRequests.add("getSchemas:" + args[0]);
|
||||
return args[0] == null ? schemas("APP", "PUBLIC") : emptyRows();
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
TestSupport.setPrivateConnection(agent, connection(statements, metadata));
|
||||
setConfiguredDatabase(agent, "SUNDB");
|
||||
|
||||
agent.listObjects("app", new MetadataListConstraints("sync", 10, null, List.of("PROCEDURE", "FUNCTION")));
|
||||
List<String> schemas = agent.listSchemas();
|
||||
|
||||
String sql = JdbcMetadataSqlFake.statements.get(0);
|
||||
Assertions.assertTrue(sql.contains("FROM information_schema.ROUTINES"), sql);
|
||||
Assertions.assertTrue(sql.contains("ROUTINE_TYPE IN (?, ?)"), sql);
|
||||
Assertions.assertTrue(sql.contains("ORDER BY CASE OBJECT_TYPE"), sql);
|
||||
Assertions.assertTrue(sql.endsWith("LIMIT 10"), sql);
|
||||
Assertions.assertEquals(List.of("getSchemas:SUNDB", "getSchemas:null"), metadataRequests);
|
||||
Assertions.assertEquals(List.of("APP", "PUBLIC"), schemas);
|
||||
Assertions.assertTrue(statements.isEmpty(), "元数据不应执行手写 SQL: " + statements);
|
||||
}
|
||||
|
||||
@Test
|
||||
void switchesSchemaWithSetSchemaInsteadOfMysqlUse() {
|
||||
SundbAgent agent = new SundbAgent();
|
||||
TestSupport.setPrivateConnection(agent, connection(new ArrayList<>(), metadata(
|
||||
emptyRows(), emptyRows(), emptyRows(), emptyRows(), new ArrayList<>()
|
||||
)));
|
||||
|
||||
Assertions.assertEquals("SET SCHEMA \"APP\"", agent.setSchemaSQL("APP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsSunDbJdbcUrlFromProfileTemplate() {
|
||||
Assertions.assertEquals("csii.sundb.jdbc.SundbDriver", SundbAgent.SUNDB_PROFILE.getDriverClass());
|
||||
Assertions.assertEquals(22581, SundbAgent.SUNDB_PROFILE.getDefaultPort());
|
||||
|
||||
ConnectParams params = new ConnectParams();
|
||||
params.setHost("10.0.0.1");
|
||||
params.setPort(22581);
|
||||
params.setDatabase("SUNDB");
|
||||
|
||||
Assertions.assertEquals("jdbc:sundb://10.0.0.1:22581/SUNDB", SundbAgent.SUNDB_PROFILE.buildUrl(params));
|
||||
}
|
||||
|
||||
private static void setConfiguredDatabase(SundbAgent agent, String database) {
|
||||
try {
|
||||
Field field = ConfiguredJdbcAgent.class.getDeclaredField("configuredDatabase");
|
||||
field.setAccessible(true);
|
||||
field.set(agent, database);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static DatabaseMetaData metadata(
|
||||
ResultSet catalogs,
|
||||
ResultSet schemas,
|
||||
ResultSet tableTypes,
|
||||
ResultSet tables,
|
||||
List<String> metadataRequests
|
||||
) {
|
||||
return proxy(DatabaseMetaData.class, (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getCatalogs":
|
||||
return catalogs;
|
||||
case "getSchemas":
|
||||
metadataRequests.add("getSchemas:" + args[0]);
|
||||
return schemas;
|
||||
case "getTableTypes":
|
||||
return tableTypes;
|
||||
case "getTables":
|
||||
for (Object type : (String[]) args[3]) {
|
||||
metadataRequests.add("getTables:" + type);
|
||||
}
|
||||
return tables;
|
||||
case "getIdentifierQuoteString":
|
||||
return "\"";
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection connection(List<String> statements, DatabaseMetaData metadata) {
|
||||
return proxy(Connection.class, (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "createStatement":
|
||||
return statement(statements);
|
||||
case "prepareStatement":
|
||||
statements.add((String) args[0]);
|
||||
return preparedStatement(statements);
|
||||
case "getMetaData":
|
||||
return metadata;
|
||||
case "getAutoCommit":
|
||||
return true;
|
||||
case "isClosed":
|
||||
return false;
|
||||
case "close":
|
||||
return null;
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Statement statement(List<String> statements) {
|
||||
ResultSet resultSet = emptyRows();
|
||||
return proxy(Statement.class, (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "execute":
|
||||
statements.add((String) args[0]);
|
||||
return false;
|
||||
case "executeQuery":
|
||||
statements.add((String) args[0]);
|
||||
return resultSet;
|
||||
case "getResultSet":
|
||||
return resultSet;
|
||||
case "getUpdateCount":
|
||||
return 0;
|
||||
case "close":
|
||||
return null;
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static PreparedStatement preparedStatement(List<String> statements) {
|
||||
ResultSet resultSet = emptyRows();
|
||||
return proxy(PreparedStatement.class, (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "executeQuery":
|
||||
return resultSet;
|
||||
case "getResultSet":
|
||||
return resultSet;
|
||||
case "getUpdateCount":
|
||||
return 0;
|
||||
case "close":
|
||||
return null;
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static ResultSet schemas(String... names) {
|
||||
Object[][] data = new Object[names.length][];
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
data[i] = new Object[] {names[i]};
|
||||
}
|
||||
return rows(new String[] {"TABLE_SCHEM"}, data);
|
||||
}
|
||||
|
||||
private static ResultSet rows(String[] columns, Object[][] data) {
|
||||
final int[] index = {-1};
|
||||
return proxy(ResultSet.class, (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "next":
|
||||
index[0]++;
|
||||
return index[0] < data.length;
|
||||
case "getString": {
|
||||
Object cell = cell(data[index[0]], columns, args[0]);
|
||||
return cell == null ? null : cell.toString();
|
||||
}
|
||||
case "wasNull":
|
||||
return false;
|
||||
case "close":
|
||||
return null;
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Object cell(Object[] row, String[] columns, Object selector) {
|
||||
int position = selector instanceof Number
|
||||
? ((Number) selector).intValue() - 1
|
||||
: Arrays.asList(columns).indexOf((String) selector);
|
||||
return row[position];
|
||||
}
|
||||
|
||||
private static ResultSet emptyRows() {
|
||||
return rows(new String[0], new Object[0][]);
|
||||
}
|
||||
|
||||
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, handler));
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (Boolean.TYPE.equals(type)) {
|
||||
return false;
|
||||
}
|
||||
if (Byte.TYPE.equals(type)) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (Short.TYPE.equals(type)) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (Integer.TYPE.equals(type)) {
|
||||
return 0;
|
||||
}
|
||||
if (Long.TYPE.equals(type)) {
|
||||
return 0L;
|
||||
}
|
||||
if (Float.TYPE.equals(type)) {
|
||||
return 0f;
|
||||
}
|
||||
if (Double.TYPE.equals(type)) {
|
||||
return 0.0d;
|
||||
}
|
||||
if (Character.TYPE.equals(type)) {
|
||||
return '\0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.dbx.agent.sundb;
|
||||
|
||||
import com.dbx.agent.DatabaseAgent;
|
||||
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
|
||||
import java.lang.reflect.Method;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -18,13 +17,11 @@ class SundbAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesTheSunDbJdbcDriverClass() throws Exception {
|
||||
Method driverClass = SundbAgent.class.getDeclaredMethod("driverClass");
|
||||
driverClass.setAccessible(true);
|
||||
|
||||
void usesTheSunDbJdbcDriverClass() {
|
||||
Assertions.assertEquals("csii.sundb.jdbc.SundbDriver", SundbAgent.SUNDB_PROFILE.getDriverClass());
|
||||
Assertions.assertEquals(
|
||||
"csii.sundb.jdbc.SundbDriver",
|
||||
driverClass.invoke(new SundbAgent())
|
||||
((SundbAgent) createAgent()).getProfile().getDriverClass()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Read-only opt-in coverage of the exact query path used by the visual plan.
|
||||
// Run for SYSTEM/business databases and ordinary/DBA/SYSDBA users using the
|
||||
// existing XUGU_LIVE_* settings. It creates no objects and changes no grants.
|
||||
func TestLiveXuguExplain(t *testing.T) {
|
||||
if os.Getenv("XUGU_LIVE_TEST") != "1" {
|
||||
t.Skip("set XUGU_LIVE_TEST=1 with XUGU_LIVE_* settings")
|
||||
}
|
||||
params := liveXuguParams(t)
|
||||
db, err := openDB(params)
|
||||
if err != nil {
|
||||
t.Fatalf("open live Xugu connection: %v", err)
|
||||
}
|
||||
s := newServer()
|
||||
s.db, s.params, s.currentDatabase = db, params, params.Database
|
||||
t.Cleanup(func() { _ = s.disconnect() })
|
||||
if err := s.validateConnection(); err != nil {
|
||||
t.Fatalf("validate connection: %v", err)
|
||||
}
|
||||
schema, err := s.currentSchema()
|
||||
if err != nil {
|
||||
t.Fatalf("current schema: %v", err)
|
||||
}
|
||||
|
||||
for name, sql := range map[string]string{
|
||||
"constant": "SELECT 1 FROM DUAL",
|
||||
"filter": "SELECT TABLE_NAME FROM ALL_TABLES WHERE TABLE_NAME LIKE 'DBX%'",
|
||||
"sort_limit": "SELECT TABLE_NAME FROM ALL_TABLES ORDER BY TABLE_NAME LIMIT 10",
|
||||
"aggregate": "SELECT TABLE_NAME, COUNT(*) FROM ALL_TABLES GROUP BY TABLE_NAME",
|
||||
"join": "SELECT A.TABLE_NAME FROM ALL_TABLES A JOIN ALL_TABLES B ON A.TABLE_NAME = B.TABLE_NAME WHERE A.TABLE_NAME LIKE 'DBX%'",
|
||||
"subquery": "SELECT TABLE_NAME FROM ALL_TABLES WHERE TABLE_NAME IN (SELECT TABLE_NAME FROM ALL_TABLES WHERE TABLE_NAME LIKE 'DBX%')",
|
||||
"cte": "WITH Q AS (SELECT TABLE_NAME FROM ALL_TABLES) SELECT * FROM Q LIMIT 10",
|
||||
"complex_cte": "WITH Q AS (SELECT TABLE_NAME FROM ALL_TABLES) SELECT A.TABLE_NAME, COUNT(*) FROM Q A JOIN ALL_TABLES B ON A.TABLE_NAME = B.TABLE_NAME JOIN ALL_TABLES C ON A.TABLE_NAME = C.TABLE_NAME WHERE A.TABLE_NAME LIKE 'DBX%' GROUP BY A.TABLE_NAME ORDER BY A.TABLE_NAME LIMIT 10",
|
||||
"union": "SELECT TABLE_NAME FROM ALL_TABLES UNION ALL SELECT TABLE_NAME FROM ALL_TABLES",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
result, err := s.executeQuery(queryOptions{SQL: "EXPLAIN VERBOSE " + sql, Database: params.Database, Schema: schema, MaxRows: 100, TimeoutSecs: 30})
|
||||
if err != nil {
|
||||
t.Fatalf("explain query: %v", err)
|
||||
}
|
||||
if len(result.Columns) != 1 || !strings.EqualFold(result.Columns[0], "plan_path") {
|
||||
t.Fatalf("unexpected columns: %#v", result.Columns)
|
||||
}
|
||||
if len(result.Rows) == 0 || len(result.Rows[0]) == 0 || result.Rows[0][0] == nil || strings.TrimSpace(fmt.Sprint(result.Rows[0][0])) == "" {
|
||||
t.Fatal("empty explain plan")
|
||||
}
|
||||
plan := fmt.Sprint(result.Rows[0][0])
|
||||
t.Logf("native plan:\n%s", plan)
|
||||
if !strings.Contains(plan, "Scan") && !strings.Contains(plan, "Join") && !strings.Contains(plan, "Group") {
|
||||
t.Fatalf("no operator in plan: %s", plan)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := s.validateConnection(); err != nil {
|
||||
t.Fatalf("session not reusable after EXPLAIN: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ cassandra-go shared-fallback native-go Uses cassandra-gocql-driver schema metada
|
||||
dameng native-pushdown java-sql Uses ALL_OBJECTS with type, filter, stable order, and LIMIT/OFFSET with legacy fallback.
|
||||
databend native-pushdown java-sql Uses system procedures for routine pushdown and shared table metadata constraints for table-like objects.
|
||||
db2 native-pushdown java-sql Uses SYSCAT metadata with type, filter, stable order, and OFFSET/FETCH.
|
||||
firebird shared-fallback java-jdbc-metadata Delegates constrained listings to shared JDBC metadata and only applies explicitly configured legacy text decoding to comments.
|
||||
gbase8a native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET.
|
||||
gbase8s native-pushdown java-sql Uses systables with type, filter, stable order, and SKIP/FIRST.
|
||||
goldendb native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET.
|
||||
@@ -27,7 +28,7 @@ oracle-go native-pushdown native-go Uses Oracle-compatible metadata SQL with typ
|
||||
snowflake native-pushdown java-sql Uses INFORMATION_SCHEMA with type, filter, stable order, and literal LIMIT/OFFSET.
|
||||
spanner intentional-fallback java-jdbc-metadata Uses the dialect-aware Spanner JDBC metadata with the schema passed through verbatim because GoogleSQL names the user schema with an empty string; JDBC exposes no portable server-side paging API, common constraints filter locally.
|
||||
spark intentional-fallback java-show-metadata Uses SHOW TABLES/SHOW DATABASES; stable server-side fuzzy paging is not portable, common constraints filter locally.
|
||||
sundb native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET.
|
||||
sundb intentional-fallback java-jdbc-metadata Uses the vendor driver's DatabaseMetaData (INFORMATION_SCHEMA.DBC_* views) because the server rejects MySQL-only statements such as SHOW DATABASES; JDBC exposes no portable server-side paging API, common constraints filter locally.
|
||||
tdengine shared-fallback native-rust Uses SHOW STABLES/TABLES with information_schema parent enrichment, then applies stable filtering and paging in the native agent.
|
||||
trino native-pushdown java-sql Uses information_schema.tables with type, filter, stable order, and literal LIMIT/OFFSET.
|
||||
transwarp shared-fallback java-sql Uses system catalog views with stable ordering and shared filtering/paging; the vendor SDK has no verified portable server-side paging contract.
|
||||
|
||||
|
@@ -5,8 +5,10 @@ import hashlib
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -62,13 +64,148 @@ def update_release_artifact(artifact: dict, output: Path) -> None:
|
||||
artifact["format"] = "tar_zstd"
|
||||
|
||||
|
||||
def build_driver_zips(release_dir: Path) -> list[Path]:
|
||||
# Incremental updates only pay off for packages big enough to matter on slow
|
||||
# links; small packages ship a full download either way.
|
||||
MIN_DELTA_FULL_SIZE = 8 * 1024 * 1024
|
||||
# A delta larger than this fraction of the full package is not worth hosting:
|
||||
# clients would rather re-download the full artifact.
|
||||
MAX_DELTA_RATIO = 0.5
|
||||
|
||||
|
||||
class DeltaGenerator:
|
||||
"""Generates `zstd --patch-from` deltas between two releases of one artifact.
|
||||
|
||||
The previous registry (from the `agents-latest` release) provides each
|
||||
driver's previously published version and artifact URL. For every artifact
|
||||
whose version changed, the previous package is downloaded once, the new
|
||||
package is compressed against it, and — when the delta is small enough to
|
||||
be worth it — both the `.delta` file and the registry `delta` block are
|
||||
emitted. Any failure to produce a delta is logged and skipped: the release
|
||||
must never break because an incremental update could not be built.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
release_dir: Path,
|
||||
previous_registry: dict | None,
|
||||
*,
|
||||
min_full_size: int = MIN_DELTA_FULL_SIZE,
|
||||
max_ratio: float = MAX_DELTA_RATIO,
|
||||
verbose: bool = True,
|
||||
) -> None:
|
||||
self.release_dir = release_dir
|
||||
self.previous_registry = previous_registry or {"drivers": {}}
|
||||
self.min_full_size = min_full_size
|
||||
self.max_ratio = max_ratio
|
||||
self.verbose = verbose
|
||||
self._downloads: dict[str, Path] = {}
|
||||
self._download_dir: Path | None = None
|
||||
|
||||
def _previous_driver(self, driver_name: str) -> dict | None:
|
||||
return self.previous_registry.get("drivers", {}).get(driver_name)
|
||||
|
||||
def _download_previous(self, url: str) -> Path | None:
|
||||
if url in self._downloads:
|
||||
return self._downloads[url]
|
||||
if self._download_dir is None:
|
||||
self._download_dir = Path(tempfile.mkdtemp(prefix="dbx-delta-prev-"))
|
||||
target = self._download_dir / artifact_filename(url)
|
||||
try:
|
||||
with urllib.request.urlopen(url) as response, target.open("wb") as out:
|
||||
out.write(response.read())
|
||||
except Exception as error: # noqa: BLE001 - any fetch failure just skips the delta
|
||||
print(f" delta skipped (previous artifact unavailable: {error})", file=sys.stderr)
|
||||
return None
|
||||
self._downloads[url] = target
|
||||
return target
|
||||
|
||||
def maybe_generate(
|
||||
self,
|
||||
driver_name: str,
|
||||
new_version: str,
|
||||
new_artifact: dict,
|
||||
previous_artifact: dict | None,
|
||||
new_package: Path,
|
||||
suffix: str,
|
||||
) -> None:
|
||||
"""Attaches a `delta` block to the freshly packaged `new_artifact`.
|
||||
|
||||
`previous_artifact` is the same artifact slot (jar, or one native
|
||||
platform) in the previous registry and only supplies the base package
|
||||
URL; `suffix` distinguishes native platforms in the delta file name.
|
||||
"""
|
||||
if not previous_artifact:
|
||||
return
|
||||
if new_package.stat().st_size < self.min_full_size:
|
||||
return
|
||||
previous_driver = self._previous_driver(driver_name) or {}
|
||||
previous_version = str(previous_driver.get("version", ""))
|
||||
if not previous_version or previous_version == new_version:
|
||||
return
|
||||
base_url = previous_artifact.get("url", "")
|
||||
if not base_url:
|
||||
return
|
||||
previous_package = self._download_previous(base_url)
|
||||
if previous_package is None:
|
||||
return
|
||||
|
||||
stem = f"dbx-agent-{driver_name}-{previous_version}-to-{new_version}{suffix}"
|
||||
delta_path = self.release_dir / f"{stem}.tar.zst.delta"
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"zstd", "-q", "--patch-from", str(previous_package),
|
||||
str(new_package), "-o", str(delta_path), "--force",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError) as error:
|
||||
print(f" delta skipped (zstd failed: {error})", file=sys.stderr)
|
||||
delta_path.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
delta_size = delta_path.stat().st_size
|
||||
if delta_size > self.max_ratio * new_package.stat().st_size:
|
||||
delta_path.unlink()
|
||||
if self.verbose:
|
||||
ratio = delta_size / new_package.stat().st_size
|
||||
print(f" delta skipped ({ratio:.0%} of full package, above {self.max_ratio:.0%})")
|
||||
return
|
||||
|
||||
# `new_artifact["url"]` already points at the packaged tar.zst on this
|
||||
# release; the delta file rides the same release, so only the file name
|
||||
# needs swapping.
|
||||
new_artifact["delta"] = {
|
||||
"base_version": previous_version,
|
||||
"url": release_url_with_filename(new_artifact["url"], delta_path.name),
|
||||
"sha256": hashlib.sha256(delta_path.read_bytes()).hexdigest(),
|
||||
"size": delta_size,
|
||||
}
|
||||
if self.verbose:
|
||||
ratio = delta_size / new_package.stat().st_size
|
||||
print(f" delta {delta_path.name} ({delta_size} bytes, {ratio:.0%} of full)")
|
||||
|
||||
|
||||
def build_driver_zips(
|
||||
release_dir: Path,
|
||||
previous_registry: dict | None = None,
|
||||
*,
|
||||
delta_min_full_size: int = MIN_DELTA_FULL_SIZE,
|
||||
delta_max_ratio: float = MAX_DELTA_RATIO,
|
||||
) -> list[Path]:
|
||||
registry_path = release_dir / "agent-registry.json"
|
||||
registry = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
delta_generator = DeltaGenerator(
|
||||
release_dir,
|
||||
previous_registry,
|
||||
min_full_size=delta_min_full_size,
|
||||
max_ratio=delta_max_ratio,
|
||||
)
|
||||
outputs: list[Path] = []
|
||||
|
||||
for driver_name, driver in registry.get("drivers", {}).items():
|
||||
version = driver["version"]
|
||||
previous_driver = delta_generator._previous_driver(driver_name)
|
||||
jar_artifact = driver.get("jar")
|
||||
if jar_artifact and jar_artifact.get("size", 0) > 0:
|
||||
filename = artifact_filename(jar_artifact["url"])
|
||||
@@ -86,6 +223,10 @@ def build_driver_zips(release_dir: Path) -> list[Path]:
|
||||
elif not output.is_file():
|
||||
raise FileExistsError(f"Reusable Java agent package is not a file: {output}")
|
||||
update_release_artifact(jar_artifact, output)
|
||||
delta_generator.maybe_generate(
|
||||
driver_name, version, jar_artifact,
|
||||
(previous_driver or {}).get("jar"), output, "",
|
||||
)
|
||||
outputs.append(output)
|
||||
|
||||
for platform, artifact in driver.get("native", {}).items():
|
||||
@@ -104,6 +245,10 @@ def build_driver_zips(release_dir: Path) -> list[Path]:
|
||||
elif not output.is_file():
|
||||
raise FileExistsError(f"Reusable native agent package is not a file: {output}")
|
||||
update_release_artifact(artifact, output)
|
||||
delta_generator.maybe_generate(
|
||||
driver_name, version, artifact,
|
||||
(previous_driver or {}).get("native", {}).get(platform), output, f"-{platform}",
|
||||
)
|
||||
outputs.append(output)
|
||||
|
||||
registry_path.write_text(json.dumps(registry, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
@@ -113,7 +258,7 @@ def build_driver_zips(release_dir: Path) -> list[Path]:
|
||||
def remove_raw_driver_artifacts(release_dir: Path) -> list[Path]:
|
||||
removed: list[Path] = []
|
||||
for path in sorted(release_dir.glob("dbx-agent-*")):
|
||||
if path.name.endswith(".tar.zst") or not path.is_file():
|
||||
if path.name.endswith(".tar.zst") or path.name.endswith(".delta") or not path.is_file():
|
||||
continue
|
||||
path.unlink()
|
||||
removed.append(path)
|
||||
@@ -124,9 +269,22 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Build tar.zst packages for individual DBX agents")
|
||||
parser.add_argument("release_dir", type=Path)
|
||||
parser.add_argument("--cleanup-sources", action="store_true")
|
||||
parser.add_argument(
|
||||
"--previous-registry",
|
||||
type=Path,
|
||||
default=None,
|
||||
help=(
|
||||
"agent-registry.json of the previous agents release; enables "
|
||||
"incremental delta patches between consecutive driver versions"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
for path in build_driver_zips(args.release_dir):
|
||||
previous_registry = None
|
||||
if args.previous_registry and args.previous_registry.is_file():
|
||||
previous_registry = json.loads(args.previous_registry.read_text(encoding="utf-8"))
|
||||
|
||||
for path in build_driver_zips(args.release_dir, previous_registry):
|
||||
print(f"Prepared {path.name} ({path.stat().st_size} bytes)")
|
||||
if args.cleanup_sources:
|
||||
for path in remove_raw_driver_artifacts(args.release_dir):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import secrets
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
@@ -466,5 +468,144 @@ class DriverReleasePackagesTest(unittest.TestCase):
|
||||
self.assertIn(f"drivers/{kafka_filename}", archive.namelist())
|
||||
|
||||
|
||||
class DriverDeltaPatchTest(unittest.TestCase):
|
||||
"""Incremental `zstd --patch-from` deltas emitted by build_driver_zips."""
|
||||
|
||||
@staticmethod
|
||||
def _incompressible_filler(size: int) -> bytes:
|
||||
# Deterministic, incompressible shared payload: two packages built from
|
||||
# it stay ~`size` bytes large while their version differences stay a
|
||||
# few bytes, so the delta ratio reflects the small real change.
|
||||
chunks = []
|
||||
counter = 0
|
||||
while sum(len(chunk) for chunk in chunks) < size:
|
||||
chunks.append(hashlib.sha256(f"demo-filler-{counter}".encode()).digest())
|
||||
counter += 1
|
||||
return b"".join(chunks)[:size]
|
||||
|
||||
@staticmethod
|
||||
def _write_release(release_dir: Path, version: str, jar_payload: bytes) -> None:
|
||||
jar = release_dir / f"dbx-agent-demo-{version}.jar"
|
||||
jar.write_bytes(jar_payload)
|
||||
registry = {
|
||||
"jres": {"21": {"version": "21", "platforms": {}}},
|
||||
"drivers": {
|
||||
"demo": {
|
||||
"version": version,
|
||||
"label": "Demo",
|
||||
"min_app_version": "0.6.0",
|
||||
"jre": "21",
|
||||
"jar": {"url": f"https://example.com/{jar.name}", "size": jar.stat().st_size},
|
||||
}
|
||||
},
|
||||
}
|
||||
(release_dir / "agent-registry.json").write_text(json.dumps(registry), encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _zstd_available() -> bool:
|
||||
try:
|
||||
subprocess.run(["zstd", "--version"], capture_output=True, check=True)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return False
|
||||
|
||||
def _build_previous_release(self, release_dir: Path) -> None:
|
||||
filler = self._incompressible_filler(96 * 1024)
|
||||
jar_v1 = b"PK-demo-jar-v1\x00" + filler + b"\x00trailer-v1"
|
||||
self._write_release(release_dir, "0.1.0", jar_v1)
|
||||
build_driver_zips(release_dir)
|
||||
registry = json.loads((release_dir / "agent-registry.json").read_text(encoding="utf-8"))
|
||||
package = release_dir / "dbx-agent-demo-0.1.0.tar.zst"
|
||||
# Point the previous registry at the local package so the generator
|
||||
# fetches its base the same way production fetches the previous release.
|
||||
registry["drivers"]["demo"]["jar"]["url"] = package.resolve().as_uri()
|
||||
(release_dir / "previous-agent-registry.json").write_text(json.dumps(registry), encoding="utf-8")
|
||||
|
||||
def test_delta_patch_generated_and_registry_annotated(self) -> None:
|
||||
if not self._zstd_available():
|
||||
self.skipTest("zstd CLI is unavailable")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
release_dir = Path(temp_dir)
|
||||
self._build_previous_release(release_dir)
|
||||
|
||||
filler = self._incompressible_filler(96 * 1024)
|
||||
jar_v2 = b"PK-demo-jar-v2\x00" + filler + b"\x00trailer-v2"
|
||||
self._write_release(release_dir, "0.2.0", jar_v2)
|
||||
previous = json.loads((release_dir / "previous-agent-registry.json").read_text(encoding="utf-8"))
|
||||
build_driver_zips(release_dir, previous, delta_min_full_size=0)
|
||||
|
||||
package_v2 = release_dir / "dbx-agent-demo-0.2.0.tar.zst"
|
||||
delta_path = release_dir / "dbx-agent-demo-0.1.0-to-0.2.0.tar.zst.delta"
|
||||
self.assertTrue(delta_path.is_file(), "delta file must be written into the release dir")
|
||||
self.assertLess(delta_path.stat().st_size, package_v2.stat().st_size)
|
||||
|
||||
registry = json.loads((release_dir / "agent-registry.json").read_text(encoding="utf-8"))
|
||||
delta = registry["drivers"]["demo"]["jar"].get("delta")
|
||||
self.assertIsNotNone(delta, "registry must carry the delta block")
|
||||
self.assertEqual(delta["base_version"], "0.1.0")
|
||||
self.assertEqual(delta["size"], delta_path.stat().st_size)
|
||||
self.assertEqual(delta["sha256"], hashlib.sha256(delta_path.read_bytes()).hexdigest())
|
||||
self.assertTrue(delta["url"].endswith(delta_path.name))
|
||||
self.assertEqual(delta["url"].rpartition("/")[0], "https://example.com")
|
||||
|
||||
# The delta must reconstruct the exact full package from the base.
|
||||
reconstructed = release_dir / "reconstructed.tar.zst"
|
||||
subprocess.run(
|
||||
[
|
||||
"zstd", "-q", "-d", "--patch-from",
|
||||
str(release_dir / "dbx-agent-demo-0.1.0.tar.zst"),
|
||||
str(delta_path), "-o", str(reconstructed), "--force",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
self.assertEqual(reconstructed.read_bytes(), package_v2.read_bytes())
|
||||
|
||||
def test_delta_skipped_when_ratio_exceeds_threshold(self) -> None:
|
||||
if not self._zstd_available():
|
||||
self.skipTest("zstd CLI is unavailable")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
release_dir = Path(temp_dir)
|
||||
self._build_previous_release(release_dir)
|
||||
# A completely unrelated payload produces a delta as large as the
|
||||
# full package; the generator must drop it and leave no block.
|
||||
self._write_release(release_dir, "0.2.0", secrets.token_bytes(96 * 1024))
|
||||
previous = json.loads((release_dir / "previous-agent-registry.json").read_text(encoding="utf-8"))
|
||||
build_driver_zips(release_dir, previous, delta_min_full_size=0, delta_max_ratio=0.5)
|
||||
|
||||
self.assertEqual(list(release_dir.glob("*.delta")), [])
|
||||
registry = json.loads((release_dir / "agent-registry.json").read_text(encoding="utf-8"))
|
||||
self.assertIsNone(registry["drivers"]["demo"]["jar"].get("delta"))
|
||||
|
||||
def test_delta_skipped_for_unchanged_version(self) -> None:
|
||||
if not self._zstd_available():
|
||||
self.skipTest("zstd CLI is unavailable")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
release_dir = Path(temp_dir)
|
||||
self._build_previous_release(release_dir)
|
||||
# Same version in the previous registry: no delta, and the previous
|
||||
# artifact must not even be fetched (its URL is intentionally dead).
|
||||
previous = json.loads((release_dir / "previous-agent-registry.json").read_text(encoding="utf-8"))
|
||||
previous["drivers"]["demo"]["jar"]["url"] = "https://example.invalid/no-such-package.tar.zst"
|
||||
build_driver_zips(release_dir, previous, delta_min_full_size=0)
|
||||
self.assertEqual(list(release_dir.glob("*.delta")), [])
|
||||
|
||||
def test_cleanup_keeps_delta_files(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
release_dir = Path(temp_dir)
|
||||
delta = release_dir / "dbx-agent-demo-0.1.0-to-0.2.0.tar.zst.delta"
|
||||
delta.write_bytes(b"delta-frame")
|
||||
raw = release_dir / "dbx-agent-demo-0.2.0.jar"
|
||||
raw.write_bytes(b"raw")
|
||||
removed = remove_raw_driver_artifacts(release_dir)
|
||||
self.assertEqual(removed, [raw])
|
||||
self.assertTrue(delta.is_file())
|
||||
|
||||
def test_release_uploads_delta_files_to_r2(self) -> None:
|
||||
workflow = (Path(__file__).resolve().parents[2] / ".github/workflows/agents-release.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn('--include "dbx-agent-*.tar.zst.delta"', workflow)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.dbx.agent.testing;
|
||||
|
||||
import java.io.*;
|
||||
import java.lang.reflect.*;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/** Live-test-only wrapper. All SQL goes through the real bundled JDBC driver. */
|
||||
public final class SqlServerFaultDriver implements Driver {
|
||||
private static final String PREFIX = "jdbc:dbx-fault:";
|
||||
static {
|
||||
try { DriverManager.registerDriver(new SqlServerFaultDriver()); }
|
||||
catch (SQLException error) { throw new ExceptionInInitializerError(error); }
|
||||
}
|
||||
public Connection connect(String url, Properties info) throws SQLException {
|
||||
if (!acceptsURL(url)) return null;
|
||||
String delegateClass = null, mode = null;
|
||||
int controlPort = 0;
|
||||
StringBuilder delegateUrl = new StringBuilder();
|
||||
for (String part : url.substring(PREFIX.length()).split(";")) {
|
||||
if (part.startsWith("dbxFaultDriver=")) delegateClass = part.substring(15);
|
||||
else if (part.startsWith("dbxFaultControlPort=")) controlPort = Integer.parseInt(part.substring(20));
|
||||
else if (part.startsWith("dbxFaultMode=")) mode = part.substring(13);
|
||||
else { if (delegateUrl.length() > 0) delegateUrl.append(';'); delegateUrl.append(part); }
|
||||
}
|
||||
try {
|
||||
Driver delegate = (Driver) Class.forName(delegateClass).getDeclaredConstructor().newInstance();
|
||||
Connection connection = delegate.connect(delegateUrl.toString(), info);
|
||||
if (connection == null) throw new SQLException("Fault fixture delegate rejected URL");
|
||||
final int port = controlPort;
|
||||
final String fault = mode;
|
||||
return (Connection) java.lang.reflect.Proxy.newProxyInstance(SqlServerFaultDriver.class.getClassLoader(),
|
||||
new Class<?>[] {Connection.class}, (proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ((name.equals("commit") && "commit_response_loss".equals(fault))
|
||||
|| (name.equals("rollback") && "rollback_response_loss".equals(fault))) signal(port, "ARM");
|
||||
if (name.equals("close") && "cleanup_failure".equals(fault)) signal(port, "CLOSE_ENTERED");
|
||||
Object result;
|
||||
try { result = method.invoke(connection, args); }
|
||||
catch (InvocationTargetException error) { throw error.getCause(); }
|
||||
if (name.equals("close") && "cleanup_failure".equals(fault)) {
|
||||
signal(port, "CLOSE_FAILED");
|
||||
throw new SQLException("Injected close failure after actual JDBC close", "HY000");
|
||||
}
|
||||
return result;
|
||||
});
|
||||
} catch (SQLException error) { throw error; }
|
||||
catch (ReflectiveOperationException error) { throw new SQLException("Cannot load real fault fixture delegate", error); }
|
||||
}
|
||||
private static void signal(int port, String command) throws SQLException {
|
||||
try (Socket socket = new Socket()) {
|
||||
socket.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), port), 3000);
|
||||
socket.setSoTimeout(3000);
|
||||
socket.getOutputStream().write((command + "\n").getBytes(StandardCharsets.US_ASCII));
|
||||
if (socket.getInputStream().read() != 'K') throw new IOException("Fault controller did not acknowledge");
|
||||
} catch (IOException error) { throw new SQLException("Fault controller failed", error); }
|
||||
}
|
||||
public boolean acceptsURL(String url) { return url != null && url.startsWith(PREFIX); }
|
||||
public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) { return new DriverPropertyInfo[0]; }
|
||||
public int getMajorVersion() { return 1; }
|
||||
public int getMinorVersion() { return 0; }
|
||||
public boolean jdbcCompliant() { return false; }
|
||||
public Logger getParentLogger() { return Logger.getLogger("com.dbx.agent.testing"); }
|
||||
}
|
||||
+38
-38
@@ -1,51 +1,51 @@
|
||||
{
|
||||
"access": "0.1.70",
|
||||
"bigquery": "0.1.74",
|
||||
"cache": "0.1.18",
|
||||
"access": "0.1.73",
|
||||
"bigquery": "0.1.77",
|
||||
"cache": "0.1.22",
|
||||
"cassandra": "0.1.53",
|
||||
"dameng": "0.1.84",
|
||||
"databend": "0.1.61",
|
||||
"databricks": "0.1.68",
|
||||
"db2": "0.1.74",
|
||||
"duckdb": "0.1.28",
|
||||
"exasol": "0.1.68",
|
||||
"firebird": "0.1.69",
|
||||
"gbase8a": "0.1.68",
|
||||
"gbase8s": "0.1.63",
|
||||
"goldendb": "0.1.74",
|
||||
"h2": "0.1.74",
|
||||
"h2-legacy": "0.1.43",
|
||||
"dameng": "0.1.87",
|
||||
"databend": "0.1.64",
|
||||
"databricks": "0.1.71",
|
||||
"db2": "0.1.77",
|
||||
"duckdb": "0.1.29",
|
||||
"exasol": "0.1.71",
|
||||
"firebird": "0.1.73",
|
||||
"gbase8a": "0.1.71",
|
||||
"gbase8s": "0.1.66",
|
||||
"goldendb": "0.1.77",
|
||||
"h2": "0.1.77",
|
||||
"h2-legacy": "0.1.46",
|
||||
"hive": "0.1.58",
|
||||
"argo": "0.1.2",
|
||||
"ignite": "0.1.26",
|
||||
"ignite3": "0.1.26",
|
||||
"spark": "0.1.45",
|
||||
"informix": "0.1.74",
|
||||
"ignite": "0.1.29",
|
||||
"ignite3": "0.1.29",
|
||||
"spark": "0.1.48",
|
||||
"informix": "0.1.77",
|
||||
"kingbase": "0.1.61",
|
||||
"kylin": "0.1.73",
|
||||
"neo4j": "0.1.51",
|
||||
"nebula": "0.1.1",
|
||||
"oceanbase-oracle": "0.1.70",
|
||||
"kylin": "0.1.76",
|
||||
"neo4j": "0.1.52",
|
||||
"nebula": "0.1.2",
|
||||
"oceanbase-oracle": "0.1.73",
|
||||
"oracle": "0.1.67",
|
||||
"oracle-oci": "0.1.0",
|
||||
"snowflake": "0.1.73",
|
||||
"snowflake": "0.1.76",
|
||||
"sqlite-worker": "0.1.6",
|
||||
"spanner": "0.1.26",
|
||||
"saphana": "0.1.69",
|
||||
"sundb": "0.1.73",
|
||||
"teradata": "0.1.68",
|
||||
"spanner": "0.1.29",
|
||||
"saphana": "0.1.72",
|
||||
"sundb": "0.1.77",
|
||||
"teradata": "0.1.71",
|
||||
"tdengine": "0.1.52",
|
||||
"trino": "0.1.73",
|
||||
"transwarp": "0.1.4",
|
||||
"trino": "0.1.76",
|
||||
"transwarp": "0.1.7",
|
||||
"vastbase": "0.1.53",
|
||||
"vertica": "0.1.68",
|
||||
"mongodb": "0.1.76",
|
||||
"highgo": "0.1.71",
|
||||
"uxdb": "0.1.40",
|
||||
"yashandb": "0.1.69",
|
||||
"oscar": "0.1.48",
|
||||
"vertica": "0.1.71",
|
||||
"mongodb": "0.1.80",
|
||||
"highgo": "0.1.74",
|
||||
"uxdb": "0.1.43",
|
||||
"yashandb": "0.1.72",
|
||||
"oscar": "0.1.51",
|
||||
"xugu": "0.1.49",
|
||||
"iris": "0.1.67",
|
||||
"iris": "0.1.70",
|
||||
"iotdb": "0.1.45",
|
||||
"etcd": "0.1.45",
|
||||
"etcd2": "0.1.3",
|
||||
@@ -53,5 +53,5 @@
|
||||
"kafka": "0.1.16",
|
||||
"rocketmq": "0.1.15",
|
||||
"rabbitmq": "0.1.14",
|
||||
"sqlserver-legacy": "0.1.47"
|
||||
"sqlserver-legacy": "0.1.50"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 290 191" width="290" height="191">
|
||||
<path fill="#e42528" d="M235.298 126.699c0 12.001-6.323 17.882-18.1 18.09v.01H72.399v-.01c-11.777-.208-18.1-6.089-18.1-18.09s6.323-17.882 18.1-18.09v-.01h144.799v.01c11.777.208 18.1 6.089 18.1 18.09m-18.1 27.159v-.01H72.399v.01c-11.777.208-18.1 6.09-18.1 18.091s6.323 17.882 18.1 18.09v.01h144.799v-.01c11.776-.208 18.1-6.089 18.1-18.09s-6.323-17.883-18.1-18.091m45.25-99.538v-.01c-11.777.208-18.101 6.089-18.101 18.09v99.549c0 12.001 6.323 17.882 18.101 18.09v-.021c17.665-.623 27.149-18.266 27.149-54.27V90.5c.001-24.002-9.484-35.764-27.149-36.18M27.15 54.31v.01C9.484 54.736 0 66.497 0 90.5v45.249c0 36.004 9.484 53.646 27.15 54.27v.021c11.777-.208 18.099-6.089 18.099-18.09V72.4c0-12.001-6.322-17.882-18.099-18.09m235.298-9.06c0-30.003-15.808-44.705-45.25-45.225V0H72.399v.025C42.957.545 27.15 15.247 27.15 45.25v.015c17.665.312 27.149 9.133 27.149 27.135 0 18.001 9.485 26.823 27.15 27.135v.015h126.699v-.015c17.666-.312 27.15-9.134 27.15-27.135s9.484-26.823 27.15-27.135z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
+455
-50
@@ -7,12 +7,13 @@ import { checkStartupAuthentication, logoutWeb, type StartupAuthentication } fro
|
||||
import { markStartupPhase } from "@/lib/startup/startupTiming";
|
||||
import { clearStartupPreloadRetry } from "@/lib/startup/startupPreloadRecovery";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { FileText } from "@lucide/vue";
|
||||
import { FileText, FolderPlus } from "@lucide/vue";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import AppToolbar from "@/components/layout/AppToolbar.vue";
|
||||
import AppTabBar from "@/components/layout/AppTabBar.vue";
|
||||
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "@/components/layout/groupTabBarPortal";
|
||||
import PluginShortcutBar from "@/components/plugins/PluginShortcutBar.vue";
|
||||
import SidebarDangerDialogHost from "@/components/sidebar/SidebarDangerDialogHost.vue";
|
||||
import AppSidebar from "@/components/layout/AppSidebar.vue";
|
||||
import SqlEditorWorkspace from "@/components/layout/SqlEditorWorkspace.vue";
|
||||
import { EDITOR_TOOLBAR_ACTIONS } from "@/components/layout/editorToolbarActions";
|
||||
@@ -22,6 +23,7 @@ import WelcomeScreen from "@/components/layout/WelcomeScreen.vue";
|
||||
import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useNavigationStore } from "@/stores/navigationStore";
|
||||
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
|
||||
import type { MultiDbExecutionContext } from "@/composables/useMultiDbExecution";
|
||||
import type { MultiDbExecutionTarget } from "@/types/sqlExecution";
|
||||
@@ -31,6 +33,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
|
||||
import { canDownloadAndInstallUpdate, useAppUpdater } from "@/composables/useAppUpdater";
|
||||
import { useMcpUpdateBadge } from "@/composables/useMcpUpdateBadge";
|
||||
import { useComponentUpdates, type ComponentUpdateCategory } from "@/composables/useComponentUpdates";
|
||||
@@ -68,6 +71,7 @@ import { disposeAllSqlServerActivityTraces } from "@/lib/sqlserver/sqlServerActi
|
||||
import { useVisibilityChange } from "@/composables/useVisibilityChange";
|
||||
import { useExternalSqlFileChanges } from "@/composables/useExternalSqlFileChanges";
|
||||
import { useWebDavAutoUpload } from "@/composables/useWebDavAutoUpload";
|
||||
import { readSyncMethod, readWebDavAutoUploadConfig, readWebDavBackupSelection } from "@/lib/webdav/webdavAutoUploadConfig";
|
||||
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
|
||||
import { shouldDrawDesktopWindowFrame } from "@/composables/useWindowControls";
|
||||
import { createOpenTabsRestorationBarrier, initializeDesktopOpenTabs, initializeOpenTabs, type OpenTabsRestorationBarrier } from "@/lib/app/openTabsStartup";
|
||||
@@ -95,7 +99,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
|
||||
import { activeTabExternalSqlFileTarget, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTab, unassociatedExternalSqlFileTarget, type ExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { defaultSavedQueryFileName, externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, queryEditorOpenFileAccept, queryEditorOpenFileFilters, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
|
||||
import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database";
|
||||
import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TableInfoTab, TabOutputView, TreeNode } from "@/types/database";
|
||||
import { OPEN_PLUGIN_SETTINGS, type PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
|
||||
import { parsePluginInstallDeepLink } from "@/lib/plugins/pluginInstallDeepLink";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
@@ -103,12 +107,19 @@ import { parseConnectionDeepLink, parseConnectionDeepLinkUpdate, type Connection
|
||||
import { resolveConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLinkUpdate";
|
||||
import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink";
|
||||
import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry";
|
||||
import type { GlobalNavigationEntry, GlobalNavigationKind, GlobalNavigationSurface } from "@/lib/navigation/navigationEntry";
|
||||
import { navigationEntryKey } from "@/lib/navigation/navigationEntry";
|
||||
import { canRestoreQueryNavigationEntry, captureSourceNavigationIdentity, matchesNavigationDataTab, navigationSourceTarget, navigationTableTarget, type SourceNavigationIdentity } from "@/lib/navigation/navigationRestoreTargets";
|
||||
import { restoreGlobalNavigationEntry } from "@/lib/navigation/globalNavigationExecutor";
|
||||
|
||||
import {
|
||||
isBrowserReloadShortcut,
|
||||
isBrowserTaskManagerShortcut,
|
||||
isCloseOtherTabsShortcut,
|
||||
isCloseTabShortcut,
|
||||
isCloseWindowShortcut,
|
||||
isDisconnectAllActiveConnectionsShortcut,
|
||||
isEditTableStructureShortcut,
|
||||
isExecuteSqlInNewResultTabShortcut,
|
||||
isExecuteSqlShortcut,
|
||||
isFocusSearchShortcut,
|
||||
@@ -136,7 +147,6 @@ import {
|
||||
switchToTabIndexFromShortcut,
|
||||
tabSwitcherDirectionFromShortcut,
|
||||
} from "@/lib/editor/keyboardShortcuts";
|
||||
import { createTabNavigationHistory, moveInTabNavigationHistory, recordTabVisit } from "@/lib/tabs/tabNavigationHistory";
|
||||
import { canSaveSqlTab } from "@/lib/tabs/sqlTabSaveTarget";
|
||||
import { initialTabSwitcherSelection, moveTabSwitcherSelection, tabSwitcherOrder } from "@/lib/tabs/tabSwitcher";
|
||||
import { createTabSwitcherKeyboardController } from "@/lib/tabs/tabSwitcherKeyboard";
|
||||
@@ -152,7 +162,7 @@ import { countAvailableAgentDriverUpdates } from "@/lib/connection/agentDriverUp
|
||||
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { webPath } from "@/lib/common/webPath";
|
||||
import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
|
||||
import { eventTargetAllowsNativeClipboard, shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
|
||||
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts";
|
||||
import { DATA_GRID_TYPE_COLOR_KEYS, dataGridTypeColorCssVar, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
|
||||
import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory";
|
||||
@@ -167,7 +177,7 @@ import { objectBrowserTablesToAiTreeNodes } from "@/lib/ai/objectBrowserToAiTarg
|
||||
import { aiTargetFromTab, type AiConversationBinding } from "@/lib/ai/aiConversationBinding";
|
||||
import type { AiExternalContextRequest } from "@/lib/ai/aiExternalContext";
|
||||
import { isSchemaAware, isSingleDatabase, supportsConnectionQueryActions, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
|
||||
import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { canFormatSqlForDatabaseType, formatSqlForEditing, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
|
||||
import { formatSqlSnapshotForSave } from "@/lib/sql/sqlFormatOnSave";
|
||||
import { detectAndFormatStructured } from "@/lib/sql/autoFormat";
|
||||
@@ -186,6 +196,7 @@ import { useBackgroundImage } from "@/composables/useBackgroundImage";
|
||||
import ExternalSqlFileChangeDialog from "@/components/editor/ExternalSqlFileChangeDialog.vue";
|
||||
import { resolveWindowContext } from "@/lib/app/windowContext";
|
||||
import { openDetachedTabWindow } from "@/lib/app/detachedTabWindow";
|
||||
import { FLOATING_OPEN_FILESYSTEM_EVENT, FLOATING_OPEN_WORKBENCH_EVENT, type FloatingOpenFilesystemPayload, type FloatingOpenWorkbenchPayload } from "@/lib/plugins/pluginFloatingWindow";
|
||||
import { OPEN_PLUGIN_AI_CONVERSATION, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
||||
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
|
||||
|
||||
@@ -224,8 +235,10 @@ const { t, locale: appLocale } = useI18n();
|
||||
const startupProps = defineProps<{ startupAuthentication?: StartupAuthentication }>();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const navigationStore = useNavigationStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { active: appBackgroundActive, backgroundObjectUrl: appBackgroundObjectUrl, backgroundImageStyle: appBackgroundImageStyle } = useBackgroundImage(settingsStore);
|
||||
const editorFontFamilyStyle = useEditorFontFamilyStyle();
|
||||
const { uiFontFamilyPreview } = useUiFontFamilyPreview();
|
||||
const savedSqlStore = useSavedSqlStore();
|
||||
const promptTemplateStore = usePromptTemplateStore();
|
||||
@@ -396,9 +409,11 @@ const pluginCenterActive = ref(false);
|
||||
const pluginCenterFocus = ref<PluginCenterFocus | null>(null);
|
||||
const connectionPluginProvider = ref<PluginCenterFocus | null>(null);
|
||||
const settingsReturnSurface = ref<"query" | "driverStore" | "pluginCenter" | "welcome">("welcome");
|
||||
const immediateSyncing = ref(false);
|
||||
const showDriverStore = computed(() => driverStoreTabOpen.value && driverStoreActive.value);
|
||||
const showPluginCenter = computed(() => pluginCenterTabOpen.value && pluginCenterActive.value);
|
||||
const showSettingsPage = computed(() => Boolean(settingsPageTabOpen.value && settingsStore.settingsPageActive));
|
||||
const isSpecialPageActive = computed(() => !isDetachedWindowContext && (driverStoreActive.value || pluginCenterActive.value || settingsStore.settingsPageActive));
|
||||
const showQuickOpen = ref(false);
|
||||
const quickOpenForceContent = ref(false);
|
||||
const showTabSwitcher = ref(false);
|
||||
@@ -489,7 +504,19 @@ const saveSqlName = ref("");
|
||||
// never quietly saved under a different one.
|
||||
const autoGeneratedSaveSqlName = ref<string | null>(null);
|
||||
const ROOT_SAVED_SQL_FOLDER = "__root__";
|
||||
const { selection: saveSqlFolderId, pending: saveSqlFolderCreationPending, reset: resetSaveSqlFolderSelection, invalidate: invalidateSaveSqlFolderSelection, select: selectSaveSqlFolder } = useSaveSqlFolderSelection(ROOT_SAVED_SQL_FOLDER);
|
||||
const {
|
||||
selection: saveSqlFolderId,
|
||||
pending: saveSqlFolderCreationPending,
|
||||
isCreating: isCreatingSaveSqlFolder,
|
||||
newFolderName: saveSqlNewFolderName,
|
||||
startCreating: startCreatingSaveSqlFolder,
|
||||
cancelCreating: cancelCreateSaveSqlFolder,
|
||||
confirmCreating: confirmCreateSaveSqlFolder,
|
||||
reset: resetSaveSqlFolderSelection,
|
||||
invalidate: invalidateSaveSqlFolderSelection,
|
||||
select: selectSaveSqlFolder,
|
||||
} = useSaveSqlFolderSelection(ROOT_SAVED_SQL_FOLDER);
|
||||
const saveSqlNewFolderInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
const pendingSaveAndCloseTabId = ref<string | null>(null);
|
||||
const pendingPrevActiveTabId = ref<string | null>(null);
|
||||
const pendingSaveShouldCloseTab = ref(true);
|
||||
@@ -520,9 +547,8 @@ function updatePluginAiRecommendations(tabId: string, update: PluginAiRecommenda
|
||||
const mountedPluginWorkbenchTabs = computed(() => queryStore.tabs.filter((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench));
|
||||
type PluginWorkbenchTabHandle = { refresh: () => Promise<unknown> };
|
||||
const pluginWorkbenchTabRefs = new Map<string, PluginWorkbenchTabHandle>();
|
||||
const tabNavigationHistory = ref(createTabNavigationHistory());
|
||||
let pendingTabHistoryNavigationId: string | null = null;
|
||||
let detachedEventUnlisteners: Array<() => void> = [];
|
||||
let floatingEventUnlisteners: Array<() => void> = [];
|
||||
let detachedCloseInProgress = false;
|
||||
const detachedDropTargetTabId = ref<string | null>(null);
|
||||
const showDetachedClosePrompt = ref(false);
|
||||
@@ -772,6 +798,54 @@ async function setupDetachedWindowEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
// A floating plugin window hosts a single widget and has no shell of its own, so
|
||||
// its shell-bound navigation is forwarded here (emitTo "main"): raise this window
|
||||
// and open the tab the widget asked for. Payloads are validated field by field —
|
||||
// they cross a window boundary, and a malformed one must not open a half-titled tab.
|
||||
async function raiseMainWindow() {
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
const currentWindow = getCurrentWindow();
|
||||
await currentWindow.show().catch(() => undefined);
|
||||
await currentWindow.unminimize().catch(() => undefined);
|
||||
await currentWindow.setFocus().catch(() => undefined);
|
||||
}
|
||||
|
||||
async function handleFloatingOpenWorkbench(payload: unknown) {
|
||||
const data = payload as Partial<FloatingOpenWorkbenchPayload> | null;
|
||||
if (typeof data?.pluginId !== "string" || typeof data.contributionId !== "string") return;
|
||||
const context = data.context && typeof data.context === "object" ? data.context : undefined;
|
||||
await raiseMainWindow();
|
||||
queryStore.openPluginWorkbench(data.pluginId, data.contributionId, {
|
||||
...(typeof data.title === "string" && data.title ? { title: data.title } : {}),
|
||||
...(context ? { context } : {}),
|
||||
forceNew: data.forceNew === true,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFloatingOpenFilesystem(payload: unknown) {
|
||||
const data = payload as Partial<FloatingOpenFilesystemPayload> | null;
|
||||
if (typeof data?.pluginId !== "string" || typeof data.providerId !== "string") return;
|
||||
const context = data.context && typeof data.context === "object" ? data.context : undefined;
|
||||
await raiseMainWindow();
|
||||
queryStore.openPluginFilesystem(data.pluginId, data.providerId, {
|
||||
...(typeof data.title === "string" && data.title ? { title: data.title } : {}),
|
||||
...(typeof data.rootUri === "string" && data.rootUri ? { rootUri: data.rootUri } : {}),
|
||||
...(typeof context?.connectionId === "string" ? { connectionId: context.connectionId } : {}),
|
||||
...(typeof context?.uri === "string" ? { currentUri: context.uri } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function setupFloatingWindowEvents() {
|
||||
if (!isDesktop || isDetachedWindowContext) return;
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
floatingEventUnlisteners.push(await listen(FLOATING_OPEN_WORKBENCH_EVENT, (message) => void handleFloatingOpenWorkbench(message.payload)));
|
||||
floatingEventUnlisteners.push(await listen(FLOATING_OPEN_FILESYSTEM_EVENT, (message) => void handleFloatingOpenFilesystem(message.payload)));
|
||||
// A floating window whose renderer dies cannot dismiss itself (frameless, and
|
||||
// nothing inside it runs), so the main window reaps silent ones.
|
||||
const { startFloatingWindowReaper } = await import("@/lib/plugins/pluginFloatingWindow");
|
||||
floatingEventUnlisteners.push(startFloatingWindowReaper());
|
||||
}
|
||||
|
||||
async function detachTab(tab: QueryTab, position?: { x: number; y: number }) {
|
||||
if (isDetachedWindowContext || !isDetachableTab(tab)) return;
|
||||
try {
|
||||
@@ -1013,7 +1087,7 @@ const specialPageTabs = computed(() => ({
|
||||
driverStoreActive: driverStoreActive.value,
|
||||
driverUpdateCount: showDriverStoreUpdateBadge.value,
|
||||
}));
|
||||
provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(computed(() => !isDetachedWindowContext && (driverStoreActive.value || pluginCenterActive.value || settingsStore.settingsPageActive))));
|
||||
provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(isSpecialPageActive));
|
||||
provide(EDITOR_TOOLBAR_ACTIONS, {
|
||||
canNewQuery: canCreateNewQuery,
|
||||
newQuery: (groupId: string) => {
|
||||
@@ -1176,6 +1250,35 @@ function openSettings(initialTab = "appearance", initialSection?: string) {
|
||||
activateSettingsPage();
|
||||
}
|
||||
|
||||
async function openImmediateSync() {
|
||||
const syncMethod = readSyncMethod();
|
||||
if (syncMethod !== "webdav") {
|
||||
openSettings("sync", syncMethod === "snippet" ? "sync-snippet" : "sync-local");
|
||||
return;
|
||||
}
|
||||
const autoUploadConfig = readWebDavAutoUploadConfig();
|
||||
if (!autoUploadConfig.webDavConfig) {
|
||||
openSettings("sync", "sync-webdav");
|
||||
return;
|
||||
}
|
||||
const webDavConfig = autoUploadConfig.webDavConfig;
|
||||
if (immediateSyncing.value) return;
|
||||
immediateSyncing.value = true;
|
||||
await nextTick();
|
||||
|
||||
try {
|
||||
const secretsStatus = await api.webdavSyncSecretsStatus();
|
||||
const selection = readWebDavBackupSelection();
|
||||
const includeSecrets = Boolean((selection?.includeSecrets ?? secretsStatus.enabled) && secretsStatus.enabled && secretsStatus.hasSavedPassphrase);
|
||||
const summary = await api.webdavSyncUpload(webDavConfig, settingsStore.editorSettings, undefined, includeSecrets, selection);
|
||||
toast(t("settings.syncUploadSuccess", { bytes: summary.bytes, path: summary.remotePath }), 3000);
|
||||
} catch (error: any) {
|
||||
toast(error?.message || String(error), 5000);
|
||||
} finally {
|
||||
immediateSyncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
type MainContentSurface = "query" | "settings" | "driverStore" | "pluginCenter";
|
||||
|
||||
function activateMainContentSurface(surface: MainContentSurface) {
|
||||
@@ -1450,7 +1553,7 @@ async function updateAllAvailable() {
|
||||
}
|
||||
}
|
||||
const hasSqlFileConnections = computed(() => connectionStore.connections.some((c) => supportsSqlFileExecution(c.db_type)));
|
||||
const welcomePageMode = computed(() => (settingsStore.isEditorSettingsLoaded ? settingsStore.editorSettings.welcomePageMode : "intro"));
|
||||
const welcomePageMode = computed(() => (settingsStore.isEditorSettingsLoaded ? settingsStore.editorSettings.welcomePageMode : "workspace"));
|
||||
const queryEditorDdlDatabaseType = computed(() => {
|
||||
if (!queryEditorDdlTarget.value?.connectionId) return undefined;
|
||||
return effectiveDatabaseTypeForConnection(connectionStore.getConfig(queryEditorDdlTarget.value.connectionId));
|
||||
@@ -1629,15 +1732,6 @@ watch(
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (id) {
|
||||
if (pendingTabHistoryNavigationId === id) pendingTabHistoryNavigationId = null;
|
||||
else {
|
||||
pendingTabHistoryNavigationId = null;
|
||||
tabNavigationHistory.value = recordTabVisit(tabNavigationHistory.value, id);
|
||||
}
|
||||
} else {
|
||||
pendingTabHistoryNavigationId = null;
|
||||
}
|
||||
if (id) newQueryContextSource.value = "tab";
|
||||
if (id) activateQuerySurface();
|
||||
else if (previousId) activateOpenSpecialPageFallback();
|
||||
@@ -1650,6 +1744,94 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
const sourceNavigationIdentity = new Map<string, SourceNavigationIdentity>();
|
||||
|
||||
function currentGlobalNavigationEntry(): GlobalNavigationEntry | null {
|
||||
let surface: GlobalNavigationSurface | null = null;
|
||||
if (showSettingsPage.value) surface = "settings";
|
||||
else if (showDriverStore.value) surface = "driverStore";
|
||||
else if (showPluginCenter.value) surface = "pluginCenter";
|
||||
else if (queryStore.activeTabId) surface = "query";
|
||||
if (!surface) return null;
|
||||
const tab = surface === "query" ? activeTab.value : undefined;
|
||||
if (surface === "query" && !tab) return null;
|
||||
// Keep the request identity for read-only sources, which never receive objectSource.
|
||||
for (const id of sourceNavigationIdentity.keys()) {
|
||||
if (!queryStore.tabs.some((item) => item.id === id)) sourceNavigationIdentity.delete(id);
|
||||
}
|
||||
const sourceIdentity = tab ? captureSourceNavigationIdentity(tab, sourceNavigationIdentity.get(tab.id)) : undefined;
|
||||
if (tab && sourceIdentity) sourceNavigationIdentity.set(tab.id, sourceIdentity);
|
||||
if (tab?.sourceView && !tab.ddlViewer && !sourceIdentity) return null;
|
||||
const kind: GlobalNavigationKind =
|
||||
surface !== "query"
|
||||
? "special"
|
||||
: tab?.ddlViewer
|
||||
? "ddl"
|
||||
: tab?.sourceView || tab?.objectSource
|
||||
? "objectSource"
|
||||
: tab?.mode === "data" || (tab?.mode === "mongo" && tab.tableMeta?.tableName && connectionStore.getConfig(tab.connectionId)?.db_type === "mongodb")
|
||||
? "data"
|
||||
: tab?.mode === "structure"
|
||||
? "structure"
|
||||
: "query";
|
||||
const entry: GlobalNavigationEntry = {
|
||||
id: "",
|
||||
surface,
|
||||
kind,
|
||||
tabId: tab?.id,
|
||||
title: tab?.title,
|
||||
mode: tab?.mode,
|
||||
tableInfoTab: tab?.tableInfoTab,
|
||||
sourceView: tab?.sourceView,
|
||||
initialEditing: sourceIdentity?.initialEditing,
|
||||
connectionId: tab?.connectionId,
|
||||
database: tab?.tableMeta?.database ?? tab?.database,
|
||||
catalog: tab?.tableMeta?.catalog ?? tab?.catalog,
|
||||
schema: tab?.objectSource?.schema ?? tab?.tableMeta?.schema ?? tab?.schema,
|
||||
tableName: tab?.tableMeta?.tableName ?? tab?.structureTableName ?? tab?.ddlViewer?.tableName,
|
||||
tableType: tab?.tableMeta?.tableType,
|
||||
objectName: sourceIdentity?.name ?? tab?.ddlViewer?.tableName,
|
||||
objectType: sourceIdentity?.objectType ?? tab?.ddlViewer?.objectType,
|
||||
objectSignature: sourceIdentity?.signature,
|
||||
};
|
||||
entry.id = navigationEntryKey(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
queryStore.activeTabId,
|
||||
showSettingsPage.value,
|
||||
showDriverStore.value,
|
||||
showPluginCenter.value,
|
||||
activeTab.value?.mode,
|
||||
activeTab.value?.tableInfoTab,
|
||||
activeTab.value?.sourceView,
|
||||
activeTab.value?.connectionId,
|
||||
activeTab.value?.database,
|
||||
activeTab.value?.catalog,
|
||||
activeTab.value?.schema,
|
||||
activeTab.value?.sourceLoad,
|
||||
activeTab.value?.sourceLoad?.initialEditing,
|
||||
activeTab.value?.objectSource?.name,
|
||||
activeTab.value?.objectSource?.objectType,
|
||||
activeTab.value?.objectSource?.signature,
|
||||
activeTab.value?.ddlViewer?.tableName,
|
||||
activeTab.value?.structureTableName,
|
||||
activeTab.value?.tableMeta?.schema,
|
||||
activeTab.value?.tableMeta?.catalog,
|
||||
activeTab.value?.tableMeta?.database,
|
||||
activeTab.value?.tableMeta?.tableName,
|
||||
activeTab.value?.tableMeta?.tableType,
|
||||
] as const,
|
||||
() => {
|
||||
const entry = currentGlobalNavigationEntry();
|
||||
navigationStore.record(entry);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => connectionStore.selectedTreeNodeId,
|
||||
(id) => {
|
||||
@@ -1724,6 +1906,9 @@ function applyRightSidebarPanelState(next: RightSidebarPanelState) {
|
||||
}
|
||||
|
||||
function setRightSidebarPanelOpen(panelId: RightSidebarPanelId, open: boolean) {
|
||||
if (open && isSpecialPageActive.value) {
|
||||
activateQuerySurface();
|
||||
}
|
||||
if ((panelId === "history" && !open) || (panelId !== "history" && open)) isHistoryPanelMaximized.value = false;
|
||||
if (panelId === "ai" && !open) {
|
||||
isAiPanelMaximized.value = false;
|
||||
@@ -2148,6 +2333,7 @@ async function writeExternalSqlTab(tab: QueryTab, options: { closeAfterSave?: bo
|
||||
const result = await api.writeExternalSqlFile(tab.externalSqlPath, await formattedSqlForSave(tab), {
|
||||
expectedContentHash: options.expectedContentHash,
|
||||
expectedMissing: options.expectedMissing,
|
||||
encoding: tab.externalSqlEncoding === "auto" ? "utf8" : tab.externalSqlEncoding,
|
||||
});
|
||||
if (result.kind !== "written") return "retry";
|
||||
rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema: tab.schema });
|
||||
@@ -2445,9 +2631,41 @@ async function handleSaveSqlFolderSelect(value: string) {
|
||||
}
|
||||
const tab = saveSqlDialogTab();
|
||||
if (!tab) return;
|
||||
const target = savedSqlTargetForSave(tab);
|
||||
await selectSaveSqlFolder(
|
||||
value,
|
||||
async () => (await savedSqlStore.createFolder(tab.connectionId, value)).id,
|
||||
async () => (await savedSqlStore.createFolder(target.connectionId, value)).id,
|
||||
(error: any) => toast(t("savedSql.createFolderFailed", { message: error?.message || String(error) }), 5000),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStartCreatingSaveSqlFolder() {
|
||||
startCreatingSaveSqlFolder();
|
||||
await nextTick();
|
||||
const raw = saveSqlNewFolderInputRef.value?.$el;
|
||||
const inputEl = (raw instanceof HTMLInputElement ? raw : raw?.querySelector?.("input")) as HTMLInputElement | undefined;
|
||||
inputEl?.focus();
|
||||
}
|
||||
|
||||
async function handleSaveSqlNewFolderConfirm(): Promise<boolean> {
|
||||
const name = saveSqlNewFolderName.value.trim();
|
||||
if (!name || saveSqlFolderCreationPending.value) return false;
|
||||
const tab = saveSqlDialogTab();
|
||||
if (!tab) return false;
|
||||
|
||||
const existingFolder = saveSqlFolders.value.find((f) => f.displayName === name || f.name === name);
|
||||
if (existingFolder) {
|
||||
await selectSaveSqlFolder(existingFolder.id);
|
||||
cancelCreateSaveSqlFolder();
|
||||
return true;
|
||||
}
|
||||
|
||||
const target = savedSqlTargetForSave(tab);
|
||||
return await confirmCreateSaveSqlFolder(
|
||||
async (folderName) => {
|
||||
const created = await savedSqlStore.createFolder(target.connectionId, folderName);
|
||||
return created.id;
|
||||
},
|
||||
(error: any) => toast(t("savedSql.createFolderFailed", { message: error?.message || String(error) }), 5000),
|
||||
);
|
||||
}
|
||||
@@ -2468,6 +2686,14 @@ function autoAdjustedSaveName(error: unknown, attemptedName: string): string | u
|
||||
|
||||
async function confirmSaveSqlToLibrary() {
|
||||
if (saveSqlFolderCreationPending.value) return;
|
||||
if (isCreatingSaveSqlFolder.value) {
|
||||
if (saveSqlNewFolderName.value.trim()) {
|
||||
const created = await handleSaveSqlNewFolderConfirm();
|
||||
if (!created || saveSqlFolderCreationPending.value) return;
|
||||
} else {
|
||||
cancelCreateSaveSqlFolder();
|
||||
}
|
||||
}
|
||||
const tab = saveSqlDialogTab();
|
||||
const name = saveSqlName.value.trim();
|
||||
if (!tab || !tab.sql.trim() || !name) return;
|
||||
@@ -2524,9 +2750,9 @@ async function saveExternalSqlTabAs(tab: QueryTab): Promise<boolean> {
|
||||
const isMongo = databaseType === "mongodb";
|
||||
const filterExtension = currentFileName.includes(".") ? currentFileName.split(".").pop()?.toLowerCase() : isMongo ? "js" : undefined;
|
||||
const defaultName = currentFileName || defaultSavedQueryFileName(tab.title, databaseType);
|
||||
const saved = await api.saveExternalSqlFile(defaultName, await formattedSqlForSave(tab), filterExtension);
|
||||
const saved = await api.saveExternalSqlFile(defaultName, await formattedSqlForSave(tab), filterExtension, tab.externalSqlEncoding === "auto" ? "utf8" : tab.externalSqlEncoding);
|
||||
if (!saved) return false;
|
||||
queryStore.linkExternalSqlPath(tab.id, saved.path, sqlFileTitleFromPath(saved.path), saved.version);
|
||||
queryStore.linkExternalSqlPath(tab.id, saved.path, sqlFileTitleFromPath(saved.path), saved.version, tab.externalSqlEncoding);
|
||||
rememberExternalSqlFileTarget(saved.path, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema: tab.schema });
|
||||
invalidateSaveSqlFolderSelection();
|
||||
showSaveSqlDialog.value = false;
|
||||
@@ -2580,9 +2806,9 @@ async function openSqlFile() {
|
||||
if (path) {
|
||||
const sqlPath = path as string;
|
||||
openedSqlPath = sqlPath;
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(sqlPath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(sqlPath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb), "auto");
|
||||
queryStore.updateSql(tab.id, snapshot.content);
|
||||
queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath), snapshot.version);
|
||||
queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath), snapshot.version, snapshot.encoding);
|
||||
applyExternalSqlFileTarget(tab, sqlPath);
|
||||
}
|
||||
} else {
|
||||
@@ -2640,11 +2866,11 @@ async function openSqlFilePath(path: string) {
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
await desktopOpenTabsRestorationBarrier?.settled;
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb), "auto");
|
||||
// Startup and OS-open events have no initiating editor tab. Keep new files
|
||||
// unassociated even if persisted tab restoration has since selected one.
|
||||
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema);
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema, undefined, snapshot.encoding);
|
||||
} catch (e: any) {
|
||||
toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
|
||||
}
|
||||
@@ -3178,9 +3404,9 @@ async function changeActiveConnection(tabId: string, connectionId: string) {
|
||||
queryStore.updateDatabase(tab.id, database);
|
||||
isCurrentTarget = queryStore.createExecutionTargetGuard(tab.id);
|
||||
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database, catalog: undefined, schema: undefined });
|
||||
if (connection.default_schema || connection.db_type === "oracle") {
|
||||
if (connection.default_schema || connection.db_type === "oracle" || connection.db_type === "oceanbase-oracle") {
|
||||
try {
|
||||
// A configured default wins. Otherwise Oracle returns the session's current schema first.
|
||||
// A configured default wins. Otherwise Oracle/OB returns the session's current schema first.
|
||||
const orderedSchemas = connection.default_schema ? [] : await api.listSchemas(connectionId, database);
|
||||
if (!isCurrentTarget()) return;
|
||||
const schema = schemaAfterConnectionSwitch(connection.db_type, orderedSchemas, connection.default_schema);
|
||||
@@ -3417,7 +3643,7 @@ async function handleQuickOpenSelect(item: any) {
|
||||
try {
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
|
||||
const target = resolveExternalSqlFileTargetForActiveTab(item.filePath, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId));
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema, { line: item.line ?? 1, column: item.column });
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema, { line: item.line ?? 1, column: item.column }, snapshot.encoding);
|
||||
} catch (e: any) {
|
||||
toast(
|
||||
externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)),
|
||||
@@ -3431,7 +3657,7 @@ async function handleQuickOpenSelect(item: any) {
|
||||
try {
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
|
||||
const target = resolveExternalSqlFileTargetForActiveTab(item.filePath, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId));
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema);
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema, undefined, snapshot.encoding);
|
||||
} catch (e: any) {
|
||||
toast(
|
||||
externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)),
|
||||
@@ -3612,6 +3838,8 @@ function activateQueryTab(tabId: string): boolean {
|
||||
if (!queryStore.activateTab(tabId)) return false;
|
||||
activateQuerySurface();
|
||||
pluginCenterActive.value = false;
|
||||
isHistoryPanelMaximized.value = false;
|
||||
isAiPanelMaximized.value = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3628,17 +3856,130 @@ function activateAdjacentTab(direction: -1 | 1): boolean {
|
||||
return activateTabByIndex(nextIndex);
|
||||
}
|
||||
|
||||
function activateTabFromHistory(direction: -1 | 1): boolean {
|
||||
const move = moveInTabNavigationHistory(tabNavigationHistory.value, direction, new Set(queryStore.tabs.map((tab) => tab.id)), queryStore.activeTabId);
|
||||
if (!move) return false;
|
||||
function isGlobalNavigationEntryAvailable(candidate: GlobalNavigationEntry): boolean {
|
||||
if (candidate.surface === "query") {
|
||||
if (candidate.kind === "objectSource") return navigationSourceTarget(candidate) !== null;
|
||||
if (candidate.kind === "data" || candidate.kind === "structure") return navigationTableTarget(candidate) !== null;
|
||||
return Boolean(candidate.tabId && queryStore.tabs.some((tab) => tab.id === candidate.tabId));
|
||||
}
|
||||
if (candidate.surface === "settings") return settingsPageTabOpen.value;
|
||||
if (candidate.surface === "driverStore") return driverStoreTabOpen.value;
|
||||
if (candidate.surface === "pluginCenter") return pluginCenterTabOpen.value;
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousHistory = tabNavigationHistory.value;
|
||||
tabNavigationHistory.value = move.history;
|
||||
pendingTabHistoryNavigationId = move.tabId;
|
||||
if (activateQueryTab(move.tabId)) return true;
|
||||
function canRestoreGlobalNavigationEntry(entry: GlobalNavigationEntry): boolean {
|
||||
return canRestoreQueryNavigationEntry(entry, queryStore.tabs, activeTab.value, settingsStore.editorSettings.dataTabReuseMode);
|
||||
}
|
||||
|
||||
tabNavigationHistory.value = previousHistory;
|
||||
pendingTabHistoryNavigationId = null;
|
||||
// Decide synchronously while the keyboard event is still dispatching. Waiting
|
||||
// for I/O before preventDefault cannot stop the event's native action.
|
||||
function navigateGlobal(direction: -1 | 1): boolean {
|
||||
if (navigationStore.restoring) return false;
|
||||
const candidate = navigationStore.peek(direction, isGlobalNavigationEntryAvailable);
|
||||
if (!candidate || !canRestoreGlobalNavigationEntry(candidate)) return false;
|
||||
const previousHistory = navigationStore.snapshot();
|
||||
const entry = navigationStore.move(direction, isGlobalNavigationEntryAvailable);
|
||||
if (!entry) return false;
|
||||
const restoreSerial = navigationStore.beginRestore(entry, previousHistory);
|
||||
void completeGlobalNavigation(entry, previousHistory, restoreSerial);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function completeGlobalNavigation(entry: GlobalNavigationEntry, previousHistory: ReturnType<typeof navigationStore.snapshot>, restoreSerial: number): Promise<boolean> {
|
||||
const isCurrent = () => navigationStore.isCurrentRestore(restoreSerial);
|
||||
const accept = () => {
|
||||
const actual = currentGlobalNavigationEntry();
|
||||
return actual !== null && navigationStore.acceptRestore(restoreSerial, actual);
|
||||
};
|
||||
try {
|
||||
const restored = await restoreGlobalNavigationEntry(entry, {
|
||||
restoreQuery: (target) => restoreQueryNavigationEntry(target, isCurrent, accept),
|
||||
activateSettings: () => {
|
||||
activateSettingsPage();
|
||||
accept();
|
||||
},
|
||||
activateDriverStore: () => {
|
||||
openDriverStorePage();
|
||||
accept();
|
||||
},
|
||||
activatePluginCenter: () => {
|
||||
openPluginCenterPage();
|
||||
accept();
|
||||
},
|
||||
});
|
||||
if (!isCurrent()) return false;
|
||||
if (!restored) {
|
||||
navigationStore.restoreSnapshot(previousHistory);
|
||||
return false;
|
||||
}
|
||||
await nextTick();
|
||||
return isCurrent();
|
||||
} catch (error) {
|
||||
if (isCurrent()) navigationStore.restoreSnapshot(previousHistory);
|
||||
console.error("[DBX] Navigation restore failed", error);
|
||||
return false;
|
||||
} finally {
|
||||
navigationStore.endRestore(restoreSerial);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreQueryNavigationEntry(entry: GlobalNavigationEntry, isCurrent: () => boolean = () => true, accept: () => boolean = () => true): Promise<boolean> {
|
||||
const activate = (id: string) => isCurrent() && activateQueryTab(id) && accept();
|
||||
if (!isCurrent()) return false;
|
||||
const existingTab = entry.tabId ? queryStore.tabs.find((tab) => tab.id === entry.tabId) : undefined;
|
||||
if (entry.kind === "objectSource" && entry.connectionId && entry.database && entry.objectName && entry.objectType) {
|
||||
if (existingTab && existingTab.sourceView) return activate(existingTab.id);
|
||||
const target = navigationSourceTarget(entry);
|
||||
if (!target) return false;
|
||||
return activate(queryStore.openObjectSourceTabPending(target));
|
||||
}
|
||||
if (entry.kind === "data" && entry.connectionId && entry.database && entry.tableName) {
|
||||
const matching = existingTab && matchesNavigationDataTab(existingTab, entry) ? existingTab : queryStore.tabs.find((tab) => matchesNavigationDataTab(tab, entry));
|
||||
if (matching) {
|
||||
matching.tableInfoTab = entry.tableInfoTab as TableInfoTab | undefined;
|
||||
return activate(matching.id);
|
||||
}
|
||||
if (!canRestoreGlobalNavigationEntry(entry)) return false;
|
||||
const target = navigationTableTarget(entry);
|
||||
if (!target) return false;
|
||||
// Selection completes navigation; database I/O may continue in that tab.
|
||||
// Never infer the target from activeTabId after an asynchronous operation.
|
||||
return new Promise<boolean>((resolve) => {
|
||||
void openObjectBrowserTableTarget(target, {
|
||||
isCurrent,
|
||||
onOpened: (tabId) => {
|
||||
if (!isCurrent()) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const tab = queryStore.tabs.find((item) => item.id === tabId);
|
||||
// Opening canonicalizes legacy database-as-schema identities (MySQL).
|
||||
const schema = connectionObjectTreeNodeSchema(connectionStore.getConfig(target.connectionId), target.database, target.schema);
|
||||
if (!tab || !matchesNavigationDataTab(tab, { ...entry, schema })) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
tab.tableInfoTab = entry.tableInfoTab as TableInfoTab | undefined;
|
||||
resolve(activate(tabId));
|
||||
},
|
||||
}).then(
|
||||
() => resolve(false),
|
||||
(error) => {
|
||||
console.error("[DBX] Navigation target loading failed", error);
|
||||
resolve(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
if (entry.kind === "structure" && entry.connectionId && entry.database && entry.tableName) {
|
||||
return activate(queryStore.openTableStructure(entry.connectionId, entry.database, entry.schema, entry.tableName, entry.tableInfoTab as TableInfoTab | undefined, undefined, entry.catalog));
|
||||
}
|
||||
if (existingTab) {
|
||||
existingTab.tableInfoTab = entry.tableInfoTab as TableInfoTab | undefined;
|
||||
existingTab.sourceView = entry.sourceView;
|
||||
return activate(existingTab.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3833,6 +4174,14 @@ async function handleKeydown(e: KeyboardEvent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isEditTableStructureShortcut(e, shortcuts) && !showSettingsPage.value && !showPluginCenter.value && !showDriverStore.value) {
|
||||
const target = e.target instanceof Element ? e.target : null;
|
||||
if (!target?.closest('[role="dialog"], [role="alertdialog"]') && !eventTargetAllowsNativeClipboard(e) && contentAreaRef.value?.openTableStructureEditor?.()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Grid-scoped shortcuts normally win inside DataGrid. Keep that precedence
|
||||
// for a data tab even when focus is in a sibling input, where the grid's
|
||||
// local listener intentionally leaves native editing untouched.
|
||||
@@ -3928,7 +4277,7 @@ async function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (handleTabHistoryNavigationShortcut(e, shortcuts, activateTabFromHistory)) {
|
||||
if (handleTabHistoryNavigationShortcut(e, shortcuts, navigateGlobal)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
@@ -3953,6 +4302,24 @@ async function handleKeydown(e: KeyboardEvent) {
|
||||
appTabBarRef.value?.closeOtherActiveTabs();
|
||||
return;
|
||||
}
|
||||
if (isDisconnectAllActiveConnectionsShortcut(e, shortcuts)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void appSidebarRef.value?.disconnectAllActiveConnections();
|
||||
return;
|
||||
}
|
||||
if (isCloseWindowShortcut(e, shortcuts)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDetachedWindowContext) {
|
||||
void requestDetachedReturn("close");
|
||||
return;
|
||||
}
|
||||
if (isDesktop) {
|
||||
void api.requestAppClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isCloseTabShortcut(e, shortcuts)) {
|
||||
e.preventDefault();
|
||||
await closeActiveSurface();
|
||||
@@ -4324,6 +4691,7 @@ onMounted(async () => {
|
||||
});
|
||||
setupCloseActionPromptListener();
|
||||
void setupDetachedWindowEvents();
|
||||
void setupFloatingWindowEvents();
|
||||
console.log(`[STARTUP] onMounted sync done: ${(performance.now() - mountStart).toFixed(0)}ms`);
|
||||
});
|
||||
|
||||
@@ -4334,6 +4702,8 @@ onUnmounted(() => {
|
||||
connectionLivenessUnlisten = null;
|
||||
detachedEventUnlisteners.forEach((unlisten) => unlisten());
|
||||
detachedEventUnlisteners = [];
|
||||
floatingEventUnlisteners.forEach((unlisten) => unlisten());
|
||||
floatingEventUnlisteners = [];
|
||||
cleanupTauriListeners();
|
||||
cleanupCloseActionPromptListener();
|
||||
if (updateCheckTimer) {
|
||||
@@ -4366,7 +4736,8 @@ onUnmounted(() => {
|
||||
<div class="h-full w-full" :style="appBackgroundImageStyle"></div>
|
||||
</div>
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<div class="h-screen w-screen max-w-full min-w-[760px] min-h-[600px] flex flex-col bg-background text-foreground overflow-hidden" :class="{ 'dbx-desktop-window-frame': drawDesktopWindowFrame }" :style="appUiFontFamilyStyle">
|
||||
<SidebarDangerDialogHost />
|
||||
<div data-app-shell class="h-screen w-screen max-w-full min-w-[760px] min-h-[600px] flex flex-col bg-background text-foreground overflow-hidden" :class="{ 'dbx-desktop-window-frame': drawDesktopWindowFrame }" :style="appUiFontFamilyStyle">
|
||||
<AppToolbar
|
||||
v-if="!isDetachedWindowContext"
|
||||
:is-dark="isDark"
|
||||
@@ -4394,6 +4765,7 @@ onUnmounted(() => {
|
||||
:has-connections="connectionStore.connections.length > 0"
|
||||
:can-new-query="canCreateNewQuery"
|
||||
:has-sql-file-connections="hasSqlFileConnections"
|
||||
:immediate-syncing="immediateSyncing"
|
||||
:show-logout="!isDesktop && needsAuth"
|
||||
@new-connection="showConnectionDialog = true"
|
||||
@expand-sidebar="setSidebarOpen(true)"
|
||||
@@ -4408,6 +4780,7 @@ onUnmounted(() => {
|
||||
@open-driver-store="openDriverStorePage"
|
||||
@open-plugin-center="openPluginCenterPage()"
|
||||
@check-updates="handleToolbarUpdateClick"
|
||||
@immediate-sync="openImmediateSync"
|
||||
@open-transfer="dialogs.showTransferDialog.value = true"
|
||||
@open-sql-file="dialogs.showSqlFileDialog.value = true"
|
||||
@open-schema-diff="dialogs.showSchemaDiffDialog.value = true"
|
||||
@@ -4436,7 +4809,7 @@ onUnmounted(() => {
|
||||
|
||||
<div
|
||||
data-editor-content
|
||||
v-show="(!isAiPanelMaximized && !isHistoryPanelMaximized) || isZenMode"
|
||||
v-show="isSpecialPageActive || (!isAiPanelMaximized && !isHistoryPanelMaximized) || isZenMode"
|
||||
:class="isDetachedWindowContext ? 'flex-1 min-w-0 overflow-hidden bg-background' : isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'"
|
||||
>
|
||||
<div class="h-full flex min-h-0 min-w-0 flex-col">
|
||||
@@ -4716,7 +5089,7 @@ onUnmounted(() => {
|
||||
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showAiPanel"
|
||||
v-show="!isHistoryPanelMaximized && !isZenMode"
|
||||
v-show="!isSpecialPageActive && !isHistoryPanelMaximized && !isZenMode"
|
||||
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[240px] max-w-full']"
|
||||
:style="isAiPanelMaximized ? {} : { width: aiPanelWidth + 'px' }"
|
||||
>
|
||||
@@ -4745,9 +5118,9 @@ onUnmounted(() => {
|
||||
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showHistory"
|
||||
v-show="!isAiPanelMaximized && !isZenMode"
|
||||
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isHistoryPanelMaximized ? 'min-w-0 flex-1' : 'shrink-0 max-w-full']"
|
||||
:style="isHistoryPanelMaximized ? {} : { width: historyWidth + 'px' }"
|
||||
v-show="!isSpecialPageActive && !isAiPanelMaximized && !isZenMode"
|
||||
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isHistoryPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[240px] max-w-full']"
|
||||
:style="isHistoryPanelMaximized ? {} : { width: historyWidth + 'px', maxWidth: 'calc(100% - 240px)' }"
|
||||
>
|
||||
<div v-if="!isHistoryPanelMaximized" class="panel-resize-handle panel-resize-handle--left" @pointerdown="startHistoryResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]" @mousedown="rememberAuxiliarySearchSurface('history')">
|
||||
@@ -4767,7 +5140,7 @@ onUnmounted(() => {
|
||||
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showSqlLibraryPanel"
|
||||
v-show="!isAiPanelMaximized && !isHistoryPanelMaximized && !isZenMode"
|
||||
v-show="!isSpecialPageActive && !isAiPanelMaximized && !isHistoryPanelMaximized && !isZenMode"
|
||||
:class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'"
|
||||
:style="{ width: sqlLibraryWidth + 'px' }"
|
||||
>
|
||||
@@ -4781,7 +5154,7 @@ onUnmounted(() => {
|
||||
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showSqlFilePanel"
|
||||
v-show="!isAiPanelMaximized && !isHistoryPanelMaximized && !isZenMode"
|
||||
v-show="!isSpecialPageActive && !isAiPanelMaximized && !isHistoryPanelMaximized && !isZenMode"
|
||||
:class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'"
|
||||
:style="{ width: sqlFilePanelWidth + 'px' }"
|
||||
>
|
||||
@@ -4943,8 +5316,40 @@ onUnmounted(() => {
|
||||
<Input v-model="saveSqlName" @keydown.enter.prevent="confirmSaveSqlToLibrary" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t("savedSql.folder") }}</label>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t("savedSql.folder") }}</label>
|
||||
<button
|
||||
v-if="!isCreatingSaveSqlFolder"
|
||||
type="button"
|
||||
data-test="save-sql-new-folder-button"
|
||||
class="flex items-center gap-1 text-xs text-primary hover:underline cursor-pointer disabled:pointer-events-none disabled:opacity-50"
|
||||
:disabled="saveSqlFolderCreationPending"
|
||||
@click="handleStartCreatingSaveSqlFolder"
|
||||
>
|
||||
<FolderPlus class="h-3 w-3" />
|
||||
<span>{{ t("savedSql.newFolderDefault") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isCreatingSaveSqlFolder" class="flex items-center gap-1.5">
|
||||
<Input
|
||||
ref="saveSqlNewFolderInputRef"
|
||||
v-model="saveSqlNewFolderName"
|
||||
data-test="save-sql-new-folder-input"
|
||||
:placeholder="t('savedSql.newFolderDefault')"
|
||||
class="h-8 text-sm flex-1"
|
||||
:disabled="saveSqlFolderCreationPending"
|
||||
@keydown.enter.prevent="handleSaveSqlNewFolderConfirm"
|
||||
@keydown.esc.prevent="cancelCreateSaveSqlFolder"
|
||||
/>
|
||||
<Button size="sm" class="h-8 px-2.5" data-test="save-sql-new-folder-confirm" :disabled="!saveSqlNewFolderName.trim() || saveSqlFolderCreationPending" @click="handleSaveSqlNewFolderConfirm">
|
||||
{{ t("common.confirm") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2" data-test="save-sql-new-folder-cancel" :disabled="saveSqlFolderCreationPending" @click="cancelCreateSaveSqlFolder">
|
||||
{{ t("common.cancel") }}
|
||||
</Button>
|
||||
</div>
|
||||
<SearchableSelect
|
||||
v-else
|
||||
:model-value="saveSqlFolderId"
|
||||
:options="[ROOT_SAVED_SQL_FOLDER, ...saveSqlFolders.map((f) => f.id)]"
|
||||
:display-name="saveSqlFolderDisplayName"
|
||||
@@ -5001,7 +5406,7 @@ onUnmounted(() => {
|
||||
@saved="onQueryEditorObjectSourceSaved"
|
||||
/>
|
||||
</TooltipProvider>
|
||||
<div id="dbx-query-editor-tooltip-root" class="fixed left-0 top-0 z-[70] h-0 w-0 overflow-visible" />
|
||||
<div id="dbx-query-editor-tooltip-root" class="fixed left-0 top-0 z-[70] h-0 w-0 overflow-visible" :style="editorFontFamilyStyle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ function syncHorizontalScrollbar() {
|
||||
function scrollTableHorizontally(event: WheelEvent) {
|
||||
const scroller = tableScroller.value;
|
||||
if (!scroller) return;
|
||||
const delta = event.shiftKey ? event.deltaY : Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : 0;
|
||||
const delta = event.shiftKey || event.altKey ? event.deltaY : Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : 0;
|
||||
if (!delta) return;
|
||||
const previousScrollLeft = scroller.scrollLeft;
|
||||
scroller.scrollLeft += delta;
|
||||
|
||||
@@ -33,12 +33,15 @@ import {
|
||||
type DatabaseBackupSchedule,
|
||||
} from "@/lib/backup/scheduledDatabaseBackup";
|
||||
import { databaseBackupTableSelectionScopeKey, normalizeDatabaseBackupTableTargets, type DatabaseBackupTableSelectionState } from "@/lib/backup/scheduledDatabaseBackup";
|
||||
import { getLastBackupDirectory, setLastBackupDirectory } from "@/lib/export/exportPath";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, heartbeat, destinationRoot, error: backupError, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRuns, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
|
||||
const desktop = isTauriRuntime();
|
||||
const backgroundEnabled = ref(false);
|
||||
@@ -155,13 +158,26 @@ watch(historyConnectionPickerOpen, (open) => {
|
||||
if (!open) historyConnectionSearch.value = "";
|
||||
});
|
||||
|
||||
/**
|
||||
* 解析新建备份时的默认「备份目录」。
|
||||
* 优先级:服务端根目录(Web 端) > 上次备份目录 > 设置中的「首选导出路径」。
|
||||
* Desktop 端后端 root 恒为 null(见 src-tauri/background_backup.rs),
|
||||
* 此前默认值恒为空串,导致每次新建备份都要重新选择目录(issue #11317)。
|
||||
*/
|
||||
function defaultBackupDestinationDirectory(): string {
|
||||
if (destinationRoot.value) return destinationRoot.value;
|
||||
const remembered = getLastBackupDirectory();
|
||||
if (remembered) return remembered;
|
||||
return settingsStore.editorSettings.preferredExportPath?.trim() || "";
|
||||
}
|
||||
|
||||
function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupExecutionConfig {
|
||||
return {
|
||||
connectionId,
|
||||
databases: [],
|
||||
tableFilterMode: "all",
|
||||
tablePatterns: [],
|
||||
destinationDirectory: destinationRoot.value || "",
|
||||
destinationDirectory: defaultBackupDestinationDirectory(),
|
||||
includeStructure: true,
|
||||
includeData: true,
|
||||
includeObjects: true,
|
||||
@@ -455,8 +471,13 @@ function toggleDatabase(database: string) {
|
||||
|
||||
async function chooseDestination() {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({ directory: true, multiple: false, title: t("databaseBackup.selectDestination") });
|
||||
if (typeof selected === "string") activeDraft.value.destinationDirectory = selected;
|
||||
// 传入当前目录,让系统选择框直接定位到上次使用的位置(issue #11317)
|
||||
const selected = await open({ directory: true, multiple: false, defaultPath: activeDraft.value.destinationDirectory || undefined, title: t("databaseBackup.selectDestination") });
|
||||
if (typeof selected === "string") {
|
||||
activeDraft.value.destinationDirectory = selected;
|
||||
// 记住本次选择,作为下次新建备份的默认目录
|
||||
setLastBackupDirectory(selected);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSchedule() {
|
||||
@@ -464,6 +485,7 @@ async function submitSchedule() {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (desktop) await api.recordDatabaseExportDestination(draft.value.destinationDirectory);
|
||||
if (desktop) setLastBackupDirectory(draft.value.destinationDirectory);
|
||||
await saveSchedule({
|
||||
...draft.value,
|
||||
databases: allDatabases.value ? [] : [...selectedDatabases.value],
|
||||
@@ -492,6 +514,7 @@ async function startOneShotBackup() {
|
||||
oneShotStarting.value = true;
|
||||
try {
|
||||
if (desktop) await api.recordDatabaseExportDestination(oneShotDraft.value.destinationDirectory);
|
||||
if (desktop) setLastBackupDirectory(oneShotDraft.value.destinationDirectory);
|
||||
const run = await runOneShot(
|
||||
{
|
||||
...oneShotDraft.value,
|
||||
|
||||
+59
@@ -5,9 +5,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "../../../i18n";
|
||||
import ScheduledDatabaseBackupSettings from "../ScheduledDatabaseBackupSettings.vue";
|
||||
import type { DatabaseBackupRun, DatabaseBackupSchedule } from "../../../lib/backup/scheduledDatabaseBackup";
|
||||
import { LAST_BACKUP_DIRECTORY_STORAGE_KEY } from "../../../lib/export/exportPath";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
desktop: true,
|
||||
preferredExportPath: "",
|
||||
sqlFileSource: null as any,
|
||||
prepareDatabaseBackupRestore: vi.fn(),
|
||||
connections: [] as Array<{ id: string; name: string; db_type: string }>,
|
||||
@@ -52,6 +54,10 @@ vi.mock("@/stores/connectionStore", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({ editorSettings: { preferredExportPath: mocks.preferredExportPath } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
|
||||
useScheduledDatabaseBackups: () => ({
|
||||
schedules: { __v_isRef: true, value: mocks.schedules },
|
||||
@@ -140,6 +146,12 @@ function currentDialog(): HTMLElement {
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function destinationInput(): HTMLInputElement {
|
||||
const input = currentDialog().querySelector<HTMLInputElement>(".backup-destination-field input");
|
||||
if (!input) throw new Error("Backup destination input not found");
|
||||
return input;
|
||||
}
|
||||
|
||||
async function selectDialogOption(triggerIndex: number, optionText: string) {
|
||||
const trigger = currentDialog().querySelectorAll<HTMLButtonElement>('[data-slot="select-trigger"]')[triggerIndex];
|
||||
if (!trigger) throw new Error(`Select trigger not found: ${triggerIndex}`);
|
||||
@@ -279,6 +291,9 @@ afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
mocks.desktop = true;
|
||||
mocks.preferredExportPath = "";
|
||||
// 上次备份目录存于 localStorage,需在用例间清理,避免互相污染
|
||||
window.localStorage.clear();
|
||||
mocks.sqlFileSource = null;
|
||||
mocks.prepareDatabaseBackupRestore.mockReset();
|
||||
mocks.connections.splice(0);
|
||||
@@ -607,6 +622,50 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
expect(mocks.runOneShot).toHaveBeenCalledWith(expect.objectContaining({ connectionId: "mysql-1", destinationDirectory: "/backups", databases: [] }), String(i18n.global.t("databaseBackup.oneShotName")));
|
||||
});
|
||||
|
||||
it("defaults a new backup directory to the last used backup directory", async () => {
|
||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||
window.localStorage.setItem(LAST_BACKUP_DIRECTORY_STORAGE_KEY, "/previous/backups");
|
||||
await mountSettings();
|
||||
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await flush();
|
||||
|
||||
expect(destinationInput().value).toBe("/previous/backups");
|
||||
});
|
||||
|
||||
it("falls back to the preferred export path when no backup directory was used yet", async () => {
|
||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||
mocks.preferredExportPath = "/preferred/exports";
|
||||
await mountSettings();
|
||||
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await flush();
|
||||
|
||||
expect(destinationInput().value).toBe("/preferred/exports");
|
||||
});
|
||||
|
||||
it("opens the picker at the current directory and remembers the choice", async () => {
|
||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||
window.localStorage.setItem(LAST_BACKUP_DIRECTORY_STORAGE_KEY, "/previous/backups");
|
||||
await mountSettings();
|
||||
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await flush();
|
||||
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
|
||||
await flush();
|
||||
|
||||
expect(mocks.openDirectory).toHaveBeenCalledWith(expect.objectContaining({ directory: true, defaultPath: "/previous/backups" }));
|
||||
expect(window.localStorage.getItem(LAST_BACKUP_DIRECTORY_STORAGE_KEY)).toBe("/backups");
|
||||
|
||||
// 重新挂载后再次打开「立即备份」,默认目录应为上次选择的 /backups
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
await mountSettings();
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await flush();
|
||||
expect(destinationInput().value).toBe("/backups");
|
||||
});
|
||||
|
||||
it("sends an exact one-shot table whitelist and prevents an empty selection from starting", async () => {
|
||||
mocks.connections.push({ id: "pg-1", name: "Local PostgreSQL", db_type: "postgres" });
|
||||
await mountSettings();
|
||||
|
||||
@@ -6,13 +6,13 @@ import { CanvasRenderer } from "echarts/renderers";
|
||||
import { LineChart, BarChart, PieChart } from "echarts/charts";
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
|
||||
import VChart from "vue-echarts";
|
||||
import { BarChart3, ChevronDown } from "@lucide/vue";
|
||||
import { BarChart3, Check, ChevronDown } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { axisColumnLabel, chartableColumnIndexes, toChartNumber } from "@/lib/dataGrid/chartData";
|
||||
import { axisColumnLabel, chartableColumnIndexes, buildQueryChartOption, type ChartType } from "@/lib/dataGrid/chartData";
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
|
||||
@@ -23,10 +23,28 @@ const props = defineProps<{
|
||||
const { t } = useI18n();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
type ChartType = "line" | "bar" | "pie";
|
||||
const QUERY_CHART_SHOW_LABELS_KEY = "dbx-query-chart-show-labels";
|
||||
|
||||
function readShowLabelsPreference(): boolean {
|
||||
try {
|
||||
return typeof localStorage !== "undefined" && localStorage.getItem(QUERY_CHART_SHOW_LABELS_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const chartType = ref<ChartType>("bar");
|
||||
const xColumnIndex = ref(0);
|
||||
const yColumnIndexes = ref<number[]>([]);
|
||||
const showLabels = ref(readShowLabelsPreference());
|
||||
|
||||
watch(showLabels, (val) => {
|
||||
try {
|
||||
localStorage.setItem(QUERY_CHART_SHOW_LABELS_KEY, String(val));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
const numericColumnIndexes = computed(() => chartableColumnIndexes(props.result));
|
||||
|
||||
@@ -60,65 +78,23 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function setYColumn(index: number, selected: boolean | "indeterminate") {
|
||||
function toggleYColumn(index: number) {
|
||||
const isSelected = yColumnIndexes.value.includes(index);
|
||||
if (selected === true && !isSelected) {
|
||||
yColumnIndexes.value = [...yColumnIndexes.value, index];
|
||||
} else if (selected !== true && isSelected) {
|
||||
if (isSelected) {
|
||||
yColumnIndexes.value = yColumnIndexes.value.filter((selected) => selected !== index);
|
||||
} else {
|
||||
yColumnIndexes.value = [...yColumnIndexes.value, index];
|
||||
}
|
||||
}
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const xIdx = xColumnIndex.value;
|
||||
if (xIdx < 0 || yColumnIndexes.value.length === 0) return null;
|
||||
|
||||
const xData = props.result.rows.map((row) => String(row[xIdx] ?? ""));
|
||||
|
||||
if (chartType.value === "pie") {
|
||||
const yIdx = yColumnIndexes.value[0];
|
||||
if (yIdx < 0) return null;
|
||||
return {
|
||||
tooltip: { trigger: "item" },
|
||||
legend: { bottom: 0, textStyle: { color: isDark.value ? "#ccc" : "#333" } },
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["30%", "60%"],
|
||||
data: xData.map((name, i) => ({
|
||||
name,
|
||||
value: toChartNumber(props.result.rows[i][yIdx]) ?? 0,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const yIndices = yColumnIndexes.value.filter((index) => index >= 0 && index < props.result.columns.length);
|
||||
|
||||
return {
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
textStyle: { color: isDark.value ? "#ccc" : "#333" },
|
||||
},
|
||||
grid: { left: 60, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: {
|
||||
type: "category" as const,
|
||||
data: xData,
|
||||
axisLabel: { color: isDark.value ? "#aaa" : "#666" },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value" as const,
|
||||
axisLabel: { color: isDark.value ? "#aaa" : "#666" },
|
||||
},
|
||||
series: yIndices.map((yIdx) => ({
|
||||
name: axisColumnLabel(props.result.columns, yIdx),
|
||||
type: chartType.value,
|
||||
data: props.result.rows.map((row) => toChartNumber(row[yIdx]) ?? 0),
|
||||
smooth: chartType.value === "line",
|
||||
})),
|
||||
};
|
||||
return buildQueryChartOption(props.result, {
|
||||
chartType: chartType.value,
|
||||
xColumnIndex: xColumnIndex.value,
|
||||
yColumnIndexes: yColumnIndexes.value,
|
||||
showLabels: showLabels.value,
|
||||
isDark: isDark.value,
|
||||
});
|
||||
});
|
||||
|
||||
const hasData = computed(() => props.result.rows.length > 0 && numericColumnIndexes.value.length > 0);
|
||||
@@ -163,12 +139,27 @@ const hasData = computed(() => props.result.rows.length > 0 && numericColumnInde
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-56" align="start" @close-auto-focus.prevent>
|
||||
<DropdownMenuCheckboxItem v-for="col in numericColumnOptions" :key="col.index" :model-value="yColumnIndexes.includes(col.index)" :class="['text-xs', yColumnIndexes.includes(col.index) ? 'bg-primary/10' : '']" @select.prevent @update:model-value="setYColumn(col.index, $event)">
|
||||
<DropdownMenuItem
|
||||
v-for="col in numericColumnOptions"
|
||||
:key="col.index"
|
||||
role="menuitemcheckbox"
|
||||
:aria-checked="yColumnIndexes.includes(col.index)"
|
||||
class="text-xs flex items-center gap-2 cursor-pointer"
|
||||
:class="yColumnIndexes.includes(col.index) ? 'bg-primary/10' : ''"
|
||||
@select.prevent="toggleYColumn(col.index)"
|
||||
>
|
||||
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border" :class="yColumnIndexes.includes(col.index) ? 'border-primary bg-primary text-primary-foreground' : 'border-input'">
|
||||
<Check v-if="yColumnIndexes.includes(col.index)" class="h-2.5 w-2.5" />
|
||||
</span>
|
||||
<span class="truncate">{{ col.label }}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<span class="h-4 w-px bg-border" />
|
||||
<Button size="sm" :variant="showLabels ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs" data-testid="query-chart-show-labels-btn" @click="showLabels = !showLabels">
|
||||
{{ t("chart.showLabels") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 p-2">
|
||||
<VChart v-if="chartOption" :option="chartOption" autoresize class="h-full w-full" />
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import QueryChart from "../QueryChart.vue";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useTheme", () => ({
|
||||
useTheme: () => ({ isDark: { value: false } }),
|
||||
}));
|
||||
|
||||
vi.mock("echarts/core", () => ({ use: vi.fn() }));
|
||||
vi.mock("echarts/renderers", () => ({ CanvasRenderer: {} }));
|
||||
vi.mock("echarts/charts", () => ({ LineChart: {}, BarChart: {}, PieChart: {} }));
|
||||
vi.mock("echarts/components", () => ({ GridComponent: {}, LegendComponent: {}, TooltipComponent: {} }));
|
||||
|
||||
vi.mock("vue-echarts", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: "VChart",
|
||||
props: { option: { type: Object, default: () => ({}) } },
|
||||
setup: (props) => () => h("div", { "data-chart-option": JSON.stringify(props.option) }),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function createQueryResult(): QueryResult {
|
||||
return {
|
||||
columns: ["item", "amount"],
|
||||
rows: [
|
||||
["A", 100],
|
||||
["B", 200],
|
||||
],
|
||||
affected_rows: 2,
|
||||
execution_time_ms: 5,
|
||||
};
|
||||
}
|
||||
|
||||
describe("QueryChart", () => {
|
||||
let app: App | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (app) {
|
||||
app.unmount();
|
||||
app = null;
|
||||
}
|
||||
if (container) {
|
||||
container.remove();
|
||||
container = null;
|
||||
}
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders showLabels toggle button and updates chart option on click", async () => {
|
||||
app = createApp(QueryChart, { result: createQueryResult() });
|
||||
app.mount(container!);
|
||||
await nextTick();
|
||||
|
||||
const btn = container!.querySelector('[data-testid="query-chart-show-labels-btn"]') as HTMLButtonElement;
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn.textContent).toContain("chart.showLabels");
|
||||
|
||||
const vchart = container!.querySelector("[data-chart-option]") as HTMLDivElement;
|
||||
let option = JSON.parse(vchart.getAttribute("data-chart-option") || "{}");
|
||||
expect(option.series[0].label.show).toBe(false);
|
||||
|
||||
btn.click();
|
||||
await nextTick();
|
||||
|
||||
expect(localStorage.getItem("dbx-query-chart-show-labels")).toBe("true");
|
||||
option = JSON.parse(vchart.getAttribute("data-chart-option") || "{}");
|
||||
expect(option.series[0].label.show).toBe(true);
|
||||
expect(option.series[0].label.position).toBe("top");
|
||||
|
||||
btn.click();
|
||||
await nextTick();
|
||||
|
||||
expect(localStorage.getItem("dbx-query-chart-show-labels")).toBe("false");
|
||||
option = JSON.parse(vchart.getAttribute("data-chart-option") || "{}");
|
||||
expect(option.series[0].label.show).toBe(false);
|
||||
});
|
||||
|
||||
it("initializes showLabels from localStorage if previously enabled", async () => {
|
||||
localStorage.setItem("dbx-query-chart-show-labels", "true");
|
||||
|
||||
app = createApp(QueryChart, { result: createQueryResult() });
|
||||
app.mount(container!);
|
||||
await nextTick();
|
||||
|
||||
const vchart = container!.querySelector("[data-chart-option]") as HTMLDivElement;
|
||||
const option = JSON.parse(vchart.getAttribute("data-chart-option") || "{}");
|
||||
expect(option.series[0].label.show).toBe(true);
|
||||
});
|
||||
|
||||
it("renders check indicators for selected y columns and updates chart option on toggle", async () => {
|
||||
const multiResult: QueryResult = {
|
||||
columns: ["item", "val1", "val2"],
|
||||
rows: [
|
||||
["A", 10, 20],
|
||||
["B", 30, 40],
|
||||
],
|
||||
affected_rows: 2,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
app = createApp(QueryChart, { result: multiResult });
|
||||
app.mount(container!);
|
||||
await nextTick();
|
||||
|
||||
const trigger = container!.querySelector(".max-w-48") as HTMLButtonElement;
|
||||
expect(trigger).not.toBeNull();
|
||||
trigger.click();
|
||||
await nextTick();
|
||||
|
||||
const items = document.querySelectorAll('[role="menuitemcheckbox"]');
|
||||
expect(items.length).toBe(2);
|
||||
|
||||
// Initial state: first numeric column (val1) is selected, second (val2) is not
|
||||
expect(items[0].getAttribute("aria-checked")).toBe("true");
|
||||
expect(items[0].querySelector("svg")).not.toBeNull();
|
||||
expect(items[1].getAttribute("aria-checked")).toBe("false");
|
||||
expect(items[1].querySelector("svg")).toBeNull();
|
||||
|
||||
const vchart = container!.querySelector("[data-chart-option]") as HTMLDivElement;
|
||||
let option = JSON.parse(vchart.getAttribute("data-chart-option") || "{}");
|
||||
expect(option.series.map((s: { name: string }) => s.name)).toEqual(["val1"]);
|
||||
|
||||
// Click on item 1 (val2) to select it
|
||||
(items[1] as HTMLElement).click();
|
||||
await nextTick();
|
||||
|
||||
expect(items[0].getAttribute("aria-checked")).toBe("true");
|
||||
expect(items[0].querySelector("svg")).not.toBeNull();
|
||||
expect(items[1].getAttribute("aria-checked")).toBe("true");
|
||||
expect(items[1].querySelector("svg")).not.toBeNull();
|
||||
|
||||
option = JSON.parse(vchart.getAttribute("data-chart-option") || "{}");
|
||||
expect(option.series.map((s: { name: string }) => s.name)).toEqual(["val1", "val2"]);
|
||||
|
||||
// Click on item 0 (val1) to deselect it
|
||||
(items[0] as HTMLElement).click();
|
||||
await nextTick();
|
||||
|
||||
expect(items[0].getAttribute("aria-checked")).toBe("false");
|
||||
expect(items[0].querySelector("svg")).toBeNull();
|
||||
expect(items[1].getAttribute("aria-checked")).toBe("true");
|
||||
expect(items[1].querySelector("svg")).not.toBeNull();
|
||||
|
||||
option = JSON.parse(vchart.getAttribute("data-chart-option") || "{}");
|
||||
expect(option.series.map((s: { name: string }) => s.name)).toEqual(["val2"]);
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,7 @@ import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedV
|
||||
import { mongodbAuthFailureHint, mongoConnectionUsesOidc, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions";
|
||||
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
|
||||
import { mysqlCleartextPasswordAuthEnabled, setMysqlCleartextPasswordAuthEnabled } from "@/lib/database/mysqlConnectionOptions";
|
||||
import { supportsOracleDatabaseLinks } from "@/lib/database/oracleDatabaseLinks";
|
||||
import { applyDamengSslUrlParams, damengSslFormConfig } from "@/lib/database/damengSslOptions";
|
||||
import { DAMENG_BUILTIN_DRIVER_PROFILE, DAMENG_CUSTOM_DRIVER_PROFILE, DAMENG_DEFAULT_JDBC_DRIVER_CLASS, damengCustomJdbcUrl, damengDriverModeForConfig, defaultDamengJdbcUrl, type DamengDriverMode } from "@/lib/database/damengDriverOptions";
|
||||
import { doltSystemTablesVisible, isDoltDriverProfile, setDoltSystemTablesVisible } from "@/lib/database/doltProfile";
|
||||
@@ -440,6 +441,7 @@ const defaultForm = (): ConnectionForm => ({
|
||||
redis_key_separator: ":",
|
||||
redis_scan_page_size: REDIS_SCAN_PAGE_SIZE_DEFAULT,
|
||||
redis_key_templates: [],
|
||||
redis_key_filter: "",
|
||||
etcd_endpoints: "",
|
||||
gbase_server: "",
|
||||
informix_server: "",
|
||||
@@ -448,6 +450,7 @@ const defaultForm = (): ConnectionForm => ({
|
||||
docs_notes_path: undefined,
|
||||
read_only: false,
|
||||
show_system_schemas: false,
|
||||
show_database_links: true,
|
||||
sidebar_auto_load_all_tables: false,
|
||||
is_production: false,
|
||||
production_databases: [],
|
||||
@@ -3129,6 +3132,7 @@ watch(
|
||||
redis_key_separator: config.redis_key_separator ?? ":",
|
||||
redis_scan_page_size: config.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT,
|
||||
redis_key_templates: normalizeRedisKeyTemplates(config.redis_key_templates),
|
||||
redis_key_filter: config.redis_key_filter ?? "",
|
||||
redis_key_grouping: config.redis_key_grouping,
|
||||
etcd_endpoints: config.etcd_endpoints || "",
|
||||
gbase_server: config.gbase_server || "",
|
||||
@@ -3139,6 +3143,7 @@ watch(
|
||||
docs_notes_path: config.docs_notes_path,
|
||||
read_only: config.read_only || false,
|
||||
show_system_schemas: config.show_system_schemas || false,
|
||||
show_database_links: config.show_database_links !== false,
|
||||
sidebar_auto_load_all_tables: config.sidebar_auto_load_all_tables === true,
|
||||
is_production: config.is_production || false,
|
||||
production_databases: config.production_databases || [],
|
||||
@@ -3953,6 +3958,17 @@ const firebirdCharset = computed({
|
||||
form.value.url_params = setUrlParam(form.value.url_params, "charSet", value === "default" ? "" : value);
|
||||
},
|
||||
});
|
||||
const FIREBIRD_DATA_CHARSET_OPTIONS = ["GBK", "GB18030", "BIG5"];
|
||||
const firebirdDataCharsetItems = computed(() => {
|
||||
const current = getUrlParam(form.value.url_params, "dataCharset");
|
||||
return current && !FIREBIRD_DATA_CHARSET_OPTIONS.includes(current) ? [current, ...FIREBIRD_DATA_CHARSET_OPTIONS] : FIREBIRD_DATA_CHARSET_OPTIONS;
|
||||
});
|
||||
const firebirdDataCharset = computed({
|
||||
get: () => getUrlParam(form.value.url_params, "dataCharset") || "default",
|
||||
set: (value: string) => {
|
||||
form.value.url_params = setUrlParam(form.value.url_params, "dataCharset", value === "default" ? "" : value);
|
||||
},
|
||||
});
|
||||
const redisTlsInsecure = computed({
|
||||
get: () => getUrlParam(form.value.url_params, "insecure").toLowerCase() === "true",
|
||||
set: (value: boolean) => {
|
||||
@@ -5158,6 +5174,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
|
||||
config.redis_scan_page_size = undefined;
|
||||
config.redis_database_aliases = undefined;
|
||||
config.redis_key_templates = undefined;
|
||||
config.redis_key_filter = undefined;
|
||||
config.redis_key_grouping = undefined;
|
||||
} else if (config.redis_connection_mode === "sentinel") {
|
||||
config.redis_sentinel_master = config.redis_sentinel_master?.trim() || "";
|
||||
@@ -5192,6 +5209,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
|
||||
}
|
||||
if (config.db_type === "redis") {
|
||||
config.redis_key_separator = config.redis_key_separator?.trim() ?? ":";
|
||||
config.redis_key_filter = config.redis_key_filter?.trim() || undefined;
|
||||
const scanSize = Number(config.redis_scan_page_size);
|
||||
config.redis_scan_page_size = Number.isFinite(scanSize) && scanSize >= REDIS_SCAN_PAGE_SIZE_MIN && scanSize <= REDIS_SCAN_PAGE_SIZE_MAX ? Math.round(scanSize) : REDIS_SCAN_PAGE_SIZE_DEFAULT;
|
||||
{
|
||||
@@ -5382,6 +5400,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
|
||||
config.visible_databases = Array.isArray(config.visible_databases) && config.visible_databases.length > 0 ? config.visible_databases : undefined;
|
||||
}
|
||||
if (!config.show_system_schemas) config.show_system_schemas = undefined;
|
||||
if (config.show_database_links !== false) config.show_database_links = undefined;
|
||||
if (!config.sidebar_auto_load_all_tables) config.sidebar_auto_load_all_tables = undefined;
|
||||
if (config.visible_schemas && Object.keys(config.visible_schemas).length === 0) config.visible_schemas = undefined;
|
||||
if (config.agent_java_options && config.agent_java_options.length === 0) config.agent_java_options = undefined;
|
||||
@@ -8099,6 +8118,13 @@ function openExternalUrl(url: string) {
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.redisKeySeparator") }}</Label>
|
||||
<Input v-model="form.redis_key_separator" class="col-span-3 h-8 text-xs" placeholder=":" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label for="redis-key-filter" :class="connectionLabelTopClass">{{ t("connection.redisKeyFilter") }}</Label>
|
||||
<div class="col-span-3 space-y-1">
|
||||
<Input id="redis-key-filter" v-model="form.redis_key_filter" class="h-8 text-xs" placeholder="order:*" spellcheck="false" />
|
||||
<p class="text-xs text-muted-foreground">{{ t("connection.redisKeyFilterHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelTopClass">{{ t("connection.redisKeyTemplates") }}</Label>
|
||||
<div class="col-span-3 space-y-1">
|
||||
@@ -9383,6 +9409,20 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.db_type === 'firebird'" class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelTopClass">{{ t("connection.firebirdDataCharset") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<Select v-model="firebirdDataCharset">
|
||||
<SelectTrigger class="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{{ t("common.default") }}</SelectItem>
|
||||
<SelectItem v-for="charset in firebirdDataCharsetItems" :key="charset" :value="charset">{{ charset }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.firebirdDataCharsetHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="supportsGenericUrlParams" class="connection-url-params-row grid grid-cols-4 items-start gap-4" :class="{ 'connection-url-params-row--compact': !showGenericUrlParamsHint, 'connection-url-params-row--with-hint': showGenericUrlParamsHint }">
|
||||
<Label :class="[connectionLabelClass, 'connection-url-params-label']">{{ t("connection.urlParams") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
@@ -10271,6 +10311,13 @@ function openExternalUrl(url: string) {
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.showSystemSchemasHint") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="supportsOracleDatabaseLinks(form.db_type)" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.showDatabaseLinks") }}</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.show_database_links" class="mr-0" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.showDatabaseLinksHint") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="supportsAutomaticTableLoading" class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.tableLoading") }}</Label>
|
||||
<div class="col-span-3 grid gap-1.5">
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import { compareConnectionType } from "@/lib/database/databaseCompareCapabilities";
|
||||
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
connections: ConnectionConfig[];
|
||||
layout: SidebarLayout;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{ disabled: false },
|
||||
);
|
||||
const emit = defineEmits<{ "update:modelValue": [value: string] }>();
|
||||
const { t } = useI18n();
|
||||
const ALL_TYPES = "all";
|
||||
const selectedType = ref(ALL_TYPES);
|
||||
const selectedConnection = computed(() => props.connections.find((connection) => connection.id === props.modelValue));
|
||||
const typeOptions = computed(() => {
|
||||
const options = new Map<string, { value: string; label: string }>();
|
||||
for (const connection of props.connections) {
|
||||
const type = compareConnectionType(connection);
|
||||
if (!options.has(type.value)) options.set(type.value, type);
|
||||
}
|
||||
return [...options.values()].sort((left, right) => left.label.localeCompare(right.label));
|
||||
});
|
||||
const filteredConnections = computed(() => props.connections.filter((connection) => selectedType.value === ALL_TYPES || compareConnectionType(connection).value === selectedType.value));
|
||||
|
||||
// Programmatic changes (config restore, prefill, swap) follow the connection.
|
||||
// Clearing a connection preserves the filter the user just selected.
|
||||
watch(
|
||||
() => selectedConnection.value && compareConnectionType(selectedConnection.value).value,
|
||||
(type) => {
|
||||
if (type) selectedType.value = type;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(typeOptions, (options) => {
|
||||
if (selectedType.value !== ALL_TYPES && !options.some((option) => option.value === selectedType.value)) selectedType.value = ALL_TYPES;
|
||||
});
|
||||
|
||||
function selectType(value: unknown) {
|
||||
if (props.disabled || typeof value !== "string") return;
|
||||
selectedType.value = value;
|
||||
if (props.modelValue && value !== ALL_TYPES && (!selectedConnection.value || compareConnectionType(selectedConnection.value).value !== value)) {
|
||||
emit("update:modelValue", "");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="@container min-w-0 space-y-1">
|
||||
<div class="grid grid-cols-1 gap-2 @min-[300px]:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
|
||||
<Select :model-value="selectedType" :disabled="disabled || !typeOptions.length" @update:model-value="selectType">
|
||||
<SelectTrigger class="h-8 w-full min-w-0 text-xs" :aria-label="t('diff.connType')">
|
||||
<SelectValue class="min-w-0 truncate" :placeholder="t('diff.allConnectionTypes')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="ALL_TYPES">{{ t("diff.allConnectionTypes") }}</SelectItem>
|
||||
<SelectItem v-for="option in typeOptions" :key="option.value" :value="option.value">{{ option.label }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ConnectionTreeSelect
|
||||
:model-value="selectedConnection ? modelValue : ''"
|
||||
:connections="filteredConnections"
|
||||
:layout="layout"
|
||||
:disabled="disabled || !connections.length"
|
||||
:placeholder="t('diff.selectConnection')"
|
||||
:search-placeholder="t('diff.searchConnection')"
|
||||
:empty-text="t('common.noResults')"
|
||||
trigger-class="dbx-diff-connection-trigger h-8 w-full max-w-none justify-between gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-xs shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
|
||||
list-class="min-w-64 w-[var(--reka-popover-trigger-width)]"
|
||||
@update:model-value="(value) => emit('update:modelValue', value)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="modelValue && !selectedConnection" class="text-xs text-muted-foreground">{{ t("diff.unsupportedCompareConnection") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,7 +6,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import CompareConnectionSelect from "@/components/diff/CompareConnectionSelect.vue";
|
||||
import { supportsDatabaseCompare } from "@/lib/database/databaseCompareCapabilities";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
@@ -129,7 +130,34 @@ let configSaveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let componentUnmounted = false;
|
||||
let shownSessionError = "";
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(connection.db_type)));
|
||||
const compareConnections = computed(() => store.connections.filter((connection) => supportsDatabaseCompare(connection, "data")));
|
||||
// Swapping copies the loaded lists and suppresses their reload watchers. Wait
|
||||
// until all namespace/table requests settle so no unfinished list is stranded.
|
||||
const pendingMetadataRequests = ref(0);
|
||||
// Invalidate metadata responses when the user switches either endpoint, even
|
||||
// when they switch back before the previous request finishes.
|
||||
const metadataGenerations = { source: 0, target: 0 };
|
||||
watch(
|
||||
[sourceConnectionId, sourceDatabase, sourceSchema],
|
||||
() => {
|
||||
metadataGenerations.source += 1;
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
watch(
|
||||
[targetConnectionId, targetDatabase, targetSchema],
|
||||
() => {
|
||||
metadataGenerations.target += 1;
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
function metadataRequestIsCurrent(side: "source" | "target"): () => boolean {
|
||||
const generation = metadataGenerations[side];
|
||||
const connectionId = side === "source" ? sourceConnectionId.value : targetConnectionId.value;
|
||||
return () => generation === metadataGenerations[side] && supportsDatabaseCompare(store.getConfig(connectionId), "data");
|
||||
}
|
||||
|
||||
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
|
||||
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
|
||||
// Bridge the shared TableMultiSelect `string[]` v-model with the Set-based selection store.
|
||||
@@ -195,7 +223,19 @@ const singleTableNeedsKeyColumns = computed(() => {
|
||||
if (!isTableColumnsKnown(row.table)) return false;
|
||||
return row.columns.length === 0;
|
||||
});
|
||||
const canCompare = computed(() => sourceConnectionId.value && sourceDatabase.value && sourceSchema.value && selectedSourceTableNames.value.length > 0 && targetConnectionId.value && targetDatabase.value && targetSchema.value && !singleTableNeedsKeyColumns.value);
|
||||
const canCompare = computed(
|
||||
() =>
|
||||
supportsDatabaseCompare(store.getConfig(sourceConnectionId.value), "data") &&
|
||||
supportsDatabaseCompare(store.getConfig(targetConnectionId.value), "data") &&
|
||||
sourceConnectionId.value &&
|
||||
sourceDatabase.value &&
|
||||
sourceSchema.value &&
|
||||
selectedSourceTableNames.value.length > 0 &&
|
||||
targetConnectionId.value &&
|
||||
targetDatabase.value &&
|
||||
targetSchema.value &&
|
||||
!singleTableNeedsKeyColumns.value,
|
||||
);
|
||||
const detailPreviewLimitNumber = computed(() => Number(detailPreviewLimit.value) || PREVIEW_LIMIT_OPTIONS[1]);
|
||||
|
||||
const sameTableCount = computed(() => batchResults.value.filter((item) => item.status === "same").length);
|
||||
@@ -299,7 +339,7 @@ async function fetchTableColumns(table: string): Promise<CompareKeyColumnOption[
|
||||
const connectionId = sourceConnectionId.value;
|
||||
const database = sourceDatabase.value;
|
||||
const schema = sourceSchema.value;
|
||||
if (!connectionId || !database || !schema || !table) return [];
|
||||
if (!connectionId || !database || !schema || !table || !supportsDatabaseCompare(store.getConfig(connectionId), "data")) return [];
|
||||
const generation = columnMetadataGeneration;
|
||||
loadingColumnTables.value = [...loadingColumnTables.value, table];
|
||||
try {
|
||||
@@ -503,15 +543,24 @@ async function applyDataCompareConfig(config: DataCompareConfig): Promise<void>
|
||||
shownSessionError = "";
|
||||
try {
|
||||
sourceConnectionId.value = config.sourceConnectionId;
|
||||
if (!config.sourceConnectionId) return;
|
||||
await loadDatabases(config.sourceConnectionId, "source");
|
||||
if (generation !== initializingPrefillGeneration) return;
|
||||
if (config.sourceDatabase) sourceDatabase.value = config.sourceDatabase;
|
||||
if (config.sourceDatabase) await loadSchemas("source", config.sourceSchema);
|
||||
if (generation !== initializingPrefillGeneration) return;
|
||||
|
||||
targetConnectionId.value = config.targetConnectionId;
|
||||
if (config.targetConnectionId) {
|
||||
sourceDatabase.value = targetDatabase.value = "";
|
||||
sourceSchema.value = targetSchema.value = "";
|
||||
sourceDatabases.value = targetDatabases.value = [];
|
||||
sourceSchemas.value = targetSchemas.value = [];
|
||||
sourceTables.value = targetTables.value = [];
|
||||
sourceTable.value = targetTable.value = "";
|
||||
resetSelectedSourceTables([]);
|
||||
resetTableColumnMetadata();
|
||||
if (supportsDatabaseCompare(store.getConfig(config.sourceConnectionId), "data")) {
|
||||
await loadDatabases(config.sourceConnectionId, "source");
|
||||
if (generation !== initializingPrefillGeneration) return;
|
||||
if (config.sourceDatabase) sourceDatabase.value = config.sourceDatabase;
|
||||
if (config.sourceDatabase) await loadSchemas("source", config.sourceSchema);
|
||||
if (generation !== initializingPrefillGeneration) return;
|
||||
}
|
||||
|
||||
if (supportsDatabaseCompare(store.getConfig(config.targetConnectionId), "data")) {
|
||||
await loadDatabases(config.targetConnectionId, "target");
|
||||
if (generation !== initializingPrefillGeneration) return;
|
||||
if (config.targetDatabase) targetDatabase.value = config.targetDatabase;
|
||||
@@ -591,7 +640,9 @@ function applyDataCompareSession(session: DataCompareSession | undefined): void
|
||||
}
|
||||
|
||||
function swapSourceTarget() {
|
||||
if (executionLocked.value) return;
|
||||
if (comparing.value || executionLocked.value || initializingPrefill || pendingMetadataRequests.value > 0) return;
|
||||
const generation = ++initializingPrefillGeneration;
|
||||
initializingPrefill = true;
|
||||
const previousSelectedTables = [...selectedSourceTableNames.value];
|
||||
const nextSingleTarget = previousSelectedTables.length === 1 ? (previousSelectedTables[0] ?? "") : "";
|
||||
const nextSourceSelection = previousSelectedTables.length <= 1 ? [targetTable.value].filter(Boolean) : previousSelectedTables;
|
||||
@@ -620,7 +671,15 @@ function swapSourceTarget() {
|
||||
targetTable.value = nextSingleTarget;
|
||||
|
||||
sourceTable.value = selectedSourceTableNames.value.length === 1 ? selectedSourceTableNames.value[0] : "";
|
||||
keyColumnOverrides.value = {};
|
||||
resetTableColumnMetadata();
|
||||
clearResult();
|
||||
void nextTick(() => {
|
||||
if (generation !== initializingPrefillGeneration) return;
|
||||
initializingPrefill = false;
|
||||
if (dataCompareConfigId.value && !suppressConfigSave) updateDataCompareConfig(dataCompareConfigId.value, dataCompareConfigSnapshot());
|
||||
void prefetchSelectedTableColumns(selectedSourceTableNames.value);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSchema(connectionId: string, database: string, preferredSchema = ""): Promise<string> {
|
||||
@@ -636,78 +695,101 @@ async function resolveSchema(connectionId: string, database: string, preferredSc
|
||||
async function loadSchemas(side: "source" | "target", preferredSchema = "") {
|
||||
const connectionId = side === "source" ? sourceConnectionId.value : targetConnectionId.value;
|
||||
const database = side === "source" ? sourceDatabase.value : targetDatabase.value;
|
||||
if (!connectionId || !database) return;
|
||||
const config = store.getConfig(connectionId);
|
||||
if (!isSchemaAware(config?.db_type)) {
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = [];
|
||||
sourceSchema.value = database;
|
||||
} else {
|
||||
targetSchemas.value = [];
|
||||
targetSchema.value = database;
|
||||
if (!connectionId || !database || !supportsDatabaseCompare(store.getConfig(connectionId), "data")) return;
|
||||
pendingMetadataRequests.value += 1;
|
||||
try {
|
||||
const isCurrent = metadataRequestIsCurrent(side);
|
||||
const config = store.getConfig(connectionId);
|
||||
if (!isSchemaAware(config?.db_type)) {
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = [];
|
||||
sourceSchema.value = database;
|
||||
} else {
|
||||
targetSchemas.value = [];
|
||||
targetSchema.value = database;
|
||||
}
|
||||
await loadTables(side);
|
||||
return;
|
||||
}
|
||||
await loadTables(side);
|
||||
return;
|
||||
}
|
||||
|
||||
const schemas = await api.listSchemas(connectionId, database);
|
||||
const schema = preferredSchema && schemas.includes(preferredSchema) ? preferredSchema : schemas.includes("public") ? "public" : (schemas[0] ?? "");
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = schemas;
|
||||
sourceSchema.value = schema;
|
||||
} else {
|
||||
targetSchemas.value = schemas;
|
||||
targetSchema.value = schema;
|
||||
const schemas = await api.listSchemas(connectionId, database);
|
||||
if (!isCurrent()) return;
|
||||
const schema = preferredSchema && schemas.includes(preferredSchema) ? preferredSchema : schemas.includes("public") ? "public" : (schemas[0] ?? "");
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = schemas;
|
||||
sourceSchema.value = schema;
|
||||
} else {
|
||||
targetSchemas.value = schemas;
|
||||
targetSchema.value = schema;
|
||||
}
|
||||
} finally {
|
||||
pendingMetadataRequests.value -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
if (!connectionId) return;
|
||||
await store.ensureConnected(connectionId);
|
||||
const config = store.getConfig(connectionId);
|
||||
const names = config
|
||||
? await fetchNamespaceOptionsForConnection(connectionId, config)
|
||||
: databaseOptionsForConnection(
|
||||
(await api.listDatabases(connectionId)).map((database) => database.name),
|
||||
config,
|
||||
);
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = names;
|
||||
sourceDatabase.value = names.length === 1 ? names[0] : "";
|
||||
sourceSchemas.value = [];
|
||||
sourceSchema.value = "";
|
||||
sourceTables.value = [];
|
||||
sourceTable.value = "";
|
||||
resetSelectedSourceTables([]);
|
||||
} else {
|
||||
targetDatabases.value = names;
|
||||
targetDatabase.value = names.length === 1 ? names[0] : "";
|
||||
targetSchemas.value = [];
|
||||
targetSchema.value = "";
|
||||
targetTables.value = [];
|
||||
targetTable.value = "";
|
||||
if (!connectionId || !supportsDatabaseCompare(store.getConfig(connectionId), "data")) return;
|
||||
pendingMetadataRequests.value += 1;
|
||||
try {
|
||||
const isCurrent = metadataRequestIsCurrent(side);
|
||||
await store.ensureConnected(connectionId);
|
||||
if (!isCurrent()) return;
|
||||
const config = store.getConfig(connectionId);
|
||||
const names = config
|
||||
? await fetchNamespaceOptionsForConnection(connectionId, config)
|
||||
: databaseOptionsForConnection(
|
||||
(await api.listDatabases(connectionId)).map((database) => database.name),
|
||||
config,
|
||||
);
|
||||
if (!isCurrent()) return;
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = names;
|
||||
sourceDatabase.value = names.length === 1 ? names[0] : "";
|
||||
sourceSchemas.value = [];
|
||||
sourceSchema.value = "";
|
||||
sourceTables.value = [];
|
||||
sourceTable.value = "";
|
||||
resetSelectedSourceTables([]);
|
||||
} else {
|
||||
targetDatabases.value = names;
|
||||
targetDatabase.value = names.length === 1 ? names[0] : "";
|
||||
targetSchemas.value = [];
|
||||
targetSchema.value = "";
|
||||
targetTables.value = [];
|
||||
targetTable.value = "";
|
||||
}
|
||||
} finally {
|
||||
pendingMetadataRequests.value -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTables(side: "source" | "target") {
|
||||
const connectionId = side === "source" ? sourceConnectionId.value : targetConnectionId.value;
|
||||
const database = side === "source" ? sourceDatabase.value : targetDatabase.value;
|
||||
if (!connectionId || !database) return;
|
||||
const schema = side === "source" ? sourceSchema.value || (await resolveSchema(connectionId, database, props.prefillSchema)) : targetSchema.value || (await resolveSchema(connectionId, database));
|
||||
const tables = (await api.listTables(connectionId, database, schema)).filter((table) => table.table_type !== "VIEW" && table.table_type !== "MATERIALIZED_VIEW").map((table) => table.name);
|
||||
if (!connectionId || !database || !supportsDatabaseCompare(store.getConfig(connectionId), "data")) return;
|
||||
pendingMetadataRequests.value += 1;
|
||||
try {
|
||||
const isCurrent = metadataRequestIsCurrent(side);
|
||||
const schema = side === "source" ? sourceSchema.value || (await resolveSchema(connectionId, database, props.prefillSchema)) : targetSchema.value || (await resolveSchema(connectionId, database));
|
||||
if (!isCurrent()) return;
|
||||
const tables = (await api.listTables(connectionId, database, schema)).filter((table) => table.table_type !== "VIEW" && table.table_type !== "MATERIALIZED_VIEW").map((table) => table.name);
|
||||
if (!isCurrent()) return;
|
||||
|
||||
if (side === "source") {
|
||||
const preferredSelection = props.prefillTable && tables.includes(props.prefillTable) ? [props.prefillTable] : [...selectedSourceTables.value].filter((table) => tables.includes(table));
|
||||
sourceSchema.value = schema;
|
||||
sourceTables.value = tables;
|
||||
resetSelectedSourceTables(preferredSelection);
|
||||
sourceTable.value = preferredSelection.length === 1 ? preferredSelection[0] : "";
|
||||
} else {
|
||||
targetSchema.value = schema;
|
||||
targetTables.value = tables;
|
||||
const singleSourceTable = selectedSourceTableNames.value.length === 1 ? selectedSourceTableNames.value[0] : "";
|
||||
const preferred = targetTable.value && tables.includes(targetTable.value) ? targetTable.value : singleSourceTable && tables.includes(singleSourceTable) ? singleSourceTable : "";
|
||||
targetTable.value = preferred;
|
||||
if (side === "source") {
|
||||
const preferredSelection = props.prefillTable && tables.includes(props.prefillTable) ? [props.prefillTable] : [...selectedSourceTables.value].filter((table) => tables.includes(table));
|
||||
sourceSchema.value = schema;
|
||||
sourceTables.value = tables;
|
||||
resetSelectedSourceTables(preferredSelection);
|
||||
sourceTable.value = preferredSelection.length === 1 ? preferredSelection[0] : "";
|
||||
} else {
|
||||
targetSchema.value = schema;
|
||||
targetTables.value = tables;
|
||||
const singleSourceTable = selectedSourceTableNames.value.length === 1 ? selectedSourceTableNames.value[0] : "";
|
||||
const preferred = targetTable.value && tables.includes(targetTable.value) ? targetTable.value : singleSourceTable && tables.includes(singleSourceTable) ? singleSourceTable : "";
|
||||
targetTable.value = preferred;
|
||||
}
|
||||
} finally {
|
||||
pendingMetadataRequests.value -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,6 +1117,7 @@ function formatModifiedSummary(row: SelectableDataCompareModifiedRow): string {
|
||||
watch(sourceConnectionId, (id) => {
|
||||
if (initializingPrefill) return;
|
||||
clearResult();
|
||||
sourceDatabases.value = [];
|
||||
sourceDatabase.value = "";
|
||||
sourceSchema.value = "";
|
||||
sourceSchemas.value = [];
|
||||
@@ -1048,6 +1131,7 @@ watch(sourceConnectionId, (id) => {
|
||||
watch(targetConnectionId, (id) => {
|
||||
if (initializingPrefill) return;
|
||||
clearResult();
|
||||
targetDatabases.value = [];
|
||||
targetDatabase.value = "";
|
||||
targetSchema.value = "";
|
||||
targetSchemas.value = [];
|
||||
@@ -1158,6 +1242,13 @@ watch(
|
||||
suppressConfigSave = true;
|
||||
try {
|
||||
sourceConnectionId.value = props.prefillConnectionId;
|
||||
if (!supportsDatabaseCompare(store.getConfig(props.prefillConnectionId), "data")) {
|
||||
sourceDatabase.value = sourceSchema.value = sourceTable.value = "";
|
||||
sourceDatabases.value = sourceSchemas.value = sourceTables.value = [];
|
||||
resetSelectedSourceTables([]);
|
||||
resetTableColumnMetadata();
|
||||
return;
|
||||
}
|
||||
await loadDatabases(props.prefillConnectionId, "source");
|
||||
if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase;
|
||||
if (props.prefillDatabase) await loadSchemas("source", props.prefillSchema);
|
||||
@@ -1231,17 +1322,7 @@ onBeforeUnmount(() => {
|
||||
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-500/15 text-[11px] font-semibold">S</span>
|
||||
{{ t("diff.source") }}
|
||||
</div>
|
||||
<ConnectionTreeSelect
|
||||
v-model="sourceConnectionId"
|
||||
:disabled="comparing || executionLocked"
|
||||
:connections="sqlConnections"
|
||||
:layout="store.sidebarLayout"
|
||||
:placeholder="t('diff.selectConnection')"
|
||||
:search-placeholder="t('diff.searchConnection')"
|
||||
:empty-text="t('common.noResults')"
|
||||
trigger-class="dbx-diff-connection-trigger h-8 w-full max-w-none justify-between gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-xs shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
|
||||
list-class="w-[var(--reka-popover-trigger-width)]"
|
||||
/>
|
||||
<CompareConnectionSelect v-model="sourceConnectionId" :disabled="comparing || executionLocked" :connections="compareConnections" :layout="store.sidebarLayout" />
|
||||
<SearchableSelect
|
||||
v-model="sourceDatabase"
|
||||
:options="sourceDatabases"
|
||||
@@ -1277,7 +1358,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-6">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" :disabled="comparing || executionLocked" @click="swapSourceTarget">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" :disabled="comparing || executionLocked || pendingMetadataRequests > 0" @click="swapSourceTarget">
|
||||
<ArrowLeftRight class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1287,17 +1368,7 @@ onBeforeUnmount(() => {
|
||||
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500/15 text-[11px] font-semibold">T</span>
|
||||
{{ t("diff.target") }}
|
||||
</div>
|
||||
<ConnectionTreeSelect
|
||||
v-model="targetConnectionId"
|
||||
:disabled="comparing || executionLocked"
|
||||
:connections="sqlConnections"
|
||||
:layout="store.sidebarLayout"
|
||||
:placeholder="t('diff.selectConnection')"
|
||||
:search-placeholder="t('diff.searchConnection')"
|
||||
:empty-text="t('common.noResults')"
|
||||
trigger-class="dbx-diff-connection-trigger h-8 w-full max-w-none justify-between gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-xs shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
|
||||
list-class="w-[var(--reka-popover-trigger-width)]"
|
||||
/>
|
||||
<CompareConnectionSelect v-model="targetConnectionId" :disabled="comparing || executionLocked" :connections="compareConnections" :layout="store.sidebarLayout" />
|
||||
<SearchableSelect
|
||||
v-model="targetDatabase"
|
||||
:options="targetDatabases"
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import CompareConnectionSelect from "@/components/diff/CompareConnectionSelect.vue";
|
||||
import { supportsDatabaseCompare } from "@/lib/database/databaseCompareCapabilities";
|
||||
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
|
||||
import { buildSchemaDiffTableMatches, availableSchemaDiffTargetTables, areSchemaDiffTableMappingsEqual, pruneSchemaDiffTableMappings, reconcileSchemaDiffTableMappings, updateSchemaDiffTableMapping, type SchemaDiffTableMatch } from "@/lib/schema/schemaDiffTableMapping";
|
||||
import {
|
||||
@@ -84,7 +85,34 @@ const targetSchemas = ref<string[]>([]);
|
||||
const sourceDbVersion = ref<string | null>(null);
|
||||
const targetDbVersion = ref<string | null>(null);
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "solr", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(c.db_type)));
|
||||
const compareConnections = computed(() => store.connections.filter((connection) => supportsDatabaseCompare(connection, "schema")));
|
||||
|
||||
type MetadataScope = "connection" | "database" | "schema";
|
||||
const metadataGenerations = {
|
||||
source: { connection: 0, database: 0, schema: 0 },
|
||||
target: { connection: 0, database: 0, schema: 0 },
|
||||
};
|
||||
for (const side of ["source", "target"] as const) {
|
||||
watch(
|
||||
() => (side === "source" ? [props.sourceConnectionId, props.sourceDatabase, props.sourceSchema] : [props.targetConnectionId, props.targetDatabase, props.targetSchema]),
|
||||
(current, previous) => {
|
||||
const connectionChanged = current[0] !== previous[0];
|
||||
const databaseChanged = connectionChanged || current[1] !== previous[1];
|
||||
if (connectionChanged) metadataGenerations[side].connection += 1;
|
||||
if (databaseChanged) metadataGenerations[side].database += 1;
|
||||
if (databaseChanged || current[2] !== previous[2]) metadataGenerations[side].schema += 1;
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
}
|
||||
|
||||
// Only invalidate a request when its own inputs change. Selecting a schema
|
||||
// must not discard an in-flight database or schema list for the same endpoint.
|
||||
function metadataRequestIsCurrent(side: "source" | "target", scope: MetadataScope = "schema"): () => boolean {
|
||||
const generation = metadataGenerations[side][scope];
|
||||
const connectionId = side === "source" ? props.sourceConnectionId : props.targetConnectionId;
|
||||
return () => generation === metadataGenerations[side][scope] && supportsDatabaseCompare(store.getConfig(connectionId), "schema");
|
||||
}
|
||||
|
||||
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
|
||||
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
|
||||
@@ -197,7 +225,7 @@ function getTableIdentity(side: SchemaDiffTableSide): SchemaDiffTableIdentity {
|
||||
function isTableIdentityReady(side: SchemaDiffTableSide): boolean {
|
||||
const identity = getTableIdentity(side);
|
||||
const config = side === "source" ? sourceConfig.value : targetConfig.value;
|
||||
return !!identity.connectionId && !!identity.database && (!isSchemaAware(config?.db_type) || !!identity.schema);
|
||||
return supportsDatabaseCompare(config, "schema") && !!identity.connectionId && !!identity.database && (!isSchemaAware(config?.db_type) || !!identity.schema);
|
||||
}
|
||||
|
||||
function setTableList(side: SchemaDiffTableSide, tables: Array<{ name?: string }>) {
|
||||
@@ -340,7 +368,7 @@ async function loadRoutineList(side: "source" | "target") {
|
||||
const database = side === "source" ? props.sourceDatabase : props.targetDatabase;
|
||||
const schema = side === "source" ? props.sourceSchema : props.targetSchema;
|
||||
const dbType = side === "source" ? sourceDbType.value : targetDbType.value;
|
||||
if (!connectionId || !database || !supportsSchemaDiffRoutines(dbType as DatabaseType)) {
|
||||
if (!isTableIdentityReady(side) || !supportsSchemaDiffRoutines(dbType as DatabaseType)) {
|
||||
if (side === "source") sourceRoutineList.value = [];
|
||||
else {
|
||||
targetRoutineList.value = [];
|
||||
@@ -348,12 +376,15 @@ async function loadRoutineList(side: "source" | "target") {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const isCurrent = metadataRequestIsCurrent(side);
|
||||
try {
|
||||
loadingRoutines.value = true;
|
||||
await store.ensureConnected(connectionId);
|
||||
if (!isCurrent()) return;
|
||||
// Names/signatures only — avoid listFunctions N+1 source fetch on the config step.
|
||||
const kinds = schemaDiffRoutineObjectTypes(dbType as DatabaseType);
|
||||
const objects = kinds.length === 0 ? [] : await api.listObjects(connectionId, database, schema, kinds);
|
||||
if (!isCurrent()) return;
|
||||
const keys = objects.map((object) => schemaDiffRoutineKey(object.name, object.signature ?? ""));
|
||||
if (side === "source") {
|
||||
sourceRoutineList.value = keys;
|
||||
@@ -370,6 +401,7 @@ async function loadRoutineList(side: "source" | "target") {
|
||||
if (restrictRoutines.value) reconcileRoutineMappings(localSelectedRoutines.value, keys);
|
||||
}
|
||||
} catch {
|
||||
if (!isCurrent()) return;
|
||||
if (side === "source") sourceRoutineList.value = [];
|
||||
else {
|
||||
targetRoutineList.value = [];
|
||||
@@ -454,6 +486,8 @@ const canCompare = computed(() => {
|
||||
const hasValidTableMappings = !tablesEnabled || !restrictTables.value || localSelectedTables.value.length === 0 || isTableIdentityReady("target");
|
||||
const hasValidRoutineMappings = !routinesEnabled || !restrictRoutines.value || localSelectedRoutines.value.length === 0 || isTableIdentityReady("target");
|
||||
return (
|
||||
supportsDatabaseCompare(sourceConfig.value, "schema") &&
|
||||
supportsDatabaseCompare(targetConfig.value, "schema") &&
|
||||
props.sourceConnectionId &&
|
||||
props.targetConnectionId &&
|
||||
props.sourceDatabase &&
|
||||
@@ -468,11 +502,14 @@ const canCompare = computed(() => {
|
||||
});
|
||||
|
||||
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
if (!connectionId) return;
|
||||
if (!connectionId || !supportsDatabaseCompare(store.getConfig(connectionId), "schema")) return;
|
||||
const isCurrent = metadataRequestIsCurrent(side, "connection");
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
if (!isCurrent()) return;
|
||||
const config = store.getConfig(connectionId);
|
||||
const dbNames = config ? await fetchNamespaceOptionsForConnection(connectionId, config) : (await api.listDatabases(connectionId)).map((db) => db.name);
|
||||
if (!isCurrent()) return;
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = dbNames;
|
||||
if (props.sourceDatabase) {
|
||||
@@ -493,6 +530,7 @@ async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!isCurrent()) return;
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = [];
|
||||
sourceDbVersion.value = null;
|
||||
@@ -507,11 +545,14 @@ async function loadSchemas(side: "source" | "target") {
|
||||
const connectionId = side === "source" ? props.sourceConnectionId : props.targetConnectionId;
|
||||
const database = side === "source" ? props.sourceDatabase : props.targetDatabase;
|
||||
const schema = side === "source" ? props.sourceSchema : props.targetSchema;
|
||||
if (!connectionId || !database) return;
|
||||
if (!connectionId || !database || !supportsDatabaseCompare(store.getConfig(connectionId), "schema")) return;
|
||||
const isCurrent = metadataRequestIsCurrent(side, "database");
|
||||
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
if (!isCurrent()) return;
|
||||
const schemas = await api.listSchemas(connectionId, database);
|
||||
if (!isCurrent()) return;
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = schemas;
|
||||
} else {
|
||||
@@ -519,6 +560,7 @@ async function loadSchemas(side: "source" | "target") {
|
||||
}
|
||||
await fetchDbVersion(connectionId, database, schema, side);
|
||||
} catch {
|
||||
if (!isCurrent()) return;
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = [];
|
||||
} else {
|
||||
@@ -530,6 +572,9 @@ async function loadSchemas(side: "source" | "target") {
|
||||
watch(
|
||||
() => props.sourceConnectionId,
|
||||
async (id) => {
|
||||
sourceDatabases.value = [];
|
||||
sourceSchemas.value = [];
|
||||
sourceDbVersion.value = null;
|
||||
if (id) {
|
||||
await loadDatabases(id, "source");
|
||||
} else {
|
||||
@@ -554,6 +599,9 @@ watch(
|
||||
watch(
|
||||
() => props.targetConnectionId,
|
||||
async (id) => {
|
||||
targetDatabases.value = [];
|
||||
targetSchemas.value = [];
|
||||
targetDbVersion.value = null;
|
||||
if (id) {
|
||||
await loadDatabases(id, "target");
|
||||
} else {
|
||||
@@ -615,6 +663,8 @@ function getConnectionInfo(connectionId: string) {
|
||||
}
|
||||
|
||||
async function fetchDbVersion(connectionId: string, database: string, schema: string, side: "source" | "target") {
|
||||
if (!supportsDatabaseCompare(store.getConfig(connectionId), "schema")) return;
|
||||
const isCurrent = metadataRequestIsCurrent(side);
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const config = store.getConfig(connectionId);
|
||||
@@ -634,7 +684,9 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
const result = await api.executeQuery(connectionId, database, sql, schema || undefined);
|
||||
if (!isCurrent()) return;
|
||||
if (result.rows && result.rows.length > 0) {
|
||||
const version = String(result.rows[0][0]);
|
||||
if (side === "source") {
|
||||
@@ -644,6 +696,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isCurrent()) return;
|
||||
console.error(`[fetchDbVersion] Failed to fetch version for ${side}:`, e);
|
||||
if (side === "source") {
|
||||
sourceDbVersion.value = null;
|
||||
@@ -681,17 +734,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.connection") }}</Label>
|
||||
<ConnectionTreeSelect
|
||||
:model-value="sourceConnectionId"
|
||||
@update:model-value="(v: string) => $emit('update:sourceConnectionId', v)"
|
||||
:connections="sqlConnections"
|
||||
:layout="store.sidebarLayout"
|
||||
:placeholder="t('diff.selectConnection')"
|
||||
:search-placeholder="t('diff.searchConnection')"
|
||||
:empty-text="t('common.noResults')"
|
||||
trigger-class="dbx-diff-connection-trigger h-8 w-full max-w-none justify-between gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-xs shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
|
||||
list-class="w-[var(--reka-popover-trigger-width)]"
|
||||
/>
|
||||
<CompareConnectionSelect :model-value="sourceConnectionId" @update:model-value="(v: string) => $emit('update:sourceConnectionId', v)" :connections="compareConnections" :layout="store.sidebarLayout" :disabled="loading" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
@@ -757,17 +800,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.connection") }}</Label>
|
||||
<ConnectionTreeSelect
|
||||
:model-value="targetConnectionId"
|
||||
@update:model-value="(v: string) => $emit('update:targetConnectionId', v)"
|
||||
:connections="sqlConnections"
|
||||
:layout="store.sidebarLayout"
|
||||
:placeholder="t('diff.selectConnection')"
|
||||
:search-placeholder="t('diff.searchConnection')"
|
||||
:empty-text="t('common.noResults')"
|
||||
trigger-class="dbx-diff-connection-trigger h-8 w-full max-w-none justify-between gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-xs shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
|
||||
list-class="w-[var(--reka-popover-trigger-width)]"
|
||||
/>
|
||||
<CompareConnectionSelect :model-value="targetConnectionId" @update:model-value="(v: string) => $emit('update:targetConnectionId', v)" :connections="compareConnections" :layout="store.sidebarLayout" :disabled="loading" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GitCompareArrows, ArrowLeft, Play, Loader2, Maximize2, Minimize2, Alert
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
import { supportsDatabaseCompare } from "@/lib/database/databaseCompareCapabilities";
|
||||
import { useSchemaDiffConfig } from "@/composables/useSchemaDiffConfig";
|
||||
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
|
||||
import SchemaDiffConfigSelector from "@/components/diff/SchemaDiffConfigSelector.vue";
|
||||
@@ -587,6 +588,14 @@ function runWithoutTargetIdentityReset(action: () => void): void {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSourceConnectionChange(connectionId: string): void {
|
||||
if (connectionId === sourceConnectionId.value) return;
|
||||
sourceConnectionId.value = connectionId;
|
||||
sourceDatabase.value = "";
|
||||
sourceSchema.value = "";
|
||||
clearCompareObjectSelection();
|
||||
}
|
||||
|
||||
function clearCompareObjectSelection(): void {
|
||||
if (!activeConfig.value) return;
|
||||
updateActiveConfigOptions(
|
||||
@@ -729,6 +738,7 @@ function handleFieldMappingsUpdate(mappings: FieldMappingEntry[]) {
|
||||
function handleCompare(): void {
|
||||
const sourceConfig = store.getConfig(sourceConnectionId.value);
|
||||
const targetConfig = store.getConfig(targetConnectionId.value);
|
||||
if (!supportsDatabaseCompare(sourceConfig, "schema") || !supportsDatabaseCompare(targetConfig, "schema")) return;
|
||||
const targetDbType = (targetConfig?.db_type || "mysql") as DatabaseType;
|
||||
const sourceDbType = sourceConfig?.db_type || targetDbType;
|
||||
const options = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, targetDbType);
|
||||
@@ -1193,6 +1203,7 @@ function handleDeleteHistoryConfig(configId: string) {
|
||||
}
|
||||
|
||||
async function fetchDbVersion(connectionId: string, database: string, schema: string) {
|
||||
if (!supportsDatabaseCompare(store.getConfig(connectionId), "schema")) return;
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const config = store.getConfig(connectionId);
|
||||
@@ -1307,7 +1318,8 @@ const targetConnectionInfo = computed(() => {
|
||||
<SchemaDiffConfigStep
|
||||
v-if="step === 'config'"
|
||||
class="shrink-0"
|
||||
v-model:source-connection-id="sourceConnectionId"
|
||||
:source-connection-id="sourceConnectionId"
|
||||
@update:source-connection-id="handleSourceConnectionChange"
|
||||
v-model:source-database="sourceDatabase"
|
||||
v-model:source-schema="sourceSchema"
|
||||
v-model:target-connection-id="targetConnectionId"
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import DataCompareDialog from "@/components/diff/DataCompareDialog.vue";
|
||||
import type { DataCompareSession } from "@/composables/useDataCompareSession";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
session: null as DataCompareSession | null,
|
||||
ensureConnected: vi.fn(),
|
||||
listDatabases: vi.fn(),
|
||||
listSchemas: vi.fn(),
|
||||
listTables: vi.fn(),
|
||||
getColumns: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/composables/useDataCompareSession", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/composables/useDataCompareSession")>()),
|
||||
getDataCompareSession: (id: string | null | undefined) => (id === mocks.session?.id ? mocks.session : undefined),
|
||||
}));
|
||||
vi.mock("@/stores/connectionStore", () => {
|
||||
const connections = [1, 2, 3].map((index) => ({ id: `pg-${index}`, name: `PostgreSQL ${index}`, db_type: "postgres" }));
|
||||
return {
|
||||
useConnectionStore: () => ({
|
||||
connections,
|
||||
sidebarLayout: { groups: [], order: connections.map(({ id }) => ({ type: "connection", id })) },
|
||||
getConfig: (id: string) => connections.find((connection) => connection.id === id),
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listDatabases: mocks.listDatabases,
|
||||
listSchemas: mocks.listSchemas,
|
||||
listTables: mocks.listTables,
|
||||
getColumns: mocks.getColumns,
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
const tables = [
|
||||
{ name: "orders", table_type: "TABLE" },
|
||||
{ name: "invoices", table_type: "TABLE" },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.ensureConnected.mockReset().mockResolvedValue(undefined);
|
||||
mocks.listDatabases.mockReset().mockResolvedValue([{ name: "app" }]);
|
||||
mocks.listSchemas.mockReset().mockResolvedValue(["public", "analytics"]);
|
||||
mocks.listTables.mockReset().mockResolvedValue(tables);
|
||||
mocks.getColumns.mockReset().mockResolvedValue([{ name: "id", data_type: "integer", is_primary_key: true }]);
|
||||
mocks.toast.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.textContent = "";
|
||||
mocks.session = null;
|
||||
});
|
||||
|
||||
async function flushAsyncSetup() {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
async function mountLoadedDialog() {
|
||||
mocks.session = {
|
||||
id: "loaded-compare",
|
||||
version: 1,
|
||||
status: "completed",
|
||||
config: {
|
||||
sourceConnectionId: "pg-1",
|
||||
sourceDatabase: "app",
|
||||
sourceSchema: "public",
|
||||
sourceDatabases: ["app", "reporting"],
|
||||
sourceSchemas: ["public", "analytics"],
|
||||
sourceTables: ["orders", "invoices"],
|
||||
selectedSourceTables: ["orders"],
|
||||
targetConnectionId: "pg-2",
|
||||
targetDatabase: "reporting",
|
||||
targetSchema: "analytics",
|
||||
targetDatabases: ["app", "reporting"],
|
||||
targetSchemas: ["public", "analytics"],
|
||||
targetTables: ["orders", "invoices"],
|
||||
targetTable: "invoices",
|
||||
keyColumnsByTable: {},
|
||||
label: "Loaded comparison",
|
||||
},
|
||||
progress: null,
|
||||
batchResults: [],
|
||||
syncPlan: { insertCount: 0, updateCount: 0, deleteCount: 0, statementCount: 0, syncStatements: [], syncSql: "" },
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
finishedAt: 2,
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(defineComponent({ setup: () => () => h(DataCompareDialog, { open: true, sessionId: "loaded-compare" }) }));
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await flushAsyncSetup();
|
||||
}
|
||||
|
||||
function panel(side: "source" | "target"): HTMLDivElement {
|
||||
const color = side === "source" ? "blue" : "emerald";
|
||||
return [...document.querySelectorAll<HTMLDivElement>("div")].find((element) => element.classList.contains(`border-${color}-500/35`))!;
|
||||
}
|
||||
|
||||
function namespaceTriggers(side: "source" | "target") {
|
||||
return [...panel(side).querySelectorAll<HTMLButtonElement>("button.dbx-searchable-select-trigger")];
|
||||
}
|
||||
|
||||
function swapButton() {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.title === i18n.global.t("diff.swap"))!;
|
||||
}
|
||||
|
||||
async function chooseConnection(side: "source" | "target", id: string) {
|
||||
panel(side).querySelector<HTMLButtonElement>("button.dbx-diff-connection-trigger")!.click();
|
||||
await flushAsyncSetup();
|
||||
document.querySelector<HTMLButtonElement>(`[data-picker-connection="${id}"]`)!.click();
|
||||
await flushAsyncSetup();
|
||||
}
|
||||
|
||||
async function chooseOption(trigger: HTMLButtonElement, value: string) {
|
||||
trigger.click();
|
||||
await flushAsyncSetup();
|
||||
[...document.querySelectorAll<HTMLButtonElement>(".dbx-searchable-select-list button")].find((button) => button.textContent?.trim() === value)!.click();
|
||||
await flushAsyncSetup();
|
||||
}
|
||||
|
||||
const stages = ["databases", "schemas", "tables"] as const;
|
||||
type Stage = (typeof stages)[number];
|
||||
|
||||
async function beginMetadataLoad(side: "source" | "target", stage: Stage) {
|
||||
if (stage === "databases") await chooseConnection(side, "pg-3");
|
||||
else if (stage === "schemas") await chooseOption(namespaceTriggers(side)[0], side === "source" ? "reporting" : "app");
|
||||
else await chooseOption(namespaceTriggers(side)[1], side === "source" ? "analytics" : "public");
|
||||
}
|
||||
|
||||
function deferredMetadata(stage: Stage) {
|
||||
const request = stage === "databases" ? mocks.listDatabases : stage === "schemas" ? mocks.listSchemas : mocks.listTables;
|
||||
let resolve!: (value: unknown) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
request.mockReturnValueOnce(
|
||||
new Promise((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
}),
|
||||
);
|
||||
return {
|
||||
resolve: () => resolve(stage === "databases" ? [{ name: "app" }] : stage === "schemas" ? ["public", "analytics"] : tables),
|
||||
reject,
|
||||
};
|
||||
}
|
||||
|
||||
it.each(["source", "target"].flatMap((side) => stages.map((stage) => [side as "source" | "target", stage] as const)))("waits for pending %s %s before allowing swap", async (side, stage) => {
|
||||
await mountLoadedDialog();
|
||||
const pending = deferredMetadata(stage);
|
||||
await beginMetadataLoad(side, stage);
|
||||
expect(swapButton().disabled).toBe(true);
|
||||
swapButton().click();
|
||||
expect(panel(side).querySelector<HTMLButtonElement>("button.dbx-diff-connection-trigger")!.title).toBe(stage === "databases" ? "PostgreSQL 3" : side === "source" ? "PostgreSQL 1" : "PostgreSQL 2");
|
||||
|
||||
pending.resolve();
|
||||
await flushAsyncSetup();
|
||||
expect(swapButton().disabled).toBe(false);
|
||||
const previousDatabase = namespaceTriggers(side)[0].title;
|
||||
expect(namespaceTriggers(side)[0].disabled).toBe(false);
|
||||
swapButton().click();
|
||||
await flushAsyncSetup();
|
||||
const destination = side === "source" ? "target" : "source";
|
||||
expect(namespaceTriggers(destination)[0].title).toBe(previousDatabase);
|
||||
expect(namespaceTriggers(destination)[0].disabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each(stages)("releases the swap lock after a %s request fails", async (stage) => {
|
||||
await mountLoadedDialog();
|
||||
const pending = deferredMetadata(stage);
|
||||
await beginMetadataLoad("source", stage);
|
||||
expect(swapButton().disabled).toBe(true);
|
||||
pending.reject(new Error("metadata unavailable"));
|
||||
await flushAsyncSetup();
|
||||
expect(swapButton().disabled).toBe(false);
|
||||
expect(mocks.toast).toHaveBeenCalledWith("Error: metadata unavailable", 5000);
|
||||
});
|
||||
|
||||
it("waits for both endpoints when their requests overlap", async () => {
|
||||
await mountLoadedDialog();
|
||||
const source = deferredMetadata("databases");
|
||||
const target = deferredMetadata("databases");
|
||||
await chooseConnection("source", "pg-3");
|
||||
await chooseConnection("target", "pg-1");
|
||||
source.resolve();
|
||||
await flushAsyncSetup();
|
||||
expect(swapButton().disabled).toBe(true);
|
||||
target.resolve();
|
||||
await flushAsyncSetup();
|
||||
expect(swapButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves loaded databases, schemas and table selections after swap", async () => {
|
||||
await mountLoadedDialog();
|
||||
expect(swapButton().disabled).toBe(false);
|
||||
swapButton().click();
|
||||
await flushAsyncSetup();
|
||||
expect(panel("source").querySelector<HTMLButtonElement>("button.dbx-diff-connection-trigger")!.title).toBe("PostgreSQL 2");
|
||||
expect(panel("target").querySelector<HTMLButtonElement>("button.dbx-diff-connection-trigger")!.title).toBe("PostgreSQL 1");
|
||||
expect(namespaceTriggers("source").map((button) => button.title)).toEqual(["reporting", "analytics"]);
|
||||
expect(namespaceTriggers("target").map((button) => button.title)).toEqual(["app", "public", "orders"]);
|
||||
expect(panel("source").querySelector('[data-table-name="invoices"] svg')?.classList.contains("text-primary")).toBe(true);
|
||||
expect(panel("source").querySelector('[data-table-name="orders"] svg')?.classList.contains("text-primary")).toBe(false);
|
||||
expect(mocks.listDatabases).not.toHaveBeenCalled();
|
||||
expect(mocks.listSchemas).not.toHaveBeenCalled();
|
||||
expect(mocks.listTables).not.toHaveBeenCalled();
|
||||
});
|
||||
+119
-5
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import { getDefaultOptionsForDbType } from "@/types/schemaDiff";
|
||||
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
|
||||
@@ -10,15 +10,17 @@ const mocks = vi.hoisted(() => ({
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
listDatabases: vi.fn().mockResolvedValue([]),
|
||||
listSchemas: vi.fn().mockResolvedValue(["APP", "REPORTING"]),
|
||||
executeQuery: vi.fn().mockResolvedValue({ rows: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => {
|
||||
const connection = { id: "oracle-11g", name: "Oracle 11g", db_type: "oracle", driver_profile: "oracle", database: "XE" };
|
||||
const connections = [connection, { id: "pg-1", name: "PostgreSQL 1", db_type: "postgres" }, { id: "pg-2", name: "PostgreSQL 2", db_type: "postgres" }];
|
||||
return {
|
||||
useConnectionStore: () => ({
|
||||
connections: [connection],
|
||||
sidebarLayout: { groups: [], order: [{ type: "connection", id: connection.id }] },
|
||||
getConfig: (id: string) => (id === connection.id ? connection : undefined),
|
||||
connections,
|
||||
sidebarLayout: { groups: [], order: connections.map(({ id }) => ({ type: "connection", id })) },
|
||||
getConfig: (id: string) => connections.find((item) => item.id === id),
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
}),
|
||||
};
|
||||
@@ -27,10 +29,63 @@ vi.mock("@/stores/connectionStore", () => {
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listDatabases: mocks.listDatabases,
|
||||
listSchemas: mocks.listSchemas,
|
||||
executeQuery: mocks.executeQuery,
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.ensureConnected.mockReset().mockResolvedValue(undefined);
|
||||
mocks.listDatabases.mockReset().mockResolvedValue([]);
|
||||
mocks.listSchemas.mockReset().mockResolvedValue(["APP", "REPORTING"]);
|
||||
mocks.executeQuery.mockReset().mockResolvedValue({ rows: [] });
|
||||
});
|
||||
|
||||
function mountPostgresEndpoint(side: "source" | "target") {
|
||||
const endpoints = reactive({
|
||||
sourceConnectionId: "",
|
||||
sourceDatabase: "",
|
||||
sourceSchema: "",
|
||||
targetConnectionId: "",
|
||||
targetDatabase: "",
|
||||
targetSchema: "",
|
||||
});
|
||||
endpoints[`${side}ConnectionId`] = "pg-1";
|
||||
endpoints[`${side}Database`] = "app";
|
||||
endpoints[`${side}Schema`] = "public";
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(SchemaDiffConfigStep, {
|
||||
...endpoints,
|
||||
configs: [],
|
||||
activeConfigId: "",
|
||||
ignoreComments: false,
|
||||
options: { ...getDefaultOptionsForDbType("postgres"), functions: false },
|
||||
tableListLoader: { load: vi.fn().mockResolvedValue([]) },
|
||||
loading: false,
|
||||
recentConfigs: [],
|
||||
"onUpdate:sourceSchema": (value: string) => {
|
||||
endpoints.sourceSchema = value;
|
||||
},
|
||||
"onUpdate:targetSchema": (value: string) => {
|
||||
endpoints.targetSchema = value;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
return { endpoints, container };
|
||||
}
|
||||
|
||||
function selectOptions() {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>(".dbx-searchable-select-list button")];
|
||||
}
|
||||
|
||||
async function flushAsyncSetup() {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await nextTick();
|
||||
@@ -82,3 +137,62 @@ it("offers Oracle schemas as schema-diff database choices", async () => {
|
||||
expect(mocks.listDatabases).not.toHaveBeenCalled();
|
||||
expect(mocks.listSchemas).toHaveBeenCalledWith("oracle-11g", "XE", true);
|
||||
});
|
||||
|
||||
it.each(["source", "target"] as const)("keeps pending %s database choices when the user selects another schema", async (side) => {
|
||||
let resolveDatabases!: (value: Array<{ name: string }>) => void;
|
||||
mocks.listDatabases.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveDatabases = resolve;
|
||||
}),
|
||||
);
|
||||
mocks.listSchemas.mockResolvedValue(["public", "analytics"]);
|
||||
const { endpoints, container } = mountPostgresEndpoint(side);
|
||||
await flushAsyncSetup();
|
||||
|
||||
const triggers = [...container.querySelectorAll<HTMLButtonElement>("button.dbx-searchable-select-trigger")];
|
||||
const database = triggers.find((button) => button.textContent?.trim() === "app")!;
|
||||
const schema = triggers.find((button) => button.textContent?.trim() === "public")!;
|
||||
expect(database.disabled).toBe(true);
|
||||
expect(schema.disabled).toBe(false);
|
||||
schema.click();
|
||||
await flushAsyncSetup();
|
||||
selectOptions()
|
||||
.find((button) => button.textContent?.trim() === "analytics")!
|
||||
.click();
|
||||
await flushAsyncSetup();
|
||||
expect(endpoints[`${side}Schema`]).toBe("analytics");
|
||||
|
||||
resolveDatabases([{ name: "app" }, { name: "reporting" }]);
|
||||
await flushAsyncSetup();
|
||||
expect(database.disabled).toBe(false);
|
||||
database.click();
|
||||
await flushAsyncSetup();
|
||||
expect(selectOptions().map((button) => button.textContent?.trim())).toEqual(expect.arrayContaining(["app", "reporting"]));
|
||||
});
|
||||
|
||||
it.each(["source", "target"] as const)("rejects stale %s database choices after switching away and back", async (side) => {
|
||||
let resolveOldDatabases!: (value: Array<{ name: string }>) => void;
|
||||
mocks.listDatabases
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOldDatabases = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue([{ name: "app" }]);
|
||||
mocks.listSchemas.mockResolvedValue(["public", "analytics"]);
|
||||
const { endpoints, container } = mountPostgresEndpoint(side);
|
||||
await flushAsyncSetup();
|
||||
endpoints[`${side}ConnectionId`] = "pg-2";
|
||||
await flushAsyncSetup();
|
||||
endpoints[`${side}ConnectionId`] = "pg-1";
|
||||
await flushAsyncSetup();
|
||||
|
||||
resolveOldDatabases([{ name: "obsolete" }]);
|
||||
await flushAsyncSetup();
|
||||
const database = [...container.querySelectorAll<HTMLButtonElement>("button.dbx-searchable-select-trigger")].find((button) => button.textContent?.trim() === "app")!;
|
||||
expect(database.disabled).toBe(false);
|
||||
database.click();
|
||||
await flushAsyncSetup();
|
||||
expect(selectOptions().map((button) => button.textContent?.trim())).toEqual(["app"]);
|
||||
expect(mocks.listDatabases.mock.calls.map(([id]) => id)).toEqual(["pg-1", "pg-2", "pg-1"]);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, shallowRef, nextTick, watch, onMounted, onBeforeUnmount, toRaw } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff, Palette, Copy } from "@lucide/vue";
|
||||
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff, Palette, Copy } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -2788,6 +2788,20 @@ defineExpose({ focusSearch });
|
||||
|
||||
<DataGridColumnLayoutPopover v-if="viewMode === 'table' && gridResult.columns.length" :grid="dataGridRef" />
|
||||
|
||||
<Button
|
||||
v-if="viewMode === 'table' && dataGridRef?.goToColumnToolbarCapability?.visible"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-7 shrink-0 text-foreground hover:bg-accent"
|
||||
:class="{ 'bg-accent': dataGridRef?.goToColumnToolbarCapability?.active }"
|
||||
:title="dataGridRef?.goToColumnToolbarCapability?.label"
|
||||
:aria-label="dataGridRef?.goToColumnToolbarCapability?.label"
|
||||
:aria-pressed="dataGridRef?.goToColumnToolbarCapability?.active"
|
||||
@click="dataGridRef?.goToColumnToolbarCapability?.onTrigger()"
|
||||
>
|
||||
<Columns3 class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Popover v-if="viewMode === 'table' && gridResult.columns.length" v-model:open="viewOptionsOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :class="{ 'bg-accent text-foreground': dataGridRef?.nullColumnsHidden }" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
<script lang="ts">
|
||||
// Aliased because `<script setup>` imports the same binding and the two blocks
|
||||
// share one module scope.
|
||||
import { reactive as createReactiveMap } from "vue";
|
||||
|
||||
/**
|
||||
* Per-conversation skill state, shared by every panel instance.
|
||||
*
|
||||
* Declared in a plain `<script>` block — not in `<script setup>`, where the
|
||||
* bindings would be re-created per instance — because this has to outlive the
|
||||
* component: closing the AI panel unmounts it, and the chip's "loaded" state
|
||||
* (prd 09-30 Req 13) and the one-time hint flag (Req 14) must survive reopening
|
||||
* the same conversation.
|
||||
*/
|
||||
|
||||
/** A skill body this conversation has loaded, kept for the life of the app run. */
|
||||
interface LoadedSkillBody {
|
||||
id: string;
|
||||
name: string;
|
||||
source: "custom" | "default";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which skills each conversation has loaded. This is the fact the transcript
|
||||
* persists (see `AiChatMessage.loadedSkillIds`); the bodies do not go to disk at
|
||||
* all, so this map plus the persisted ids are what the chips read.
|
||||
*/
|
||||
const conversationLoadedSkillIds = createReactiveMap(new Map<string, string[]>());
|
||||
/**
|
||||
* Bodies loaded in this app run, session-only by design. A forced load can carry
|
||||
* up to 1 MiB per skill, and the stored message array is an unbounded column that
|
||||
* is re-serialized in full on every save — so only the ids above are persisted
|
||||
* (the same shape as the #10058 selection footprint in
|
||||
* `crates/dbx-ai-provider/src/ai.rs`). The body is re-read from disk when a
|
||||
* request actually needs it.
|
||||
*/
|
||||
const conversationLoadedSkillBodies = createReactiveMap(new Map<string, LoadedSkillBody[]>());
|
||||
/** Conversations whose one-time hint has already been shown. */
|
||||
const conversationSkillHintSeen = new Set<string>();
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, h, nextTick, onMounted, onUnmounted, reactive, ref, toRaw, watch, type Component } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
@@ -65,9 +107,10 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
|
||||
import { useUserSkillStore } from "@/stores/userSkillStore";
|
||||
import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
|
||||
import { buildSelectedSkillChips, duplicateSkillNames, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
|
||||
import { aiConversationTypographyCssVariables } from "@/lib/ai/aiTypography";
|
||||
import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillRootSettings } from "@/types/userSkills";
|
||||
import { buildSkillListing, buildSkillListingLines } from "@/lib/ai/skillListing";
|
||||
import { type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillMeta, type UserSkillRootSettings, type UserSkillsListResult } from "@/types/userSkills";
|
||||
import { supportsAiAssistantContext } from "@/lib/database/databaseFeatureSupport";
|
||||
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
@@ -127,7 +170,7 @@ import {
|
||||
selectionContextBudgetError,
|
||||
truncateTextAttachmentContent,
|
||||
} from "@/lib/ai/aiAttachments";
|
||||
import { isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates";
|
||||
import { isAiConfigModelCandidate, isCliProvider } from "@/lib/ai/aiConfigCandidates";
|
||||
import { deleteConversationWithCancellation, stopAiGenerationWithFallback } from "@/lib/ai/aiConversationLifecycle";
|
||||
import { AiGenerationGuard } from "@/lib/ai/aiGenerationGuard";
|
||||
import { applyStatusEvent, createGenerationStatus, createStatusTicker, liveAnnouncementText, markCancelling, shouldShowLongRunningHint, statusText, toolLabel, STATUS_IDLE_THRESHOLD_MS, type AiGenerationStatus } from "@/lib/ai/aiGenerationStatus";
|
||||
@@ -167,7 +210,7 @@ import { buildAiAgentStepItems, formatAgentToolName, formatToolDurationMs, toolC
|
||||
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
|
||||
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
|
||||
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown";
|
||||
import { aiStream, aiCancelStream, resolveAiToolApproval, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
|
||||
import { aiStream, aiCancelStream, resolveAiToolApproval, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, listUserSkills, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
|
||||
import type { AiMessage } from "@/lib/backend/api";
|
||||
import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai";
|
||||
import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database";
|
||||
@@ -178,7 +221,7 @@ import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace";
|
||||
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseCapabilities";
|
||||
import ExplainPlanViewer from "@/components/explain/ExplainPlanViewer.vue";
|
||||
import { parseExplainResult, parseOracleExplainText, type ParsedExplainPlan } from "@/lib/diagram/explainPlan";
|
||||
import { parseExplainResult, parseOracleExplainText, parseDb2ExplainText, type ParsedExplainPlan } from "@/lib/diagram/explainPlan";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { AI_TABLE_MENTION_CANDIDATE_LIMIT, AI_TABLE_MENTION_SCHEMA_LIMIT, filterAiTableMentionCandidates, formatAiTableMention, parseAiTableMentions, type AiTableMention } from "@/lib/ai/aiTableMentions";
|
||||
import { handleAiTableReferenceDropEvent } from "@/lib/ai/aiTableReferenceDrop";
|
||||
@@ -198,7 +241,7 @@ import { resolveAiMessageCopyText } from "@/lib/ai/aiMessageCopy";
|
||||
import { buildPluginAiRequest, createPluginAiConversation, pluginComposerConnectionLabel, pluginContextConnectionId, pluginContextFromMessages, pluginContextText, streamPluginAiConversation, type AiPluginContext, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
||||
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const AiChartRenderer = defineAsyncComponent({
|
||||
// Lazy-load the chart renderer (and with it the whole echarts bundle): echarts
|
||||
// is only pulled in when a message actually renders a chart-json segment.
|
||||
@@ -314,6 +357,14 @@ interface ChatMessage {
|
||||
*/
|
||||
routedFrom?: "auto";
|
||||
routedAction?: AiAction;
|
||||
/**
|
||||
* Skills this conversation has had loaded (prd 09-30 Req 13), kept on the newest
|
||||
* assistant turn of every snapshot. Only the fact is persisted: a body can reach
|
||||
* 1 MiB per skill and the stored message array is re-serialized in full on every
|
||||
* save, so bodies are re-read from disk when a request needs them (see
|
||||
* `rehydrateLoadedSkillBodies`). Same shape as `selectionsOmitted`.
|
||||
*/
|
||||
loadedSkillIds?: string[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -639,10 +690,165 @@ const userSkillStore = useUserSkillStore();
|
||||
const selectedSkillIds = ref<string[]>([]);
|
||||
const showSkillSelector = ref(false);
|
||||
const skillFailures = ref<ReadUserSkillFailure[]>([]);
|
||||
/** Force-loads in flight, so a chip can spin and a double click cannot inject the
|
||||
* same body twice. */
|
||||
const skillPreloadPending = ref<string[]>([]);
|
||||
|
||||
/** Tool name backing the on-demand skill load (`crates/dbx-core/src/ai/skill_tools.rs`). */
|
||||
const USE_SKILL_TOOL_NAME = "use_skill";
|
||||
|
||||
// Chips derive from the selection truth, not from the catalog: a skill that
|
||||
// vanished from discovery must keep a removable chip (prd.md:37) instead of
|
||||
// silently becoming an id the user can no longer drop.
|
||||
const selectedSkillChips = computed(() => buildSelectedSkillChips(selectedSkillIds.value, (id) => userSkillStore.metaFor(id)));
|
||||
const selectedSkillChips = computed(() => buildSelectedSkillChips(selectedSkillIds.value, (id) => userSkillStore.metaFor(id), loadedSkillIds.value));
|
||||
|
||||
/**
|
||||
* Per-root duplicate display names, keyed by source. Two same-named skills in one
|
||||
* root are unresolvable for the model — `use_skill` addresses by name + source
|
||||
* (ADR Decision 6) and the source is identical, so it can only answer "ambiguous".
|
||||
* The selector flags those rows and tells the user the one fix that exists on
|
||||
* their side: changing one of the two `SKILL.md` frontmatter names, which is the
|
||||
* key this collision is computed from — the folder name plays no part. Cross-root
|
||||
* name collisions are normal and are deliberately not flagged.
|
||||
*/
|
||||
const duplicateNamesBySource = computed(() => {
|
||||
const bySource = new Map<"custom" | "default", Set<string>>();
|
||||
for (const group of userSkillStore.groupedSkills) bySource.set(group.source, duplicateSkillNames(group.skills));
|
||||
return bySource;
|
||||
});
|
||||
|
||||
/**
|
||||
* The skill a completed `use_skill` call actually loaded, as reported by the
|
||||
* backend (`explain_data.skillId` — `crates/dbx-core/src/ai/skill_tools.rs`).
|
||||
*
|
||||
* Read from the tool *result*, never re-derived from the call arguments: the
|
||||
* arguments say what the model asked for, and an ambiguous or missing name loads
|
||||
* nothing at all. Mapping the arguments back through the catalog would light a
|
||||
* chip for a body that never arrived, and the next send would then re-read and
|
||||
* inject that wrong body into the prompt.
|
||||
*/
|
||||
function loadedSkillIdFromToolResult(result: unknown): string | undefined {
|
||||
if (!result || typeof result !== "object") return undefined;
|
||||
const explain = (result as { explain_data?: unknown }).explain_data;
|
||||
if (!explain || typeof explain !== "object") return undefined;
|
||||
const id = (explain as { skillId?: unknown }).skillId;
|
||||
return typeof id === "string" && id ? id : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded = this conversation's recorded facts (a model-initiated `use_skill` in
|
||||
* one of its runs, a forced load, or the ids restored from its own transcript).
|
||||
* Per-conversation and never recomputed per turn, so the chip cannot flicker
|
||||
* between turns; a new chat starts over, and a different conversation reads its
|
||||
* own key.
|
||||
*/
|
||||
/**
|
||||
* Key for the per-conversation skill maps: the conversation's id once it has one,
|
||||
* and otherwise an id for the unsaved chat — fresh per panel instance and per
|
||||
* "new chat", because such a chat cannot be reopened, so no other instance may
|
||||
* inherit its state.
|
||||
*/
|
||||
const unsavedConversationKey = ref(`unsaved:${uuid()}`);
|
||||
const skillStateKey = computed(() => conversationId.value || unsavedConversationKey.value);
|
||||
|
||||
/**
|
||||
* Loaded = this conversation's recorded facts (a model-initiated `use_skill` in
|
||||
* one of its runs, a forced load, or the ids restored from its own transcript).
|
||||
* Per-conversation and never recomputed per turn, so the chip cannot flicker
|
||||
* between turns; a new chat starts over, and a different conversation reads its
|
||||
* own key.
|
||||
*/
|
||||
const loadedSkillIds = computed<Set<string>>(() => new Set(conversationLoadedSkillIds.get(skillStateKey.value) ?? []));
|
||||
|
||||
function recordConversationSkillLoad(key: string, id: string) {
|
||||
const loaded = conversationLoadedSkillIds.get(key) ?? [];
|
||||
if (loaded.includes(id)) return;
|
||||
conversationLoadedSkillIds.set(key, [...loaded, id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the unsaved chat's skill state onto the id it just adopted. The id
|
||||
* watcher covers this for the UI, but watchers flush later: `send()` mints the id
|
||||
* and reads this state in the same tick, so it calls this again itself.
|
||||
*/
|
||||
function migrateUnsavedSkillState(id: string) {
|
||||
const unsavedKey = unsavedConversationKey.value;
|
||||
if (!unsavedKey || unsavedKey === id) return;
|
||||
const facts = conversationLoadedSkillIds.get(unsavedKey);
|
||||
if (facts) {
|
||||
conversationLoadedSkillIds.delete(unsavedKey);
|
||||
conversationLoadedSkillIds.set(id, facts);
|
||||
}
|
||||
const bodies = conversationLoadedSkillBodies.get(unsavedKey);
|
||||
if (bodies) {
|
||||
conversationLoadedSkillBodies.delete(unsavedKey);
|
||||
conversationLoadedSkillBodies.set(id, bodies);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the conversation's facts from its persisted transcript (Req 13 after a
|
||||
* restart). Every snapshot writes the whole list onto the newest assistant turn,
|
||||
* so the newest carrier is the authoritative one — older turns hold stale
|
||||
* subsets, and reading the union would resurrect an id a later snapshot dropped
|
||||
* (a skill that was deleted, say).
|
||||
*/
|
||||
function seedLoadedSkillsFromTranscript(conversationIdToSeed: string, transcript: readonly ChatMessage[]) {
|
||||
for (let i = transcript.length - 1; i >= 0; i--) {
|
||||
const persisted = transcript[i].loadedSkillIds;
|
||||
if (!persisted?.length) continue;
|
||||
const existing = conversationLoadedSkillIds.get(conversationIdToSeed) ?? [];
|
||||
const merged = [...existing, ...persisted.filter((id) => !existing.includes(id))];
|
||||
conversationLoadedSkillIds.set(conversationIdToSeed, merged);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Req 14's one-time hint: the first turn of a conversation in which something is
|
||||
* selected and none of it has been loaded yet. It is latched rather than derived
|
||||
* (so it does not re-render every turn) and it disappears on its own once the
|
||||
* skill it names is loaded, or when the user drops its chip.
|
||||
*/
|
||||
const skillHintLatch = ref<{ key: string; id: string; name: string } | null>(null);
|
||||
const showSkillHint = computed(() => {
|
||||
const latch = skillHintLatch.value;
|
||||
if (!latch || latch.key !== skillStateKey.value) return false;
|
||||
return selectedSkillIds.value.includes(latch.id) && !loadedSkillIds.value.has(latch.id);
|
||||
});
|
||||
const skillHintText = computed(() => t("ai.skillsPreloadHint", { name: skillHintLatch.value?.name ?? "" }));
|
||||
function preloadHintedSkill() {
|
||||
const id = skillHintLatch.value?.id;
|
||||
if (id) void preloadSkill(id);
|
||||
}
|
||||
/** The hint names one specific skill, so dropping that skill retires it. */
|
||||
function forgetSkillHintFor(id: string) {
|
||||
if (skillHintLatch.value?.id === id) skillHintLatch.value = null;
|
||||
}
|
||||
watch([skillStateKey, () => selectedSkillChips.value.map((chip) => chip.id).join(","), () => [...loadedSkillIds.value].sort().join(",")], () => {
|
||||
if (skillCapabilityUnavailable.value) return;
|
||||
const key = skillStateKey.value;
|
||||
if (conversationSkillHintSeen.has(key)) return;
|
||||
const chips = selectedSkillChips.value;
|
||||
if (!chips.length) return;
|
||||
if (chips.some((chip) => loadedSkillIds.value.has(chip.id))) return;
|
||||
conversationSkillHintSeen.add(key);
|
||||
skillHintLatch.value = { key, id: chips[0].id, name: chips[0].name };
|
||||
});
|
||||
// Minting the unsaved chat's id on its first send (or when queueing) is not a
|
||||
// conversation switch: move the unsaved chat's skill state onto the id it just
|
||||
// adopted, or the brand-new id would read as a fresh conversation (Req 14) and
|
||||
// would lose the bodies a forced load already put in this run's state (Req 13).
|
||||
watch(conversationId, (id, previous) => {
|
||||
if (previous !== "" || !id) return;
|
||||
const unsavedKey = unsavedConversationKey.value;
|
||||
if (conversationSkillHintSeen.delete(unsavedKey)) conversationSkillHintSeen.add(id);
|
||||
const latch = skillHintLatch.value;
|
||||
if (latch && latch.key === unsavedKey) skillHintLatch.value = { ...latch, key: id };
|
||||
migrateUnsavedSkillState(id);
|
||||
});
|
||||
// The selector's capability state (predicate, gated open state, trigger label)
|
||||
// lives below `activeFullConfig`, next to the config it reads.
|
||||
const selectableSkillIds = computed(() => userSkillStore.groupedSkills.flatMap((group) => group.skills.map((skill) => skill.id)));
|
||||
const canSelectAllSkills = computed(() => !userSkillStore.isLoading && !userSkillStore.lastError && selectableSkillIds.value.some((id) => !selectedSkillIds.value.includes(id)));
|
||||
// Selector retry-on-open mirrors the template selector above.
|
||||
@@ -682,6 +888,7 @@ function deselectAllSkills() {
|
||||
function removeSelectedSkill(id: string) {
|
||||
selectedSkillIds.value = removeSkillIds(selectedSkillIds.value, [id]);
|
||||
skillFailures.value = [];
|
||||
forgetSkillHintFor(id);
|
||||
}
|
||||
|
||||
/** Banner Remove: drops every failed skill from the selection, so the next send is not blocked by a stale id. */
|
||||
@@ -715,9 +922,125 @@ function openSkillRootSettings() {
|
||||
emit("openSettings");
|
||||
}
|
||||
|
||||
function selectedSkillsAgentStep(skills: ReadUserSkill[]): AiAgentStepItem {
|
||||
function selectedSkillsListedStep(skills: readonly UserSkillMeta[]): AiAgentStepItem {
|
||||
const details = skills.map((skill) => `${skill.name} (${t(userSkillSourceOfId(skill.id) === "custom" ? "ai.skillsGroupCustom" : "ai.skillsGroupDefault")})`).join(" · ");
|
||||
return { key: "selected-skills", labelKey: "ai.agentSteps.skillsLoaded", tone: "success", toolName: skills.map((skill) => skill.name).join(", "), toolResult: details };
|
||||
return { key: "selected-skills", labelKey: "ai.agentSteps.skillsListed", tone: "success", toolName: skills.map((skill) => skill.name).join(", "), toolResult: details };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-shaped call id for the synthetic round. Gemini derives a tool result's
|
||||
* function name from the id (`gemini-tc-<name>-<index>`, see `build_gemini_contents`
|
||||
* in dbx-ai-provider), so a generic id would replay the result as "unknown" and
|
||||
* the provider would reject the whole request.
|
||||
*/
|
||||
function skillLoadCallId(skillName: string): string {
|
||||
return activeFullConfig.value?.provider === "gemini" ? `gemini-tc-${USE_SKILL_TOOL_NAME}-0` : `dbx-skill-${skillName}-${uuid()}`;
|
||||
}
|
||||
|
||||
/** Same shape `use_skill` returns, minus the file listing: the panel's read API
|
||||
* returns the body only, and the model can still call `use_skill` for the list. */
|
||||
function skillToolResultText(name: string, source: string, content: string): string {
|
||||
return `# Skill: ${name} [${source}]\n\n${content.trimEnd()}\n\nFollow this skill's instructions.\n`;
|
||||
}
|
||||
|
||||
/** The pair a real `use_skill` round produces, for the request to replay. Built
|
||||
* per request — never written to the transcript or to storage. */
|
||||
function skillRoundMessages(bodies: readonly LoadedSkillBody[]): AiMessage[] {
|
||||
return bodies.flatMap((body): AiMessage[] => {
|
||||
const callId = skillLoadCallId(body.name);
|
||||
return [
|
||||
{ role: "assistant", content: "", toolCalls: [{ id: callId, name: USE_SKILL_TOOL_NAME, arguments: { skill: body.name, source: body.source } }] },
|
||||
{ role: "tool", content: skillToolResultText(body.name, body.source, body.content), toolCallId: callId },
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Req 13's forced load: read the body now and remember it for this conversation.
|
||||
*
|
||||
* The body stays in this app run (`conversationLoadedSkillBodies`); only the fact
|
||||
* that the skill is loaded is persisted. A failed read is reported and stores
|
||||
* nothing — that refusal is the guard, since a body can be up to 1 MiB and a
|
||||
* result that size would wreck the context instead of helping it.
|
||||
*/
|
||||
async function preloadSkill(id: string) {
|
||||
if (skillCapabilityUnavailable.value) return;
|
||||
// Re-loading would stack the same body into the request twice.
|
||||
if (loadedSkillIds.value.has(id) || skillPreloadPending.value.includes(id)) return;
|
||||
const name = userSkillStore.metaFor(id)?.name ?? id;
|
||||
skillPreloadPending.value = [...skillPreloadPending.value, id];
|
||||
try {
|
||||
const result = await readUserSkills([id], skillRootSettings());
|
||||
const skill = result.skills.find((entry) => entry.id === id);
|
||||
if (!skill) {
|
||||
const reason = result.failures.find((failure) => failure.id === id)?.reason ?? "unreadable";
|
||||
// A toast, not the send-blocked banner: no request was refused here, and the
|
||||
// banner's recovery actions all belong to a send that was.
|
||||
toast(`${t("ai.skillsPreloadFailed")}: ${name} — ${t(skillFailureReasonKey(reason))}`, 5000);
|
||||
return;
|
||||
}
|
||||
const key = skillStateKey.value;
|
||||
const source = userSkillSourceOfId(id);
|
||||
conversationLoadedSkillBodies.set(key, [...(conversationLoadedSkillBodies.get(key) ?? []), { id, name: skill.name, source, content: skill.content }]);
|
||||
recordConversationSkillLoad(key, id);
|
||||
// Only an already-stored conversation is re-persisted: a chip click must not
|
||||
// create a conversation of its own (the first send snapshots the fact).
|
||||
if (conversationId.value && conversations.value.some((conversation) => conversation.id === conversationId.value)) {
|
||||
void persistConversation();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast(`${t("ai.skillsPreloadFailed")}: ${name} — ${translateBackendError(t, message)}`, 5000);
|
||||
} finally {
|
||||
skillPreloadPending.value = skillPreloadPending.value.filter((pending) => pending !== id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bodies a request should replay: this run's loaded bodies, plus the ones a
|
||||
* restart left as a fact without a body.
|
||||
*
|
||||
* Re-reading those is NOT ADR Decision 3's rejected auto-fallback. That decision
|
||||
* forbade loading a skill the conversation never used into an unrelated
|
||||
* conversation, because the body then stays in the history and burns tokens
|
||||
* forever. This restores state the conversation already had, on the topic it is
|
||||
* already on, and the ids come from what the user or the model actually loaded.
|
||||
*
|
||||
* A skill that has since been deleted or grown past the read limit cannot be
|
||||
* restored: its id is dropped from the facts (so the chip stops claiming it is
|
||||
* loaded and the next snapshot persists the correction) and the reason is shown.
|
||||
*/
|
||||
async function rehydrateLoadedSkillBodies(key: string): Promise<LoadedSkillBody[]> {
|
||||
const ids = conversationLoadedSkillIds.get(key) ?? [];
|
||||
if (!ids.length) return [];
|
||||
const bodies = [...(conversationLoadedSkillBodies.get(key) ?? [])];
|
||||
const missing = ids.filter((id) => !bodies.some((body) => body.id === id));
|
||||
if (!missing.length) return bodies;
|
||||
try {
|
||||
const result = await readUserSkills(missing, skillRootSettings());
|
||||
for (const skill of result.skills) {
|
||||
bodies.push({ id: skill.id, name: skill.name, source: userSkillSourceOfId(skill.id), content: skill.content });
|
||||
}
|
||||
for (const failure of result.failures) {
|
||||
const name = userSkillStore.metaFor(failure.id)?.name ?? failure.id;
|
||||
toast(`${t("ai.skillsPreloadFailed")}: ${name} — ${t(skillFailureReasonKey(failure.reason))}`, 5000);
|
||||
}
|
||||
if (result.failures.length) {
|
||||
const failed = new Set(result.failures.map((failure) => failure.id));
|
||||
conversationLoadedSkillIds.set(
|
||||
key,
|
||||
ids.filter((id) => !failed.has(id)),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// The read itself failed (backend unavailable). Keep the facts — this is an
|
||||
// environment failure, not evidence that the skills are gone — and send the
|
||||
// request without the bodies rather than failing the whole send.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast(`${t("ai.skillsPreloadFailed")}: ${translateBackendError(t, message)}`, 5000);
|
||||
}
|
||||
conversationLoadedSkillBodies.set(key, bodies);
|
||||
return bodies;
|
||||
}
|
||||
|
||||
// Retry store load on selector open if prior init failed (e.g. backend not yet ready at mount)
|
||||
@@ -1066,6 +1389,39 @@ function isModelCandidate(config: AiConfigItem): boolean {
|
||||
return isAiConfigModelCandidate(config, getAiProviderPreset(config.provider, config.endpoint).requiresApiKey, supportsCliProviders);
|
||||
}
|
||||
|
||||
// Skill capability belongs to DBX's built-in AI alone (prd 09-30 Req 15a): a CLI
|
||||
// run returns before the agent loop builds its tool list and its surface is the
|
||||
// DBX MCP server, so nothing in a CLI prompt can reference a skill.
|
||||
//
|
||||
// These sit here, after `activeFullConfig`, and not beside `showSkillSelector`:
|
||||
// `watch()` reads its source once at creation, so a predicate placed above the
|
||||
// config it reads throws the same "Cannot access before initialization" that once
|
||||
// kept this panel from opening at all.
|
||||
//
|
||||
// The predicate reads the very config the send path resolves, so the selector and
|
||||
// the send can never disagree about the provider.
|
||||
const skillCapabilityUnavailable = computed(() => isCliProvider(activeFullConfig.value?.provider));
|
||||
// The open state is gated here rather than left to the button's native `disabled`
|
||||
// attribute: `/skill` writes the selector ref directly, and a disabled button is
|
||||
// not guaranteed to stop a PopoverTrigger from opening. `showSkillSelector` stays
|
||||
// the source of truth (the retry-on-open watcher reads it); writing `true` while
|
||||
// unavailable is dropped, so no path reaches the silent "ticked but never sent"
|
||||
// state this state exists to remove.
|
||||
const skillSelectorOpen = computed<boolean>({
|
||||
get: () => showSkillSelector.value && !skillCapabilityUnavailable.value,
|
||||
set: (open) => {
|
||||
showSkillSelector.value = open && !skillCapabilityUnavailable.value;
|
||||
},
|
||||
});
|
||||
// Switching to a CLI provider while the selector is open must close it, otherwise
|
||||
// the stale `true` would reopen the popover on a switch back to a built-in model.
|
||||
watch(skillCapabilityUnavailable, (unavailable) => {
|
||||
if (unavailable) showSkillSelector.value = false;
|
||||
});
|
||||
// A disabled control with no explanation is the silent state being removed: the
|
||||
// reason rides on the trigger's label and title whenever the capability is gone.
|
||||
const skillSelectorTriggerLabel = computed(() => t(skillCapabilityUnavailable.value ? "ai.skillsUnsupportedForCliProvider" : "ai.skillsEntry"));
|
||||
|
||||
function getModelsForConfig(configId: string) {
|
||||
const config = settings.aiConfigs.find((item) => item.id === configId);
|
||||
if (!config) return [];
|
||||
@@ -1368,7 +1724,10 @@ type AiSlashEntry = { type: "action"; button: AiActionButton } | { type: "skills
|
||||
|
||||
const filteredCommands = computed<AiSlashEntry[]>(() => {
|
||||
const query = prompt.value.slice(commandStart.value + 1).toLowerCase();
|
||||
const entries: AiSlashEntry[] = [...actionButtons.value.map((button): AiSlashEntry => ({ type: "action", button })), { type: "skills" }];
|
||||
// `/skill` is dropped when the provider has no skill capability (Req 15a), so the
|
||||
// palette never offers an action that cannot happen — the disabled trigger's
|
||||
// label carries the reason.
|
||||
const entries: AiSlashEntry[] = [...actionButtons.value.map((button): AiSlashEntry => ({ type: "action", button })), ...(skillCapabilityUnavailable.value ? [] : [{ type: "skills" } as AiSlashEntry])];
|
||||
return entries.filter((entry) => {
|
||||
const haystack = entry.type === "action" ? `${entry.button.action} ${t(entry.button.key)}` : `skill ${t("ai.skillsEntry")}`;
|
||||
return haystack.toLowerCase().includes(query);
|
||||
@@ -2206,6 +2565,13 @@ function extractExplainData(result: unknown): unknown | undefined {
|
||||
|
||||
/** Parse explain_data (a serialized QueryResult) into ParsedExplainPlan */
|
||||
function parseExplainFromData(explainData: unknown, dbType: string): ParsedExplainPlan | undefined {
|
||||
if (dbType === "db2" && typeof explainData === "string") {
|
||||
try {
|
||||
return parseDb2ExplainText(explainData);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (dbType === "oracle" && typeof explainData === "string") {
|
||||
return parseOracleExplainText(explainData);
|
||||
}
|
||||
@@ -2872,8 +3238,16 @@ function selectCommand(command: AiSlashEntry) {
|
||||
commandOpen.value = false;
|
||||
if (command.type === "skills") {
|
||||
skillFailures.value = [];
|
||||
showSkillSelector.value = true;
|
||||
nextTick(() => promptTextareaRef.value?.focus());
|
||||
// Routed through the gated open state, so the palette entry cannot open the
|
||||
// selector when the provider has no skill capability (Req 15a).
|
||||
skillSelectorOpen.value = true;
|
||||
// No `nextTick(() => textarea.focus())` on this branch, unlike the action
|
||||
// branch below. The just-opened popover autofocuses its own content, so
|
||||
// pulling focus back to the textarea fires a `focusin` outside the layer and
|
||||
// reka's DismissableLayer reads it as a focus-outside and dismisses the
|
||||
// popover on the next tick — the `/skill` entry then looks like it does
|
||||
// nothing at all. The entry fires on `mousedown` with `prevent`, so the
|
||||
// textarea already keeps focus; nothing needs to be restored.
|
||||
return;
|
||||
}
|
||||
activeAction.value = command.button.action;
|
||||
@@ -3397,6 +3771,9 @@ async function send() {
|
||||
}
|
||||
const myGeneration = aiGenerationGuard.begin();
|
||||
if (!auto && !conversationId.value) conversationId.value = uuid();
|
||||
// The id was minted a line ago and the watcher has not flushed yet: this send
|
||||
// must already read the skill state of the conversation it is now running for.
|
||||
if (!auto) migrateUnsavedSkillState(conversationId.value);
|
||||
const runConversationId = auto ? auto.conversationId : conversationId.value;
|
||||
const runMessages = auto ? auto.messages : messages.value;
|
||||
const runCreatedAt = new Date().toISOString();
|
||||
@@ -3457,17 +3834,50 @@ async function send() {
|
||||
}
|
||||
const generationCanContinue = () => (detachedRun ? !detachedRun.cancelRequested : aiGenerationGuard.isCurrent(myGeneration));
|
||||
const runIsVisible = () => !detachedRun || (assistantViewMounted && conversationId.value === runConversationId);
|
||||
// Selected skills are read and validated through the backend registry right
|
||||
// before the request pipeline starts (after the generation guard: no awaits
|
||||
// above this point). On any failure no AI request is sent: the failure banner
|
||||
// appears above the composer while the draft and selection stay untouched
|
||||
// (prd send-time loading contract).
|
||||
let sendSkillSnapshot: ReadUserSkill[] | undefined;
|
||||
if (selectedSkillIds.value.length > 0) {
|
||||
// Selected skills are no longer read at send time: their bodies never enter the
|
||||
// prompt (they load on demand through the use_skill tool, which only the
|
||||
// built-in AI has — a CLI run receives no skill content at all). Only the
|
||||
// catalog is consulted, to tell the user that a checked skill has disappeared
|
||||
// before the request goes out. On any failure no AI request is sent: the
|
||||
// failure banner appears above the composer while the draft and selection stay
|
||||
// untouched.
|
||||
//
|
||||
// Skill capability is built-in-AI only (Req 12): a CLI provider gets neither
|
||||
// the listing nor the two tools, so the whole block is skipped for it. The
|
||||
// selector is disabled there too (Req 15a), but a selection made under an API
|
||||
// provider survives a model switch, and nothing of it may reach the CLI run.
|
||||
let sendSkillListing: string[] | undefined;
|
||||
let sendSelectedSkillMetas: UserSkillMeta[] | undefined;
|
||||
const skillsAvailable = !isCliProvider(activeConfig.provider);
|
||||
// Req 5: the listing rides along when the user ticked something OR the
|
||||
// "allow the AI to use skills automatically" Settings toggle (default off) is
|
||||
// on. `allowSkills` below then follows from the listing itself, so the two
|
||||
// skill tools ship exactly when the prompt points at them (ADR Decision 10).
|
||||
const autoSkillsEnabled = settings.desktopSettings?.custom_ai_skill_auto_enabled === true;
|
||||
if (skillsAvailable && (selectedSkillIds.value.length > 0 || autoSkillsEnabled)) {
|
||||
skillFailures.value = [];
|
||||
const skillRead = await readUserSkills([...selectedSkillIds.value], skillRootSettings());
|
||||
if (skillRead.failures.length > 0) {
|
||||
skillFailures.value = skillRead.failures;
|
||||
let catalog: UserSkillsListResult;
|
||||
try {
|
||||
catalog = await listUserSkills(skillRootSettings());
|
||||
} catch {
|
||||
// The catalog gates nothing on its own: with nothing selected the request
|
||||
// just goes out skill-free, and a checked skill is reported as
|
||||
// unavailable. An unhandled rejection here would take the whole send down.
|
||||
catalog = { defaultRoot: { status: "invalid", skills: [] }, customRoot: null };
|
||||
}
|
||||
const catalogSkills = [...catalog.defaultRoot.skills, ...(catalog.customRoot?.skills ?? [])];
|
||||
const known = new Set(catalogSkills.map((skill) => skill.id));
|
||||
const failures: ReadUserSkillFailure[] = [];
|
||||
for (const id of selectedSkillIds.value) {
|
||||
if (known.has(id)) continue;
|
||||
// Distinguish "this skill is gone" from "its whole root is unavailable" —
|
||||
// the frozen reason vocabulary has one word for each, and the banner copy
|
||||
// differs.
|
||||
const rootUnavailable = userSkillSourceOfId(id) === "custom" ? !catalog.customRoot || catalog.customRoot.status !== "ok" : catalog.defaultRoot.status !== "ok";
|
||||
failures.push({ id, reason: rootUnavailable ? "root_unavailable" : "not_found" });
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
skillFailures.value = failures;
|
||||
clearPendingWriteGrant();
|
||||
if (generationCanContinue()) {
|
||||
if (detachedRun) finishDesktopAiRun(detachedRun, "failed");
|
||||
@@ -3480,14 +3890,10 @@ async function send() {
|
||||
resolveDetachedRunSettled();
|
||||
return;
|
||||
}
|
||||
// Skill bodies are only known here, so the combined budget is enforced on
|
||||
// the read snapshots: overflowing skills are skipped for this send while
|
||||
// the selection itself stays untouched.
|
||||
const cappedSkills = capSkillsToCharLimit(skillRead.skills, ACTIVE_SKILLS_TOTAL_MAX);
|
||||
if (cappedSkills.length < skillRead.skills.length) {
|
||||
toast(t("ai.skillsTotalTrimmed", { max: ACTIVE_SKILLS_TOTAL_MAX }), 4000);
|
||||
}
|
||||
sendSkillSnapshot = cappedSkills;
|
||||
// Only the metadata survives the send: the listing is rendered here (it is
|
||||
// what the prompt carries) and the metas feed the transcript's agent step.
|
||||
sendSelectedSkillMetas = selectedSkillIds.value.flatMap((id) => catalogSkills.filter((skill) => skill.id === id));
|
||||
sendSkillListing = buildSkillListingLines(buildSkillListing({ skills: catalogSkills, selectedIds: selectedSkillIds.value }), locale.value.startsWith("zh"));
|
||||
}
|
||||
if (!(await promptTemplateStore.ensureLoaded())) {
|
||||
clearPendingWriteGrant();
|
||||
@@ -3527,8 +3933,25 @@ async function send() {
|
||||
const customPromptContext: CustomPromptContext = {
|
||||
globalInstructions: promptTemplateStore.globalInstructions,
|
||||
activeTemplates: runPluginContext ? [] : [...activeTemplates.value],
|
||||
...(sendSkillSnapshot?.length ? { selectedSkills: sendSkillSnapshot } : {}),
|
||||
...(sendSkillListing?.length ? { skillListing: sendSkillListing } : {}),
|
||||
};
|
||||
// The skill tools ship with the listing and nothing else gates them (ADR
|
||||
// Decision 10), so this is the same condition that just put lines in the
|
||||
// prompt rather than a second decision that could drift away from it.
|
||||
const allowSkills = Boolean(sendSkillListing?.length);
|
||||
// The bodies this request should replay: the ones loaded in this session, plus
|
||||
// the ones this conversation had loaded before a restart and never kept on disk
|
||||
// (Req 13). Only when the request declares the skill tools — a send with no
|
||||
// listing carries no `use_skill`, and Gemini and Anthropic reject a replayed
|
||||
// call to a function they were not given.
|
||||
const skillBodies = allowSkills ? await rehydrateLoadedSkillBodies(runConversationId) : [];
|
||||
// Superseded while the bodies were being re-read: this generation no longer owns
|
||||
// the conversation, and `skillBodies` describes a state it may have left.
|
||||
if (!generationCanContinue()) {
|
||||
clearPendingWriteGrant();
|
||||
resolveDetachedRunSettled();
|
||||
return;
|
||||
}
|
||||
// Remember what was actually sent for this db_type so panels opened later can
|
||||
// restore it when no explicit per-db_type defaults are configured. This runs
|
||||
// for empty selections too: the store clears the remembered entry so a
|
||||
@@ -3705,8 +4128,8 @@ async function send() {
|
||||
}
|
||||
runMessages.push({ role: "assistant", content: "", sourceConnectionName: runSourceName, sourceBinding: runBinding });
|
||||
const assistantIdx = runMessages.length - 1;
|
||||
if (requestedMode === "agent" && sendSkillSnapshot?.length) {
|
||||
runMessages[assistantIdx].agentSteps = [selectedSkillsAgentStep(sendSkillSnapshot)];
|
||||
if (requestedMode === "agent" && sendSelectedSkillMetas?.length) {
|
||||
runMessages[assistantIdx].agentSteps = [selectedSkillsListedStep(sendSelectedSkillMetas)];
|
||||
}
|
||||
const sessionId = uuid();
|
||||
if (runIsVisible()) {
|
||||
@@ -3742,7 +4165,10 @@ async function send() {
|
||||
void persistConversationSnapshot(runConversationId, runMessages, runSourceName, tab?.database || "", runCreatedAt);
|
||||
}
|
||||
try {
|
||||
const history: AiMessage[] = messagesForAgentHistory(runMessages.slice(0, -2));
|
||||
// The skill round is appended after the transcript's own turns: it reaches the
|
||||
// model as the most recent context before the new user prompt, and it is built
|
||||
// per request so the body never enters the transcript or any stored record.
|
||||
const history: AiMessage[] = [...messagesForAgentHistory(runMessages.slice(0, -2)), ...skillRoundMessages(skillBodies)];
|
||||
const onEvent = (event: AgentEvent) => {
|
||||
// Superseded by a clear/switch/new-chat (or a newer send()) — the backend
|
||||
// stream may still be running, but this generation no longer owns any
|
||||
@@ -3800,6 +4226,14 @@ async function send() {
|
||||
if (detachedDeltaBuffer) detachedDeltaBuffer.appendReasoning(assistantIdx, event.delta);
|
||||
else appendAssistantReasoning(assistantIdx, event.delta);
|
||||
}
|
||||
if (event.type === "output_truncated") {
|
||||
// The provider cut the answer off at its output limit; without this the card
|
||||
// would look like a finished reply. Appended in the user's own locale, like
|
||||
// every other string the panel writes.
|
||||
const notice = `\n\n[${t("ai.outputTruncated")}]`;
|
||||
if (detachedDeltaBuffer) detachedDeltaBuffer.appendText(assistantIdx, notice);
|
||||
else appendAssistantDelta(assistantIdx, notice);
|
||||
}
|
||||
if (event.type === "agent_end") {
|
||||
// End the card's "思考过程" spinner at the terminal event rather than
|
||||
// waiting for send()'s finally (which can lag behind CLI teardown).
|
||||
@@ -3822,6 +4256,19 @@ async function send() {
|
||||
}
|
||||
// Real-time agent step rendering
|
||||
if (event.type === "tool_call_start" || event.type === "tool_call_end") {
|
||||
// Req 13's "the model actually used it" signal. The tool round itself
|
||||
// lives inside the backend run and is never persisted back into the
|
||||
// transcript, so the event stream is where this is observed — recorded
|
||||
// under the run's own conversation, which a background send may not be
|
||||
// showing.
|
||||
//
|
||||
// Only a *completed, successful* call proves a load: `tool_call_start`
|
||||
// fires before the tool runs, and an ambiguous or missing name ends as an
|
||||
// error having loaded nothing.
|
||||
if (event.type === "tool_call_end" && event.tool_name === USE_SKILL_TOOL_NAME && !event.is_error) {
|
||||
const loadedSkillId = loadedSkillIdFromToolResult(event.result);
|
||||
if (loadedSkillId && runConversationId) recordConversationSkillLoad(runConversationId, loadedSkillId);
|
||||
}
|
||||
const msg = runMessages[assistantIdx];
|
||||
if (msg) {
|
||||
if (!msg.agentSteps) msg.agentSteps = [];
|
||||
@@ -3877,6 +4324,7 @@ async function send() {
|
||||
taskContractUserRequest: text,
|
||||
context,
|
||||
inlineImages: imageAttachments.map(({ mediaType, data }) => ({ mediaType, data })),
|
||||
allowSkills,
|
||||
},
|
||||
history,
|
||||
onEvent,
|
||||
@@ -3942,6 +4390,7 @@ async function send() {
|
||||
confirmedConnectionId: confirmedTargetConnId,
|
||||
confirmedDatabase: confirmedTargetDb,
|
||||
confirmedSchema: confirmedTargetSchema,
|
||||
allowSkills,
|
||||
},
|
||||
history,
|
||||
onEvent,
|
||||
@@ -4024,6 +4473,8 @@ async function send() {
|
||||
if (compaction) {
|
||||
const { summary, compactedMessages } = compaction;
|
||||
if (!detachedRun) pendingCompaction.value = null;
|
||||
// The backend counted the messages it received; the transcript is what it
|
||||
// counted (skill rounds never enter it — see `skillRoundMessages`).
|
||||
const insertAt = Math.min(1 + compactedMessages, runMessages.length - 1);
|
||||
if (summary) {
|
||||
runMessages.splice(insertAt, 0, {
|
||||
@@ -4588,6 +5039,14 @@ function clearMessages() {
|
||||
historyIndex.value = -1;
|
||||
draftBeforeHistory.value = "";
|
||||
recoveredDraftActive.value = false;
|
||||
// A new chat is a new conversation, so it gets its own hint identity and a
|
||||
// first hint of its own (Req 14). Its predecessor's skill state is dropped with
|
||||
// it — that chat's id can never come back.
|
||||
skillHintLatch.value = null;
|
||||
conversationSkillHintSeen.delete(unsavedConversationKey.value);
|
||||
conversationLoadedSkillIds.delete(unsavedConversationKey.value);
|
||||
conversationLoadedSkillBodies.delete(unsavedConversationKey.value);
|
||||
unsavedConversationKey.value = `unsaved:${uuid()}`;
|
||||
messageRenderer.value.clear();
|
||||
}
|
||||
|
||||
@@ -4616,6 +5075,14 @@ function buildConversationSnapshot(targetConversationId: string, targetMessages:
|
||||
const first = targetMessages.find((m) => m.role === "user" && m.kind !== "contextSummary");
|
||||
const existingConversation = conversations.value.find((conversation) => conversation.id === targetConversationId);
|
||||
const binding = snapshotBinding(targetConversationId, fallbackBinding);
|
||||
let lastAssistantIndex = -1;
|
||||
for (let i = targetMessages.length - 1; i >= 0; i--) {
|
||||
if (targetMessages[i].role === "assistant") {
|
||||
lastAssistantIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const loadedSkillIdsForSnapshot = conversationLoadedSkillIds.get(targetConversationId) ?? [];
|
||||
return {
|
||||
id: targetConversationId,
|
||||
title: first?.pluginContext?.title || renamedConversationTitles.get(targetConversationId) || existingConversation?.title || (first ? messageTitle(first).slice(0, 50) : "Untitled"),
|
||||
@@ -4628,7 +5095,7 @@ function buildConversationSnapshot(targetConversationId: string, targetMessages:
|
||||
connectionId: binding.connectionId,
|
||||
database: existingConversation ? binding.database : database,
|
||||
schema: binding.schema,
|
||||
messages: targetMessages.map((m) => ({
|
||||
messages: targetMessages.map((m, index) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
...(m.mentions?.length ? { mentions: m.mentions } : {}),
|
||||
@@ -4644,6 +5111,11 @@ function buildConversationSnapshot(targetConversationId: string, targetMessages:
|
||||
// Preserve it on every subsequent snapshot or the second restart silently
|
||||
// loses the omission warning again.
|
||||
...(m.selections?.length || m.selectionsOmitted ? { selectionsOmitted: true } : {}),
|
||||
// Skills this conversation has loaded (Req 13), carried on the newest
|
||||
// assistant turn so a reload reads back one authoritative list. Only the
|
||||
// ids: see `ChatMessage.loadedSkillIds` for why the bodies stay out of the
|
||||
// record.
|
||||
...(index === lastAssistantIndex && loadedSkillIdsForSnapshot.length ? { loadedSkillIds: [...loadedSkillIdsForSnapshot] } : {}),
|
||||
})),
|
||||
// The conversation's single queued "send later" input, persisted so it
|
||||
// survives a restart (parent PRD §5).
|
||||
@@ -4770,6 +5242,14 @@ async function persistPendingInputRecovery(conversation: AiConversation, message
|
||||
...(m.kind ? { kind: m.kind } : {}),
|
||||
...(m.failed ? { failed: true } : {}),
|
||||
...(m.sourceBinding ? { sourceBinding: m.sourceBinding } : {}),
|
||||
// This is a second snapshot-writing path beside
|
||||
// `buildConversationSnapshot`, and it replaces the whole message array.
|
||||
// Every footprint a message carries must be re-projected here or the
|
||||
// restart that recovers a queued run silently drops it: the selection
|
||||
// omission warning (#10058) and the loaded-skill fact (Req 13) both only
|
||||
// survive as long as each rewrite carries them forward.
|
||||
...(m.selections?.length || m.selectionsOmitted ? { selectionsOmitted: true } : {}),
|
||||
...(m.loadedSkillIds?.length ? { loadedSkillIds: [...m.loadedSkillIds] } : {}),
|
||||
})),
|
||||
queuedInput: conversation.queuedInput,
|
||||
createdAt: conversation.createdAt,
|
||||
@@ -4847,6 +5327,11 @@ function chatMessagesFromConversation(conv: AiConversation): ChatMessage[] {
|
||||
failed: m.failed === true ? true : undefined,
|
||||
// Only the footprint is loaded back; the selection text itself is gone.
|
||||
selectionsOmitted: m.selectionsOmitted === true ? true : undefined,
|
||||
// Which skills this conversation had loaded — the fact only, and only on the
|
||||
// turn that carried it. `seedLoadedSkillsFromTranscript` reads it back into
|
||||
// the panel's per-conversation state, and the body is re-read from disk when
|
||||
// a request needs it.
|
||||
loadedSkillIds: m.loadedSkillIds?.length ? m.loadedSkillIds : undefined,
|
||||
// Old transcripts have no per-turn target. Capture the conversation's
|
||||
// current binding on load so a later rebind cannot retarget a pending card.
|
||||
sourceBinding: m.sourceBinding ?? (m.role === "assistant" && conv.connectionId ? { connectionId: conv.connectionId, database: conv.database, schema: conv.schema } : undefined),
|
||||
@@ -4875,8 +5360,15 @@ function selectConversation(conv: AiConversation) {
|
||||
clearAttachmentDraftState();
|
||||
// Drop the previous conversation's rendered Markdown instead of keeping it until the LRU evicts it.
|
||||
messageRenderer.value.clear();
|
||||
// The arriving conversation owns its own one-time skill hint; the watcher below
|
||||
// latches it again only if that conversation has not shown one yet (Req 14).
|
||||
skillHintLatch.value = null;
|
||||
const activeRun = backgroundAiRunsEnabled ? desktopAiRun<ChatMessage>(conv.id) : undefined;
|
||||
messages.value = activeRun?.messages ?? chatMessagesFromConversation(conv);
|
||||
// Req 13 after a restart: the transcript keeps the fact that skills were
|
||||
// loaded, so the chips can light up before anything is read again. The bodies
|
||||
// come back when a request actually needs them (`rehydrateLoadedSkillBodies`).
|
||||
seedLoadedSkillsFromTranscript(conv.id, messages.value);
|
||||
if (pluginContext.value) {
|
||||
assistantMode.value = pluginContext.value.mode === "agent" && !boundConnection.value ? "ask" : (pluginContext.value.mode ?? "ask");
|
||||
activeAction.value = "general";
|
||||
@@ -4988,6 +5480,10 @@ async function performDeleteConversation(id: string) {
|
||||
removeDesktopAiRun(id);
|
||||
}
|
||||
queuedInputs.delete(id);
|
||||
// The conversation's skill state goes with it; the ids can never come back.
|
||||
conversationLoadedSkillIds.delete(id);
|
||||
conversationLoadedSkillBodies.delete(id);
|
||||
conversationSkillHintSeen.delete(id);
|
||||
await deleteConversationWithCancellation({
|
||||
id,
|
||||
currentConversationId: () => conversationId.value,
|
||||
@@ -6365,9 +6861,15 @@ async function openExternalUrl(url: string) {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<!-- Skill selector (read-only user SKILL.md library) -->
|
||||
<Popover v-model:open="showSkillSelector">
|
||||
<Popover v-model:open="skillSelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="ai-skills-selector-trigger flex min-w-0 items-center gap-1 rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('ai.skillsEntry')" :title="t('ai.skillsEntry')">
|
||||
<button
|
||||
type="button"
|
||||
class="ai-skills-selector-trigger flex min-w-0 items-center gap-1 rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
:disabled="skillCapabilityUnavailable"
|
||||
:aria-label="skillSelectorTriggerLabel"
|
||||
:title="skillSelectorTriggerLabel"
|
||||
>
|
||||
<Layers class="h-3 w-3" />
|
||||
<span class="ai-skills-selector-label truncate">{{ t("ai.skillsEntry") }}</span>
|
||||
<span v-if="selectedSkillIds.length" class="ai-skills-selector-count rounded-sm bg-primary px-1 text-[10px] font-medium text-primary-foreground">{{ selectedSkillIds.length }}</span>
|
||||
@@ -6392,13 +6894,25 @@ async function openExternalUrl(url: string) {
|
||||
<div class="px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{{ t(group.source === "custom" ? "ai.skillsGroupCustom" : "ai.skillsGroupDefault") }}
|
||||
</div>
|
||||
<!-- Same-root name collision: the model addresses skills by name + source,
|
||||
so it cannot pick between these two, and only changing one of the two
|
||||
frontmatter `name` values fixes it (the folder name is irrelevant). -->
|
||||
<div v-if="duplicateNamesBySource.get(group.source)?.size" class="ai-skill-duplicate-note mx-2 mb-1 rounded-sm bg-amber-500/10 px-2 py-1 text-[10px] leading-snug text-amber-600">
|
||||
{{ t("ai.skillsDuplicateNameNote") }}
|
||||
</div>
|
||||
<template v-for="skill in group.skills" :key="skill.id">
|
||||
<button type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs hover:bg-muted" :aria-pressed="selectedSkillIds.includes(skill.id)" @click="toggleSkillSelected(skill.id)">
|
||||
<div class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border" :class="selectedSkillIds.includes(skill.id) ? 'border-primary bg-primary text-primary-foreground' : ''">
|
||||
<Check v-if="selectedSkillIds.includes(skill.id)" class="h-3 w-3" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="truncate font-medium">{{ skill.name }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="truncate font-medium">{{ skill.name }}</span>
|
||||
<span v-if="duplicateNamesBySource.get(group.source)?.has(skill.name)" class="ai-skill-duplicate-name inline-flex shrink-0 items-center gap-0.5 rounded-sm bg-amber-500/15 px-1 text-[10px] font-normal text-amber-600" :title="t('ai.skillsDuplicateName')">
|
||||
<AlertTriangle class="h-2.5 w-2.5" />
|
||||
{{ t("ai.skillsDuplicateName") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="truncate text-[10px] text-muted-foreground">{{ skill.description }}</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -6507,21 +7021,37 @@ async function openExternalUrl(url: string) {
|
||||
<X class="h-3 w-3 shrink-0 text-muted-foreground group-hover:text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Req 13: a chip has two states (idle / loaded in this conversation)
|
||||
and clicking its body force-loads the skill. Removal therefore needs
|
||||
its own control — one button cannot both load and remove. -->
|
||||
<div v-if="selectedSkillChips.length" class="mb-1.5 flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="chip in selectedSkillChips"
|
||||
:key="chip.id"
|
||||
type="button"
|
||||
class="group inline-flex max-w-full items-center gap-1 rounded border bg-muted/60 px-1.5 py-0.5 text-[11px] text-foreground/90 hover:bg-muted"
|
||||
:class="chip.unavailable ? 'border-destructive/50 text-destructive' : 'border-border/80'"
|
||||
:title="chip.description || chip.name"
|
||||
@click="removeSelectedSkill(chip.id)"
|
||||
>
|
||||
<AlertTriangle v-if="chip.unavailable" class="h-3 w-3 shrink-0" />
|
||||
<Layers v-else class="h-3 w-3 shrink-0 text-primary" />
|
||||
<span class="truncate">{{ chip.name }}</span>
|
||||
<span class="shrink-0 text-[9px] text-muted-foreground">{{ t(chip.source === "custom" ? "ai.skillsGroupCustom" : "ai.skillsGroupDefault") }}</span>
|
||||
<X class="h-3 w-3 shrink-0 text-muted-foreground group-hover:text-foreground" />
|
||||
<span v-for="chip in selectedSkillChips" :key="chip.id" class="inline-flex max-w-full items-center rounded border bg-muted/60 text-[11px] text-foreground/90" :class="chip.unavailable ? 'border-destructive/50 text-destructive' : chip.loaded ? 'border-emerald-500/60' : 'border-border/80'">
|
||||
<button
|
||||
type="button"
|
||||
class="ai-skill-chip flex min-w-0 items-center gap-1 py-0.5 pl-1.5 pr-1 hover:bg-muted disabled:cursor-not-allowed"
|
||||
:disabled="skillCapabilityUnavailable || chip.loaded"
|
||||
:title="chip.loaded ? t('ai.skillsLoadedState') : skillCapabilityUnavailable ? t('ai.skillsUnsupportedForCliProvider') : t('ai.skillsPreload')"
|
||||
@click="preloadSkill(chip.id)"
|
||||
>
|
||||
<Loader2 v-if="skillPreloadPending.includes(chip.id)" class="h-3 w-3 shrink-0 animate-spin" />
|
||||
<Check v-else-if="chip.loaded" class="h-3 w-3 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
<AlertTriangle v-else-if="chip.unavailable" class="h-3 w-3 shrink-0" />
|
||||
<Layers v-else class="h-3 w-3 shrink-0 text-primary" />
|
||||
<span class="truncate" :title="chip.description || chip.name">{{ chip.name }}</span>
|
||||
<span class="shrink-0 text-[9px] text-muted-foreground">{{ t(chip.source === "custom" ? "ai.skillsGroupCustom" : "ai.skillsGroupDefault") }}</span>
|
||||
<span v-if="chip.loaded" class="shrink-0 text-[9px] font-medium text-emerald-600 dark:text-emerald-400">{{ t("ai.skillsLoadedState") }}</span>
|
||||
</button>
|
||||
<button type="button" class="ai-skill-chip-remove shrink-0 py-0.5 pl-0.5 pr-1.5 hover:text-destructive" :aria-label="t('common.remove')" :title="t('common.remove')" @click="removeSelectedSkill(chip.id)">
|
||||
<X class="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Req 14: one one-time hint per conversation while nothing selected has
|
||||
been loaded yet. Clicking it is the same force-load as the chip. -->
|
||||
<div v-if="showSkillHint" class="mb-1.5">
|
||||
<button type="button" class="ai-skill-hint inline-flex max-w-full items-center gap-1 rounded border border-primary/40 bg-primary/5 px-1.5 py-0.5 text-left text-[11px] text-primary hover:bg-primary/10" :title="t('ai.skillsPreload')" @click="preloadHintedSkill">
|
||||
<Sparkles class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">{{ skillHintText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="selectedCsvAttachments.length || selectedImageAttachments.length" class="mb-1.5 flex max-h-28 flex-wrap gap-1.5 overflow-y-auto pr-0.5">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user