mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:02:15 +00:00
Compare commits
215
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f6e9af634 | ||
|
|
0be46fc400 | ||
|
|
d9c464d7d5 | ||
|
|
287a21457e | ||
|
|
42eafa6d6b | ||
|
|
95f4511b0d | ||
|
|
cd77b06a59 | ||
|
|
f5f58d4ae5 | ||
|
|
5ae344409a | ||
|
|
e3dfe324e8 | ||
|
|
0feb065e48 | ||
|
|
838c115a2c | ||
|
|
61939e5e46 | ||
|
|
bcbc64c635 | ||
|
|
c65a627045 | ||
|
|
440625949b | ||
|
|
f70a9b2937 | ||
|
|
a1824c9079 | ||
|
|
d022f068cd | ||
|
|
85fc95bac8 | ||
|
|
b115002d07 | ||
|
|
3deb4bda7b | ||
|
|
e797340466 | ||
|
|
fdfdd25d23 | ||
|
|
4740942e8b | ||
|
|
d219f2b8ba | ||
|
|
18980ecdc2 | ||
|
|
fad22512cd | ||
|
|
083e05d412 | ||
|
|
88ea213c35 | ||
|
|
85b18a94d6 | ||
|
|
7670e04879 | ||
|
|
fc84e09738 | ||
|
|
177b9a3b39 | ||
|
|
4a4a7603b5 | ||
|
|
6a1778eaf3 | ||
|
|
72a86b56ac | ||
|
|
9dd7d1f044 | ||
|
|
cdb4ab5c80 | ||
|
|
97e3045a7c | ||
|
|
f43a99b4fe | ||
|
|
ccac7ba14f | ||
|
|
d7df7fe3c0 | ||
|
|
2bf6af1ff0 | ||
|
|
860d901115 | ||
|
|
524a93203b | ||
|
|
a6b26ff1a6 | ||
|
|
6f6cf2f57c | ||
|
|
6b293e9eaa | ||
|
|
a7dac3434d | ||
|
|
653bf84829 | ||
|
|
ba1d5ec943 | ||
|
|
994f520df1 | ||
|
|
380f6a548b | ||
|
|
a88f8dabe7 | ||
|
|
885ccf43ba | ||
|
|
7cbef302ea | ||
|
|
76afdb2e89 | ||
|
|
ea8c2da594 | ||
|
|
1b958c4b3f | ||
|
|
0c9330e4e6 | ||
|
|
da281de1d1 | ||
|
|
f73114a381 | ||
|
|
b3fc86ac3b | ||
|
|
018fea60fa | ||
|
|
29cab5c0b8 | ||
|
|
169fc05087 | ||
|
|
69a1a4f2d4 | ||
|
|
06c3bbaa7c | ||
|
|
0d12877d89 | ||
|
|
8aca3975d1 | ||
|
|
bc6dcf8395 | ||
|
|
f5806ba961 | ||
|
|
ed3ed28d2b | ||
|
|
36aa8425e6 | ||
|
|
9c069c9cfe | ||
|
|
b612dec12c | ||
|
|
c91342ac9d | ||
|
|
ad643fcf78 | ||
|
|
f85fe496e6 | ||
|
|
03db4cad8c | ||
|
|
9f41cdc3f5 | ||
|
|
6cd2dc1c9d | ||
|
|
74aaa4f643 | ||
|
|
e7d9bd4ea8 | ||
|
|
7b1c29f3cf | ||
|
|
ac0cecd1d1 | ||
|
|
cdcefb97d5 | ||
|
|
3edafe58dd | ||
|
|
56ec883fb7 | ||
|
|
a7090cea7e | ||
|
|
03a2cd1bd7 | ||
|
|
2826bb5fc5 | ||
|
|
63adea29c1 | ||
|
|
739e805b83 | ||
|
|
c7fa862792 | ||
|
|
58e2d2978f | ||
|
|
4f56ad408d | ||
|
|
8e52e61c39 | ||
|
|
c80ef798e5 | ||
|
|
528ad2f62b | ||
|
|
33adceae7d | ||
|
|
06de88a367 | ||
|
|
4f666d9f8f | ||
|
|
7dea1ede4f | ||
|
|
ead4190584 | ||
|
|
21d6a5f719 | ||
|
|
aab0526f52 | ||
|
|
41b771a185 | ||
|
|
66e020f615 | ||
|
|
38ca03ef57 | ||
|
|
b2eb0c5f17 | ||
|
|
f39cd023f6 | ||
|
|
12b16cb026 | ||
|
|
1f4e31deef | ||
|
|
c3f1af5157 | ||
|
|
dc3f6d0fa9 | ||
|
|
c8816364f7 | ||
|
|
f468b99f76 | ||
|
|
a93f118d03 | ||
|
|
adedde8e46 | ||
|
|
d84539fc42 | ||
|
|
0453ac83e5 | ||
|
|
a486628e18 | ||
|
|
6894bb026e | ||
|
|
34fbe3510b | ||
|
|
972519ba2d | ||
|
|
42faf6ad34 | ||
|
|
67847fed2a | ||
|
|
5cfdf10e5c | ||
|
|
124ae27c0b | ||
|
|
0f87ce0b6a | ||
|
|
25feadbe37 | ||
|
|
c744598425 | ||
|
|
39505896a6 | ||
|
|
035fecd17b | ||
|
|
b799cf38df | ||
|
|
a21880ea13 | ||
|
|
d918125a04 | ||
|
|
baf5cf101f | ||
|
|
5f6e983364 | ||
|
|
bb408d7f0e | ||
|
|
13601ebe1d | ||
|
|
5eaccd3062 | ||
|
|
56ea5714af | ||
|
|
8786c76ff4 | ||
|
|
dfe7510ca0 | ||
|
|
507b6d355f | ||
|
|
19c2d6d054 | ||
|
|
55942342d5 | ||
|
|
7fee71e702 | ||
|
|
11974accdb | ||
|
|
6dd4d40234 | ||
|
|
9a49b5306c | ||
|
|
6254399add | ||
|
|
f6f88de508 | ||
|
|
9f4205be32 | ||
|
|
9262df465a | ||
|
|
66187eef70 | ||
|
|
8d61a7e944 | ||
|
|
7653e860dd | ||
|
|
6748009181 | ||
|
|
e7787b8bb8 | ||
|
|
2532e0ff59 | ||
|
|
f899004140 | ||
|
|
8f418c59a5 | ||
|
|
2540c5211c | ||
|
|
e725226f4e | ||
|
|
7c15e38b43 | ||
|
|
3825120b57 | ||
|
|
fa7c9ef49e | ||
|
|
20fc49d7db | ||
|
|
36f69f4da9 | ||
|
|
6eb8242cf7 | ||
|
|
29d7d0f62a | ||
|
|
2684e0f316 | ||
|
|
68d05ea27c | ||
|
|
4f0ce82f4a | ||
|
|
e1e0614199 | ||
|
|
eda4d3e2d5 | ||
|
|
e6ae58e989 | ||
|
|
786ccad3c4 | ||
|
|
2982646dee | ||
|
|
6c57b0ca9b | ||
|
|
b281fbd5fa | ||
|
|
d926a5cc00 | ||
|
|
53914d2af2 | ||
|
|
84758d60b3 | ||
|
|
1f7623e7f5 | ||
|
|
54b302683a | ||
|
|
26a579ba7c | ||
|
|
28b12df340 | ||
|
|
37ea6d4fc8 | ||
|
|
0d2293525e | ||
|
|
5c6030f413 | ||
|
|
1b703526ea | ||
|
|
4af7777f49 | ||
|
|
0c95c816b5 | ||
|
|
14eb349212 | ||
|
|
a812c94f67 | ||
|
|
7406cb4aac | ||
|
|
9a128d38b4 | ||
|
|
3377bf96a6 | ||
|
|
884eb047f9 | ||
|
|
235842e6a9 | ||
|
|
caa43b6f2a | ||
|
|
e9b6e5fcb8 | ||
|
|
73692376ef | ||
|
|
7407f12d07 | ||
|
|
3ad44a1811 | ||
|
|
ec42060ddf | ||
|
|
268dad2018 | ||
|
|
d170a7e205 | ||
|
|
eb6532e573 | ||
|
|
9380e98865 |
@@ -0,0 +1,174 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { detectIssueCommand, handleIssueCommand } from "./issue-commands.mjs";
|
||||
import { writeContributorSnapshot } from "../../docs/scripts/contributor-snapshot.mjs";
|
||||
|
||||
function workflow(name) {
|
||||
return readFileSync(new URL(`../workflows/${name}.yml`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
function job(content, name) {
|
||||
const jobs = content.slice(content.indexOf("\njobs:\n") + 7);
|
||||
const definitions = [...jobs.matchAll(/^ ([\w-]+):\s*$/gm)];
|
||||
const index = definitions.findIndex((match) => match[1] === name);
|
||||
assert.ok(index >= 0, `missing job ${name}`);
|
||||
return jobs.slice(definitions[index].index, definitions[index + 1]?.index ?? jobs.length);
|
||||
}
|
||||
|
||||
function evaluateCondition(content, github) {
|
||||
const folded = content.match(/^ if: >-\n((?: [^\n]*\n)+)/m);
|
||||
const expression = (folded?.[1] ?? content.match(/^ if: (.+)$/m)?.[1])?.trim();
|
||||
assert.ok(expression, "missing job condition");
|
||||
const evaluate = new Function("github", "contains", `return (${expression.replace(/^\$\{\{\s*|\s*\}\}$/g, "")});`);
|
||||
return evaluate(github, (search, item) => String(search ?? "").toLowerCase().includes(String(item).toLowerCase()));
|
||||
}
|
||||
|
||||
function commentEvent(body, { userType = "User", login = "contributor", pullRequest = false } = {}) {
|
||||
return { repository_owner: "t8y2", event: {
|
||||
issue: { pull_request: pullRequest ? { url: "https://api.github.com/repos/t8y2/dbx/pulls/123" } : undefined },
|
||||
comment: { body, user: { type: userType, login } },
|
||||
} };
|
||||
}
|
||||
|
||||
test("issue command prefilter admits every supported command and skips unrelated comments", async () => {
|
||||
const content = job(workflow("issue-claim"), "claim");
|
||||
for (const body of ["/claim", "please /claim this", "\t/claim\n", "/unclaim", " /unclaimed\n", "\t/close\r\n"]) {
|
||||
assert.ok(detectIssueCommand(body), body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { pullRequest: true })), false, body);
|
||||
}
|
||||
for (const body of ["Thanks!", "Fixed in abc123", "", null]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), false);
|
||||
}
|
||||
for (const body of ["/claimable", "please /close", "/unclaim more", "/CLAIM"]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true);
|
||||
assert.deepEqual(await handleIssueCommand({ commentBody: body, commentUserType: "User", client: {} }), { status: "ignored" });
|
||||
}
|
||||
});
|
||||
|
||||
test("spam prefilter retains all existing malicious-link cases and never bypasses the matcher", () => {
|
||||
const content = job(workflow("spam-comment-guard"), "delete-malicious-link");
|
||||
const pattern = content.match(/grep -qiP '([^']+)'/)[1];
|
||||
const matcher = new RegExp(pattern.replaceAll("[:space:]", "\\s"), "i");
|
||||
for (const body of ["https://evil.example/update.zip", "https://evil.example/update.ZIP?download=1",
|
||||
"[update.zip](//evil.example/download)", "[update.ZIP](https://evil%2Eexample/download)",
|
||||
"[update.7z](https://evil.example/download)", "[update.exe](https://evil.example/download)",
|
||||
"[update.SCR](//evil.example/download)"]) {
|
||||
assert.equal(matcher.test(body), true, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { login: "t8y2" })), false);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { pullRequest: true })), true);
|
||||
}
|
||||
for (const body of ["Thanks!", "https://example.com/docs", "", null]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), false);
|
||||
}
|
||||
for (const body of ["Please upload a .zip", "https://github.com/t8y2/dbx/archive/main.zip",
|
||||
"https://user-images.githubusercontent.com/files/logs.zip", "[logs.exe](https://github.com/user-attachments/files/123/logs.exe)",
|
||||
"[logs.7z](//raw.githubusercontent.com/t8y2/dbx/main/logs.7z)"]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
|
||||
assert.equal(matcher.test(body), false, body);
|
||||
}
|
||||
});
|
||||
|
||||
test("PR notification automation stays disabled while its configuration is retained", () => {
|
||||
const content = workflow("notify");
|
||||
assert.match(content, /# pull_request_target:\s+#\s+types: \[closed\]/);
|
||||
assert.match(content, /^ workflow_dispatch:/m);
|
||||
assert.doesNotMatch(content, /^ (?:pull_request_target|pull_request|issues|release|push|schedule):/m);
|
||||
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: { merged: true } } }), true);
|
||||
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: { merged: false } } }), false);
|
||||
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: {} } }), false);
|
||||
});
|
||||
|
||||
test("plugin checks run once before unchanged cross-platform verification", () => {
|
||||
const content = workflow("plugin-dev-host");
|
||||
const checks = job(content, "checks");
|
||||
const tests = job(content, "test");
|
||||
assert.match(checks, /runs-on: ubuntu-24\.04/);
|
||||
assert.match(checks, /npm run lint --prefix plugins\/sdk\/dev-host/);
|
||||
assert.match(checks, /npm run fmt:check --prefix plugins\/sdk\/dev-host/);
|
||||
assert.doesNotMatch(checks, /cargo|setup-go|rust-toolchain/);
|
||||
assert.match(tests, /needs: checks/);
|
||||
assert.match(tests, /os: \[ubuntu-24\.04, macos-15, windows-2022\]/);
|
||||
assert.doesNotMatch(tests, /npm run (?:lint|fmt:check)/);
|
||||
for (const command of ["npm test --prefix plugins/sdk/dev-host", "cargo nextest run", "cargo test --doc",
|
||||
"npm test --prefix packages/plugin-cli", "node scripts/verify-plugin-cli-package.mjs", "DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'"]) {
|
||||
assert.ok(tests.includes(command), command);
|
||||
}
|
||||
assert.match(content, /group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}\s+cancel-in-progress: true/);
|
||||
});
|
||||
|
||||
test("contributor refresh is weekly and remains manually available", () => {
|
||||
const content = workflow("contributors");
|
||||
assert.match(content, /cron: "0 18 \* \* 0"/);
|
||||
assert.match(content, /workflow_dispatch:/);
|
||||
});
|
||||
|
||||
test("GitHub script selection excludes planner tests only when fast checks own them", () => {
|
||||
const content = job(workflow("ci"), "github-scripts");
|
||||
const script = content.match(/run: \|\n((?: [^\n]*\n)+)/)[1].replace(/^ /gm, "");
|
||||
const repository = new URL("../../", import.meta.url);
|
||||
const files = readdirSync(new URL(".github/scripts/", repository)).filter((file) => file.endsWith(".test.mjs"));
|
||||
for (const fast of ["true", "false", ""]) {
|
||||
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", `node() { printf '%s\\n' "$@"; }\n${script}`], {
|
||||
cwd: repository, encoding: "utf8", env: { ...process.env, FAST_CHECKS: fast },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const selected = result.stdout.trim().split("\n").slice(1).sort();
|
||||
const expected = files.filter((file) => fast !== "true" || !file.startsWith("ci-")).map((file) => `.github/scripts/${file}`).sort();
|
||||
assert.deepEqual(selected, expected);
|
||||
assert.ok(selected.includes(".github/scripts/actions-efficiency.test.mjs"));
|
||||
}
|
||||
});
|
||||
|
||||
async function snapshotFixture(context) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "dbx-contributor-snapshot-"));
|
||||
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||
return join(directory, "data", "contributors.json");
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { repository: "t8y2/dbx", generatedAt: "2026-10-01T18:00:00.000Z", stars: 10,
|
||||
contributors: [{ login: "contributor", commits: 3, mergedPullRequests: 1 }] };
|
||||
}
|
||||
|
||||
test("a missing contributor snapshot is created and timestamp-only refreshes preserve exact bytes", async (context) => {
|
||||
const outputPath = await snapshotFixture(context);
|
||||
const original = snapshot();
|
||||
assert.equal(await writeContributorSnapshot(outputPath, original), true);
|
||||
const originalBytes = await readFile(outputPath, "utf8");
|
||||
assert.equal(originalBytes, `${JSON.stringify(original, null, 2)}\n`);
|
||||
const refreshed = { ...original, generatedAt: "2026-10-08T18:00:00.000Z" };
|
||||
assert.equal(await writeContributorSnapshot(outputPath, refreshed), false);
|
||||
assert.equal(await readFile(outputPath, "utf8"), originalBytes);
|
||||
assert.equal(refreshed.generatedAt, "2026-10-08T18:00:00.000Z");
|
||||
});
|
||||
|
||||
test("actual contributor, star and repository changes update the snapshot", async (context) => {
|
||||
const outputPath = await snapshotFixture(context);
|
||||
const original = snapshot();
|
||||
for (const updated of [{ ...original, stars: 11 }, { ...original, repository: "other/dbx" },
|
||||
{ ...original, contributors: [{ ...original.contributors[0], commits: 4 }] },
|
||||
{ ...original, contributors: [] }]) {
|
||||
await writeContributorSnapshot(outputPath, original);
|
||||
const refreshed = { ...updated, generatedAt: "2026-10-08T18:00:00.000Z" };
|
||||
assert.equal(await writeContributorSnapshot(outputPath, refreshed), true);
|
||||
assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), refreshed);
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid contributor snapshots and filesystem errors are not silently overwritten", async (context) => {
|
||||
const outputPath = await snapshotFixture(context);
|
||||
await writeContributorSnapshot(outputPath, snapshot());
|
||||
await writeFile(outputPath, "invalid JSON");
|
||||
await assert.rejects(writeContributorSnapshot(outputPath, snapshot()), SyntaxError);
|
||||
assert.equal(await readFile(outputPath, "utf8"), "invalid JSON");
|
||||
await assert.rejects(writeContributorSnapshot(join(outputPath, "invalid"), snapshot()), { code: "ENOTDIR" });
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export function changeBase({ eventName, before, root = process.cwd() }) {
|
||||
if (eventName === "pull_request") {
|
||||
const commits = execFileSync("git", ["rev-list", "--parents", "-n", "1", "HEAD"], {
|
||||
cwd: root, encoding: "utf8", stdio: "pipe",
|
||||
}).trim().split(/\s+/);
|
||||
if (commits.length !== 3) throw new Error("PR change detection requires the checked-out merge commit and both parents");
|
||||
return commits[1];
|
||||
}
|
||||
if (!before || /^0+$/.test(before)) return null;
|
||||
if (!/^[0-9a-f]{40}$/.test(before)) throw new Error("Invalid push base SHA");
|
||||
return before;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const base = changeBase({ eventName: process.env.GITHUB_EVENT_NAME, before: process.env.BEFORE_SHA });
|
||||
if (base) console.log(base);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { changeBase } from "./ci-change-base.mjs";
|
||||
import { changedPaths, planCi } from "./ci-plan.mjs";
|
||||
|
||||
function mergeFixture(context) {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-change-base-"));
|
||||
context.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const root = path.join(directory, "source");
|
||||
mkdirSync(root);
|
||||
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }).trim();
|
||||
const write = (file, content) => {
|
||||
const filename = path.join(root, file);
|
||||
mkdirSync(path.dirname(filename), { recursive: true });
|
||||
writeFileSync(filename, content);
|
||||
};
|
||||
const commit = (message) => {
|
||||
git("add", ".");
|
||||
git("-c", "core.hooksPath=/dev/null", "commit", "-m", message);
|
||||
return git("rev-parse", "HEAD");
|
||||
};
|
||||
git("init", "--initial-branch=main");
|
||||
git("config", "user.email", "fixture@example.invalid");
|
||||
git("config", "user.name", "CI Fixture");
|
||||
write("crates/dbx-core/tests/old.rs", "original source\n");
|
||||
write("crates/dbx-core/tests/deleted.rs", "removed source\n");
|
||||
write("crates/dbx-web/src/routes/transfer.rs", "original route\n");
|
||||
const staleBase = commit("baseline");
|
||||
git("checkout", "-b", "pull-request");
|
||||
git("mv", "crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/renamed\nfile.rs");
|
||||
git("rm", "crates/dbx-core/tests/deleted.rs");
|
||||
write("crates/dbx-core/tests/runtime_diagnostic_history.rs", "diagnostic test\n");
|
||||
write("crates/dbx-web/src/routes/transfer.rs", "updated route\n");
|
||||
const pullRequestHead = commit("PR diagnostic changes");
|
||||
git("checkout", "main");
|
||||
write(".github/workflows/ci.yml", "main CI changes\n");
|
||||
write("agents/drivers/zookeeper/main.go", "main native Agent changes\n");
|
||||
write("agents/drivers/oceanbase-oracle/src/main/java/Agent.java", "main JDBC changes\n");
|
||||
const actualBase = commit("main advances while PR remains open");
|
||||
git("-c", "core.hooksPath=/dev/null", "merge", "--no-ff", "pull-request", "-m", "synthetic PR test merge");
|
||||
return { directory, root, staleBase, actualBase, pullRequestHead };
|
||||
}
|
||||
|
||||
test("PR routing uses the exact test merge first parent, excluding unrelated main changes", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
const base = changeBase({ eventName: "pull_request", before: fixture.staleBase, root: fixture.root });
|
||||
assert.equal(base, fixture.actualBase);
|
||||
assert.notEqual(base, fixture.pullRequestHead);
|
||||
const files = changedPaths(base, fixture.root);
|
||||
assert.deepEqual(files.toSorted(), [
|
||||
"crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/deleted.rs", "crates/dbx-core/tests/renamed\nfile.rs",
|
||||
"crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs",
|
||||
].toSorted());
|
||||
const metadata = {
|
||||
workspace_members: ["dbx-core", "dbx-web", "dbx"],
|
||||
packages: [
|
||||
{ id: "dbx-core", name: "dbx-core", manifest_path: path.join(fixture.root, "crates/dbx-core/Cargo.toml"), dependencies: [] },
|
||||
{ id: "dbx-web", name: "dbx-web", manifest_path: path.join(fixture.root, "crates/dbx-web/Cargo.toml"), dependencies: [{ name: "dbx-core" }] },
|
||||
{ id: "dbx", name: "dbx", manifest_path: path.join(fixture.root, "src-tauri/Cargo.toml"), dependencies: [{ name: "dbx-core" }] },
|
||||
],
|
||||
};
|
||||
const corrected = planCi({ files, metadata, root: fixture.root });
|
||||
assert.equal(corrected.rust_full, false);
|
||||
assert.deepEqual(corrected.rust_matrix.include.map((entry) => entry.group), ["application"]);
|
||||
assert.deepEqual(corrected.agent_rust.include, [{ driver: "duckdb" }]);
|
||||
assert.equal(corrected.agent_java, false);
|
||||
assert.deepEqual(corrected.agent_go.include, []);
|
||||
assert.deepEqual(corrected.agent_integration.include, []);
|
||||
assert.equal(corrected.windows_standard, true);
|
||||
assert.equal(corrected.windows_win7_bundle, false);
|
||||
const staleFiles = changedPaths(fixture.staleBase, fixture.root);
|
||||
assert.ok(staleFiles.includes(".github/workflows/ci.yml"));
|
||||
assert.ok(staleFiles.includes("agents/drivers/zookeeper/main.go"));
|
||||
const polluted = planCi({ files: staleFiles, metadata, root: fixture.root });
|
||||
assert.equal(polluted.rust_full, true);
|
||||
assert.equal(polluted.agent_java, true);
|
||||
assert.ok(polluted.agent_go.include.length > 0);
|
||||
assert.ok(polluted.agent_integration.include.length > 0);
|
||||
});
|
||||
|
||||
test("a depth-two checkout preserves the PR parents and supports the base CLI", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
const checkout = path.join(fixture.directory, "depth-two");
|
||||
execFileSync("git", ["clone", "--depth=2", pathToFileURL(fixture.root).href, checkout], { stdio: "pipe" });
|
||||
assert.equal(changeBase({ eventName: "pull_request", root: checkout }), fixture.actualBase);
|
||||
assert.deepEqual(changedPaths(fixture.actualBase, checkout), changedPaths(fixture.actualBase, fixture.root));
|
||||
const output = execFileSync(process.execPath, [path.join(import.meta.dirname, "ci-change-base.mjs")], {
|
||||
cwd: checkout, encoding: "utf8", env: { ...process.env, GITHUB_EVENT_NAME: "pull_request", BEFORE_SHA: fixture.staleBase },
|
||||
});
|
||||
assert.equal(output.trim(), fixture.actualBase);
|
||||
});
|
||||
|
||||
test("PR base detection rejects a nonmerge commit and a depth-one checkout", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
const checkout = path.join(fixture.directory, "depth-one");
|
||||
execFileSync("git", ["clone", "--depth=1", pathToFileURL(fixture.root).href, checkout], { stdio: "pipe" });
|
||||
assert.throws(() => changeBase({ eventName: "pull_request", root: checkout }), /merge commit and both parents/);
|
||||
execFileSync("git", ["checkout", "--detach", fixture.pullRequestHead], { cwd: fixture.root, stdio: "pipe" });
|
||||
assert.throws(() => changeBase({ eventName: "pull_request", root: fixture.root }), /merge commit and both parents/);
|
||||
});
|
||||
|
||||
test("push routing preserves the event before SHA even when HEAD is a merge", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
assert.equal(changeBase({ eventName: "push", before: fixture.staleBase, root: fixture.root }), fixture.staleBase);
|
||||
assert.equal(changeBase({ eventName: "push", before: fixture.pullRequestHead, root: fixture.root }), fixture.pullRequestHead);
|
||||
for (const before of [undefined, "", "0".repeat(40)]) {
|
||||
assert.equal(changeBase({ eventName: "push", before }), null);
|
||||
}
|
||||
for (const before of ["not-a-ref", "HEAD", "a".repeat(39), "a".repeat(41)]) {
|
||||
assert.throws(() => changeBase({ eventName: "push", before }), /Invalid push base SHA/);
|
||||
}
|
||||
});
|
||||
@@ -8,7 +8,7 @@ export const rustGroups = {
|
||||
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers",
|
||||
"dbx-sqlite-worker",
|
||||
],
|
||||
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp"],
|
||||
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
|
||||
};
|
||||
|
||||
export const goAgents = [
|
||||
|
||||
@@ -9,7 +9,7 @@ export function gateFailures(needs, mode) {
|
||||
const flags = ["rust", "agents", "fast", "agent_java", "agent_go_changed", "agent_rust_changed", "agent_integration_changed"];
|
||||
if (!flags.every((flag) => typeof plan[flag] === "boolean")) return ["incomplete CI plan"];
|
||||
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
|
||||
"windows-standard-check": "windows_win7_bundle", "windows-win7-bundle": "windows_win7_bundle",
|
||||
"windows-standard-check": "windows_standard", "windows-win7-bundle": "windows_win7_bundle",
|
||||
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
|
||||
if (mode === "all" && !Object.values(routedJobs).every((output) => ["true", "false"].includes(needs.changes.outputs[output]))) {
|
||||
return ["missing or invalid job selection outputs"];
|
||||
|
||||
+70
-11
@@ -2,19 +2,48 @@
|
||||
|
||||
Paths in this guide are relative to `.github/`.
|
||||
|
||||
`workflows/ci.yml` retains one workflow and one cancellation group. Splitting jobs
|
||||
does not change release workflows or branch-protection settings. The existing
|
||||
`workflows/ci.yml` retains one workflow. Splitting jobs does not change release
|
||||
workflows or branch-protection settings. The existing
|
||||
`rust` and `agents` aggregate check names remain; `ci` additionally summarizes all
|
||||
selected jobs. None of these gates accepts a failed, cancelled, missing, or
|
||||
unexpectedly skipped prerequisite. The gates are guarded by
|
||||
`if: always() && !cancelled()`: on a superseded commit a newer push cancels the
|
||||
run, and `always()` alone would still execute the gate and report those
|
||||
cancellations as a failure of the commit that no longer matters.
|
||||
`if: always() && !cancelled()`: on a cancelled run, `always()` alone would still
|
||||
execute the gate and report cancelled prerequisites as a failure.
|
||||
|
||||
## Contributor concurrency
|
||||
|
||||
PRs from anyone other than the repository owner share main `CI` concurrency
|
||||
groups keyed by the PR author's stable numeric user ID and the parity of the PR
|
||||
number (`pr-author-<id>-even` / `pr-author-<id>-odd`). At most two runs per author
|
||||
execute at a time — one per parity group — and other authors have independent
|
||||
groups. Actions expressions have no modulo, so parity is read from the PR number's
|
||||
last digit with `endsWith`. Two PRs of the same parity still queue behind each other.
|
||||
The PR author, not the trigger actor or author association, determines this
|
||||
policy, so a maintainer retrigger cannot bypass contributor limits.
|
||||
`cancel-in-progress: false` preserves the executing PR, and `queue: max` retains
|
||||
up to 100 pending runs rather than replacing earlier pending PRs. Further runs
|
||||
beyond that limit are cancelled by GitHub. Updates to the same contributor PR
|
||||
also enqueue separate runs; this policy does not deduplicate superseded commits.
|
||||
|
||||
The repository owner (`t8y2` here, compared with `github.repository_owner`) is
|
||||
exempt from author-level limits. Owner PRs retain one cancellation group per PR,
|
||||
so separate owner PRs run independently and a new commit cancels that PR's old
|
||||
run. Main pushes retain their independent ref-based cancellation group. These
|
||||
paths use `queue: single`, never `queue: max` with in-progress cancellation.
|
||||
|
||||
This limits main CI workflow runs, not the runner count inside one selected PR.
|
||||
Other PR workflows and all release workflows remain unchanged. Existing runs
|
||||
retain their original workflow configuration until a new event uses this policy.
|
||||
|
||||
## Selection
|
||||
|
||||
`scripts/ci-plan.mjs` reads Cargo workspace metadata and the exact event base/head
|
||||
diff. Renames include both paths and deleted paths remain visible. Rust package
|
||||
`scripts/ci-change-base.mjs` uses the first parent of the checked-out PR test merge
|
||||
as its base, not the potentially stale base SHA in the PR event. A depth-two
|
||||
checkout preserves both parents; a missing merge parent fails change detection.
|
||||
Pushes use the exact event `before` SHA. `paths-filter` uses Git, with an empty API
|
||||
token and explicit base/head, so its selection matches `scripts/ci-plan.mjs`
|
||||
rather than a mutable PR file list. An unavailable push diff keeps conservative
|
||||
planner coverage. Renames include both paths and deleted paths remain visible. Rust package
|
||||
selection follows reverse dependencies, including build and test dependencies.
|
||||
Connection-type descriptors belong to `dbx-types`; dialect descriptors belong to
|
||||
`dbx-sql`. A changed core dependency also selects the standalone DuckDB Agent tests,
|
||||
@@ -22,7 +51,10 @@ whose development dependency is `dbx-core`.
|
||||
|
||||
- Ordinary Rust PRs select foundation, driver, and application test groups from
|
||||
`scripts/ci-config.mjs`. Each group contains several packages, not one runner per
|
||||
crate. At most three Rust test groups run concurrently.
|
||||
crate. The application group includes `dbx-tauri-consul` and `dbx-tauri-schema`.
|
||||
At most three Rust test groups run concurrently; a contract checks the groups
|
||||
against actual workspace metadata so extracted crates cannot silently force
|
||||
every PR into the full-workspace fallback.
|
||||
- Main pushes, shared Cargo manifests/locks, toolchain/config/vendor changes, CI
|
||||
infrastructure changes, an unknown workspace member, or an unavailable diff
|
||||
select the original full-workspace test lane instead of duplicating it with all
|
||||
@@ -34,8 +66,33 @@ whose development dependency is `dbx-core`.
|
||||
cases. Shared Agent inputs conservatively select all existing Agent checks.
|
||||
Windows DuckDB packaging is selected by its own inputs or shared build/lock
|
||||
changes, not every change to its development dependency's source.
|
||||
- Existing frontend, package, Windows, JDBC, offline-payload and Nix jobs retain
|
||||
their commands and path filters.
|
||||
- Standard Windows compilation follows desktop reverse dependencies, including
|
||||
ordinary core/driver source changes. Full Win7 packaging runs for Cargo
|
||||
manifests/locks, toolchain/config/vendor changes, platform/update/installer
|
||||
code, Tauri configuration, Win7 fixtures/scripts and release/CI workflow inputs.
|
||||
Unknown Rust paths, unassigned Rust workspace members and unavailable diffs
|
||||
retain both Windows jobs. Other desktop source and test changes keep standard
|
||||
Windows compilation without repeating the full Win7 installer build. The final
|
||||
gate validates each Windows selection independently; release builds are unchanged.
|
||||
- Existing frontend, package, JDBC, offline-payload and Nix jobs retain their
|
||||
commands and path filters, except for the snapshot-only routing below.
|
||||
|
||||
Contributor snapshots refresh weekly, at 02:00 Monday in Asia/Shanghai, and can
|
||||
still be refreshed manually. An unchanged snapshot keeps its existing timestamp
|
||||
and creates no commit. A diff containing only `docs/data/contributors.json` skips
|
||||
desktop frontend validation, but the docs deployment still rebuilds the site.
|
||||
Mixed diffs and unavailable diffs keep the existing validation routing.
|
||||
|
||||
When `fast-checks` is selected, it owns the CI planner and gate tests before
|
||||
expensive dependent jobs start. `github-scripts` omits that subset in these runs,
|
||||
but runs every GitHub script test when the fast-check lane is not selected.
|
||||
|
||||
Issue command and spam jobs reject irrelevant comments before allocating a
|
||||
runner. Their existing command parser and malicious-link matcher remain the
|
||||
authoritative checks. PR notification triggers are commented out; release
|
||||
notifications in the publishing workflow are unchanged. Plugin development
|
||||
host lint and formatting run once on Linux before the Linux, macOS and Windows
|
||||
runtime, Rust and package checks; cross-platform coverage is unchanged.
|
||||
|
||||
Changes to the planner itself select the broad checks. Missing/invalid planning
|
||||
data fails the aggregate gates rather than producing a successful skip.
|
||||
@@ -70,7 +127,9 @@ incompatible SQLite backends.
|
||||
| `agent-integration` | Sixteen existing engine/version/authentication cases | 4 |
|
||||
|
||||
Matrix jobs set `fail-fast: false`, so one failure does not conceal results from
|
||||
the other selected cases. Live test predicates, readiness checks, time limits,
|
||||
the other selected cases. Go and live matrix parallelism is bounded at four each
|
||||
to reduce bursts competing with other workflows; this is not a reservation or a
|
||||
priority guarantee for release jobs. Live test predicates, readiness checks, time limits,
|
||||
credentials for disposable fixtures, and failure cleanup are retained.
|
||||
Native, Java and live jobs wait for fast checks and Agent source validation before
|
||||
starting expensive builds or containers.
|
||||
|
||||
+14
-15
@@ -17,12 +17,14 @@ const win7InfrastructureFiles = new Set([
|
||||
"src-tauri/build.rs",
|
||||
]);
|
||||
const win7InfrastructurePrefixes = [
|
||||
".cargo/",
|
||||
"rust-toolchain",
|
||||
".github/fixtures/win7-",
|
||||
"src-tauri/windows/nsis/",
|
||||
"vendor/wry/",
|
||||
"vendor/webview2-com-sys/",
|
||||
"vendor/ctor/",
|
||||
"vendor/dirs-sys/",
|
||||
"vendor/pageant/",
|
||||
"src-tauri/src/commands/update",
|
||||
"crates/dbx-core/src/host/update",
|
||||
"crates/dbx-platform/",
|
||||
"vendor/",
|
||||
];
|
||||
|
||||
function expandAffectedPackages(packages, members, affected) {
|
||||
@@ -93,7 +95,8 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
if (full) for (const pkg of packages) affected.add(pkg.name);
|
||||
expandAffectedPackages(packages, members, affected);
|
||||
const win7InfrastructureChanged = unknownDiff || files.some((file) => win7InfrastructureFiles.has(file)
|
||||
|| win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix)));
|
||||
|| win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix))
|
||||
|| /^src-tauri\/tauri.*\.json$/.test(file));
|
||||
const win7DependencyChanged = files.includes("Cargo.toml") || files.includes("Cargo.lock")
|
||||
|| files.some((file) => /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
|
||||
const windowsWin7Reasons = {
|
||||
@@ -101,6 +104,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
dependency_input: win7DependencyChanged,
|
||||
desktop_dependency: win7Affected.has("dbx"),
|
||||
unknown_rust: unknownRust,
|
||||
unknown_member: rust && unknownMember,
|
||||
};
|
||||
const windowsWin7Candidate = Object.values(windowsWin7Reasons).some(Boolean);
|
||||
const rustMatrix = !rust ? [] : full ? [{ group: "workspace" }] : Object.entries(rustGroups)
|
||||
@@ -128,6 +132,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
const java = allAgents || javaDriverChanges;
|
||||
const agents = agentsChanged || sharedAgents || nativeChanges.size > 0 || javaDriverChanges;
|
||||
return {
|
||||
contributor_snapshot_only: files.length > 0 && files.every((file) => file === "docs/data/contributors.json"),
|
||||
rust, rust_full: full, rust_matrix: { include: rustMatrix }, rust_groups_known: !unknownMember,
|
||||
affected_packages: [...affected].sort(),
|
||||
agents, agent_java: java, agent_go: { include: goMatrix }, agent_rust: { include: nativeRustMatrix },
|
||||
@@ -135,6 +140,8 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
agent_rust_changed: nativeRustMatrix.length > 0, agent_integration_changed: liveMatrix.length > 0,
|
||||
duckdb_changed: nativeChanges.has("duckdb"),
|
||||
duckdb_windows: sharedRust || files.some((file) => file.startsWith("agents/drivers/duckdb/")),
|
||||
windows_standard: windowsWin7Candidate,
|
||||
windows_win7_bundle: win7InfrastructureChanged || win7DependencyChanged || unknownRust || (rust && unknownMember),
|
||||
windows_win7_candidate: windowsWin7Candidate,
|
||||
windows_win7_affected_packages: [...win7Affected].sort(),
|
||||
windows_win7_reasons: windowsWin7Reasons,
|
||||
@@ -153,13 +160,5 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
|
||||
const outputs = { ...plan, plan };
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, Object.entries(outputs).map(([name, value]) => `${name}=${JSON.stringify(value)}\n`).join(""));
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
...plan,
|
||||
win7_routing_comparison: {
|
||||
current: process.env.WIN7_CURRENT === "true",
|
||||
candidate: plan.windows_win7_candidate,
|
||||
affected_packages: plan.windows_win7_affected_packages,
|
||||
reasons: plan.windows_win7_reasons,
|
||||
},
|
||||
}, null, 2));
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ const graph = {
|
||||
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support",
|
||||
"dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"],
|
||||
"dbx-core": ["dbx-drivers", "dbx-sql", "dbx-types", "dbx-platform", "dbx-ai-provider", "dbx-plugin-runtime", "dbx-formats"],
|
||||
"dbx-mcp": ["dbx-core"], "dbx-cli": ["dbx-core", "dbx-mcp"], "dbx-web": ["dbx-core", "dbx-mcp"], "dbx": ["dbx-core", "dbx-mcp"],
|
||||
"dbx-mcp": ["dbx-core"], "dbx-cli": ["dbx-core", "dbx-mcp"], "dbx-web": ["dbx-core", "dbx-mcp"], "dbx": ["dbx-core", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
|
||||
"dbx-tauri-consul": ["dbx-core"], "dbx-tauri-schema": ["dbx-core"],
|
||||
};
|
||||
const metadata = {
|
||||
workspace_members: Object.keys(graph),
|
||||
@@ -52,12 +53,22 @@ test("the planner uses the runner stable Cargo only for metadata", () => {
|
||||
assert.deepEqual(invocation.args, ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"]);
|
||||
assert.equal(invocation.options.cwd, "/workspace");
|
||||
});
|
||||
// Keep the gate fixtures in sync with ci-gate.mjs routedJobs. Both Windows jobs
|
||||
// share the windows_win7_bundle routing output.
|
||||
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
|
||||
"windows-standard-check": "windows_win7_bundle", "windows-win7-bundle": "windows_win7_bundle",
|
||||
"windows-standard-check": "windows_standard", "windows-win7-bundle": "windows_win7_bundle",
|
||||
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
|
||||
|
||||
test("only an exact contributor snapshot diff skips desktop frontend validation", () => {
|
||||
for (const eventName of ["pull_request", "push"]) {
|
||||
assert.equal(plan(["docs/data/contributors.json"], { eventName }).contributor_snapshot_only, true);
|
||||
for (const files of [null, [], ["docs/lib/contributorActivity.test.ts"],
|
||||
["docs/data/contributors.json", "apps/desktop/src/main.ts"],
|
||||
["docs/data/contributors.json", "docs/lib/contributorActivity.ts"],
|
||||
["docs/data/contributors.json", "Cargo.lock"]]) {
|
||||
assert.equal(plan(files, { eventName }).contributor_snapshot_only, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("foundation changes select transitive consumers and standalone DuckDB", () => {
|
||||
const result = plan(["crates/dbx-types/src/lib.rs"]);
|
||||
assert.deepEqual(groups(result), ["foundation", "drivers", "application"]);
|
||||
@@ -85,6 +96,21 @@ test("Win7 candidate follows the desktop package dependency graph", () => {
|
||||
assert.equal(plan(["crates/dbx-cli/src/main.rs"]).windows_win7_candidate, false);
|
||||
});
|
||||
|
||||
test("ordinary desktop Rust changes keep Windows compilation without full Win7 packaging", () => {
|
||||
for (const file of ["crates/dbx-core/src/query/mod.rs", "crates/dbx-core/tests/runtime_diagnostic_history.rs",
|
||||
"crates/dbx-drivers/src/lib.rs", "crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-driver-mysql/tests/query.rs",
|
||||
"src-tauri/src/commands/query.rs", "crates/dbx-tauri-consul/src/lib.rs", "crates/dbx-tauri-schema/src/lib.rs"]) {
|
||||
const result = plan([file]);
|
||||
assert.equal(result.windows_standard, true, file);
|
||||
assert.equal(result.windows_win7_bundle, false, file);
|
||||
}
|
||||
for (const file of ["crates/dbx-cli/src/main.rs", "crates/dbx-web/src/routes/transfer.rs", "docs/README.md"]) {
|
||||
const result = plan([file]);
|
||||
assert.equal(result.windows_standard, false, file);
|
||||
assert.equal(result.windows_win7_bundle, false, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("old Win7 Rust paths remain candidates through the dependency graph", () => {
|
||||
for (const file of [
|
||||
"src-tauri/src/commands/update.rs",
|
||||
@@ -114,20 +140,45 @@ test("Win7 infrastructure changes remain candidates outside the Cargo graph", ()
|
||||
"vendor/ctor/src/lib.rs",
|
||||
"vendor/dirs-sys/src/lib.rs",
|
||||
"vendor/pageant/src/lib.rs",
|
||||
".cargo/config.toml",
|
||||
"rust-toolchain.toml",
|
||||
".github/fixtures/win7-imports/dll.json",
|
||||
"src-tauri/tauri.conf.json",
|
||||
"src-tauri/tauri.windows.conf.json",
|
||||
"src-tauri/src/commands/update.rs",
|
||||
"crates/dbx-core/src/host/update.rs",
|
||||
"crates/dbx-platform/src/lib.rs",
|
||||
]) {
|
||||
assert.equal(plan([file]).windows_win7_candidate, true, file);
|
||||
const result = plan([file]);
|
||||
assert.equal(result.windows_win7_candidate, true, file);
|
||||
assert.equal(result.windows_standard, true, file);
|
||||
assert.equal(result.windows_win7_bundle, true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("Win7 dependency inputs fail open", () => {
|
||||
for (const file of ["Cargo.toml", "Cargo.lock", "src-tauri/Cargo.toml", "crates/dbx-cli/Cargo.toml"]) {
|
||||
assert.equal(plan([file]).windows_win7_candidate, true, file);
|
||||
assert.equal(plan([file]).windows_standard, true, file);
|
||||
assert.equal(plan([file]).windows_win7_bundle, true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown Rust changes and unknown diffs remain Win7 candidates", () => {
|
||||
assert.equal(plan(["crates/new-engine/src/lib.rs"]).windows_win7_candidate, true);
|
||||
assert.equal(plan(null).windows_win7_candidate, true);
|
||||
for (const files of [["crates/new-engine/src/lib.rs"], null]) {
|
||||
const result = plan(files);
|
||||
assert.equal(result.windows_win7_candidate, true);
|
||||
assert.equal(result.windows_standard, true);
|
||||
assert.equal(result.windows_win7_bundle, true);
|
||||
}
|
||||
const fixture = structuredClone(metadata);
|
||||
fixture.workspace_members.push("new-engine");
|
||||
fixture.packages.push({ id: "new-engine", name: "new-engine", manifest_path: `${root}/crates/new-engine/Cargo.toml`, dependencies: [] });
|
||||
const result = plan(["crates/dbx-web/src/lib.rs"], { metadata: fixture });
|
||||
assert.equal(result.windows_standard, true);
|
||||
assert.equal(result.windows_win7_bundle, true);
|
||||
assert.equal(result.windows_win7_reasons.unknown_member, true);
|
||||
assert.equal(plan(["docs/README.md"], { metadata: fixture }).windows_win7_bundle, false);
|
||||
});
|
||||
|
||||
test("unrelated CI inputs do not become Win7 candidates through full Rust coverage", () => {
|
||||
@@ -152,6 +203,7 @@ test("Win7 candidate reports each routing reason", () => {
|
||||
dependency_input: false,
|
||||
desktop_dependency: false,
|
||||
unknown_rust: false,
|
||||
unknown_member: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,6 +304,22 @@ test("test groups partition every current workspace package exactly once", () =>
|
||||
assert.deepEqual(selected.toSorted(), Object.keys(graph).toSorted());
|
||||
});
|
||||
|
||||
test("Rust groups cover the actual workspace metadata, including extracted Tauri crates", () => {
|
||||
const repository = path.resolve(import.meta.dirname, "../..");
|
||||
const actual = cargoMetadata(repository);
|
||||
const members = actual.packages.filter((pkg) => actual.workspace_members.includes(pkg.id)).map((pkg) => pkg.name);
|
||||
assert.deepEqual(Object.values(rustGroups).flat().toSorted(), members.toSorted());
|
||||
const result = planCi({ files: ["crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"],
|
||||
metadata: actual, root: repository });
|
||||
assert.equal(result.rust_groups_known, true);
|
||||
assert.equal(result.rust_full, false);
|
||||
assert.deepEqual(groups(result), ["application"]);
|
||||
assert.deepEqual(result.agent_rust.include, [{ driver: "duckdb" }]);
|
||||
assert.equal(result.agent_java, false);
|
||||
assert.equal(result.agent_go.include.length, 0);
|
||||
assert.equal(result.agent_integration.include.length, 0);
|
||||
});
|
||||
|
||||
test("workspace Cargo flags preserve full versus fast coverage and strict clippy", () => {
|
||||
for (const mode of ["fast", "full"]) {
|
||||
const command = rustCommand("test", "workspace", mode);
|
||||
@@ -382,6 +450,7 @@ test("the final gate requires both Windows jobs when the bundle routing is selec
|
||||
needs.changes.outputs[output] = "false";
|
||||
}
|
||||
needs.changes.outputs.windows_win7_bundle = "true";
|
||||
needs.changes.outputs.windows_standard = "true";
|
||||
needs["windows-standard-check"].result = "success";
|
||||
needs["windows-win7-bundle"].result = "success";
|
||||
assert.deepEqual(gateFailures(needs, "all"), []);
|
||||
@@ -393,6 +462,26 @@ test("the final gate requires both Windows jobs when the bundle routing is selec
|
||||
assert.ok(gateFailures(needs, "all").length);
|
||||
});
|
||||
|
||||
test("the final gate independently requires standard Windows compilation without Win7 packaging", () => {
|
||||
const needs = results(["crates/dbx-core/src/query/mod.rs"]);
|
||||
const selected = plan(["crates/dbx-core/src/query/mod.rs"]);
|
||||
needs.rust = needs.agents = { result: "success" };
|
||||
for (const [job, output] of Object.entries(routedJobs)) {
|
||||
needs.changes.outputs[output] = String(selected[output] ?? false);
|
||||
needs[job] = { result: selected[output] ? "success" : "skipped" };
|
||||
}
|
||||
assert.equal(needs.changes.outputs.windows_standard, "true");
|
||||
assert.equal(needs.changes.outputs.windows_win7_bundle, "false");
|
||||
assert.deepEqual(gateFailures(needs, "all"), []);
|
||||
for (const result of ["failure", "skipped", "cancelled"]) {
|
||||
needs["windows-standard-check"].result = result;
|
||||
assert.ok(gateFailures(needs, "all").some((failure) => failure.includes("windows-standard-check")));
|
||||
}
|
||||
needs["windows-standard-check"].result = "success";
|
||||
delete needs.changes.outputs.windows_standard;
|
||||
assert.ok(gateFailures(needs, "all").length);
|
||||
});
|
||||
|
||||
test("coverage audit rejects lost features, omitted packages, and overlapping group selection", () => {
|
||||
const coverage = { workspace: Object.fromEntries(Object.keys(graph).map((name) => [name, ["fixture"]])),
|
||||
...Object.fromEntries(Object.entries(rustGroups).map(([group, packages]) => [group, Object.fromEntries(packages.map((name) => [name, ["fixture"]]))])) };
|
||||
|
||||
@@ -15,6 +15,65 @@ function job(name, content = workflow) {
|
||||
return jobs.slice(definitions[index].index, definitions[index + 1]?.index ?? jobs.length);
|
||||
}
|
||||
|
||||
function concurrencySettings(github) {
|
||||
const block = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)?.[1];
|
||||
assert.ok(block, "missing workflow concurrency");
|
||||
const format = (template, ...values) => template.replace(/\{(\d+)\}/g, (_, index) => String(values[Number(index)]));
|
||||
const endsWith = (search, value) => String(search).toLowerCase().endsWith(String(value).toLowerCase());
|
||||
return Object.fromEntries(block.trim().split("\n").map((line) => {
|
||||
const [, setting, value] = line.match(/^\s*([\w-]+):\s*(.+)$/);
|
||||
const evaluate = (expression) =>
|
||||
new Function("github", "format", "endsWith", `return (${expression});`)(github, format, endsWith);
|
||||
const expressions = [...value.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)];
|
||||
return [setting, expressions.length === 1 && expressions[0][0] === value
|
||||
? evaluate(expressions[0][1]) : value.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_, expression) => evaluate(expression))];
|
||||
}));
|
||||
}
|
||||
|
||||
function concurrencyContext({ authorId = 101, author = "contributor", actor = author, number = 100, ref = `refs/pull/${number}/merge`, eventName = "pull_request" } = {}) {
|
||||
return { workflow: "CI", repository_owner: "t8y2", actor, ref, event_name: eventName,
|
||||
event: eventName === "pull_request" ? { pull_request: { number, user: { id: authorId, login: author } } } : {} };
|
||||
}
|
||||
|
||||
test("a contributor's PRs share two main CI queues split by PR number parity, regardless of trigger actor", () => {
|
||||
const even = concurrencySettings(concurrencyContext());
|
||||
assert.deepEqual(even, { group: "CI-pr-author-101-even", "cancel-in-progress": false, queue: "max" });
|
||||
for (const context of [concurrencyContext({ number: 202 }), concurrencyContext({ actor: "t8y2" }),
|
||||
concurrencyContext({ actor: "github-actions[bot]" }), concurrencyContext({ author: "renamed-contributor" })]) {
|
||||
assert.deepEqual(concurrencySettings(context), even);
|
||||
}
|
||||
for (const number of [101, 11849, 11853, 7]) {
|
||||
assert.deepEqual(concurrencySettings(concurrencyContext({ number })), { ...even, group: "CI-pr-author-101-odd" }, String(number));
|
||||
}
|
||||
for (const number of [2, 4, 6, 8, 11850]) assert.equal(concurrencySettings(concurrencyContext({ number })).group, even.group);
|
||||
assert.notEqual(concurrencySettings(concurrencyContext({ authorId: 102, author: "another-contributor" })).group, even.group);
|
||||
});
|
||||
|
||||
test("repository-owner PRs remain independent and superseded runs are cancelled only within a PR", () => {
|
||||
const first = concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2" }));
|
||||
const second = concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2", ref: "refs/pull/200/merge" }));
|
||||
assert.deepEqual(first, { group: "CI-refs/pull/100/merge", "cancel-in-progress": true, queue: "single" });
|
||||
assert.notEqual(first.group, second.group);
|
||||
assert.deepEqual(concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2", actor: "another-contributor" })), first);
|
||||
});
|
||||
|
||||
test("main pushes retain their independent cancellation group without PR context", () => {
|
||||
const main = concurrencySettings(concurrencyContext({ eventName: "push", ref: "refs/heads/main", actor: "github-actions[bot]" }));
|
||||
assert.deepEqual(main, { group: "CI-refs/heads/main", "cancel-in-progress": true, queue: "single" });
|
||||
assert.notEqual(main.group, concurrencySettings(concurrencyContext()).group);
|
||||
});
|
||||
|
||||
test("expanded author queues never combine max queuing with in-progress cancellation", () => {
|
||||
for (const context of [concurrencyContext(), concurrencyContext({ author: "t8y2" }),
|
||||
concurrencyContext({ eventName: "push", ref: "refs/heads/main" })]) {
|
||||
const settings = concurrencySettings(context);
|
||||
assert.ok(settings.queue === "single" || settings.queue === "max");
|
||||
assert.equal(settings.queue === "max" && settings["cancel-in-progress"], false);
|
||||
}
|
||||
const concurrency = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)[1];
|
||||
assert.doesNotMatch(concurrency, /github\.actor|author_association/);
|
||||
});
|
||||
|
||||
test("fast checks run format and contracts before graph resolution or compilation", () => {
|
||||
const fast = job("fast-checks");
|
||||
for (const command of ["cargo fmt --check", "node --test scripts/core-architecture.test.mjs", "node scripts/sync-connection-types.mjs --check", "node .github/scripts/ci-lockfiles.mjs", "node .github/scripts/ci-rust-coverage.mjs"]) assert.ok(fast.includes(command));
|
||||
@@ -23,12 +82,31 @@ test("fast checks run format and contracts before graph resolution or compilatio
|
||||
assert.ok(fast.includes("needs.changes.outputs.rust_groups_known == 'true'"));
|
||||
});
|
||||
|
||||
test("fast checks retain ownership of the planner subset before expensive jobs", () => {
|
||||
assert.match(job("github-scripts"), /FAST_CHECKS: \$\{\{ needs\.changes\.outputs\.fast \}\}/);
|
||||
assert.match(job("github-scripts"), /for file in \.github\/scripts\/\*\.test\.mjs/);
|
||||
assert.match(job("fast-checks"), /name: CI planner and gate tests\s+run: node --test \.github\/scripts\/ci-\*\.test\.mjs/);
|
||||
});
|
||||
|
||||
test("contributor snapshot routing preserves docs deployment and mixed frontend validation", () => {
|
||||
assert.ok(job("changes").includes("frontend: ${{ steps.filter.outputs.frontend == 'true' && steps.plan.outputs.contributor_snapshot_only != 'true' }}"));
|
||||
assert.match(job("changes"), /frontend:\s+- 'apps\/desktop\/\*\*'\s+- 'docs\/\*\*'/);
|
||||
const docsWorkflow = readFileSync(new URL("../workflows/docs.yml", import.meta.url), "utf8");
|
||||
assert.match(docsWorkflow, /paths: \["docs\/\*\*"\]/);
|
||||
for (const file of ["contributors", "issue-claim", "notify", "plugin-dev-host", "spam-comment-guard"]) {
|
||||
assert.ok(job("changes").includes(`- '.github/workflows/${file}.yml'`));
|
||||
}
|
||||
for (const file of ["contributor-snapshot", "update-contributors"]) {
|
||||
assert.ok(job("changes").includes(`- 'docs/scripts/${file}.mjs'`));
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent and Rust matrices are bounded and do not cancel sibling failures", () => {
|
||||
for (const [name, output, parallel] of [
|
||||
["rust-test", "rust_matrix", 3],
|
||||
["agent-rust", "agent_rust", 2],
|
||||
["agent-go", "agent_go", 8],
|
||||
["agent-integration", "agent_integration", 8],
|
||||
["agent-go", "agent_go", 4],
|
||||
["agent-integration", "agent_integration", 4],
|
||||
]) {
|
||||
const content = job(name);
|
||||
assert.match(content, /fail-fast: false/);
|
||||
@@ -188,7 +266,7 @@ test("Rust test jobs install pinned nextest and retain separate doctests", () =>
|
||||
const pluginRelease = readFileSync(new URL("../workflows/plugin-cli-release.yml", import.meta.url), "utf8");
|
||||
const consumers = [job("packages"), job("rust-test"), job("agent-rust"), job("agent-integration"), job("test", pluginDevHost), job("prepare", pluginRelease)];
|
||||
for (const content of consumers) {
|
||||
assert.ok(content.includes("uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3"));
|
||||
assert.ok(content.includes("uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b"));
|
||||
assert.ok(content.includes("tool: cargo-nextest@0.9.137"));
|
||||
assert.doesNotMatch(content, /cargo test (?!.*--doc)/);
|
||||
const testCommand = content.indexOf("cargo nextest run") >= 0 ? "cargo nextest run" : content.includes("pnpm test:packages") ? "pnpm test:packages" : content.includes("ci-rust.mjs test") ? "ci-rust.mjs test" : "ci-agent-integration.sh";
|
||||
@@ -224,9 +302,8 @@ test("DuckDB Windows builds persist Rust and C++ compiler results", () => {
|
||||
});
|
||||
|
||||
test("Windows compatibility jobs cache Rust compilation without wrapping C or C++", () => {
|
||||
assert.ok(job("changes").includes("'vendor/webview2-com-sys/**'"));
|
||||
const standard = job("windows-standard-check");
|
||||
assert.ok(standard.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
|
||||
assert.ok(standard.includes("needs.changes.outputs.windows_standard == 'true'"));
|
||||
assert.ok(standard.includes("RUSTC_WRAPPER: sccache"));
|
||||
assert.ok(standard.includes('SCCACHE_GHA_ENABLED: "true"'));
|
||||
assert.ok(standard.includes("SCCACHE_GHA_VERSION: windows-standard-v1"));
|
||||
@@ -236,6 +313,7 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
|
||||
assert.ok(standard.includes("sccache --show-stats"));
|
||||
|
||||
const win7 = job("windows-win7-bundle");
|
||||
assert.ok(win7.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
|
||||
assert.doesNotMatch(win7, /x86_64-pc-windows-msvc|Setup Rust for standard Windows/);
|
||||
for (const setting of ["RUSTC_WRAPPER: sccache", 'SCCACHE_GHA_ENABLED: "true"', "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1", 'SCCACHE_IDLE_TIMEOUT: "0"', 'CARGO_PROFILE_RELEASE_LTO: "off"', 'CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"']) assert.ok(win7.includes(setting));
|
||||
assert.ok(win7.includes("fc920bf0ec8de6ee65d409111f7ec508035751ba"));
|
||||
@@ -276,20 +354,21 @@ test("Win7 TLS cache keys ignore the workspace lockfile", () => {
|
||||
assert.doesNotMatch(win7, /key: win7-(?:aws-lc|openssl).*hashFiles\('Cargo\.lock'/);
|
||||
});
|
||||
|
||||
test("the planner uses the exact event base and preserves a single workflow cancellation scope", () => {
|
||||
test("path filtering and the planner use the exact test merge first parent", () => {
|
||||
const changes = job("changes");
|
||||
assert.ok(changes.includes("github.event.pull_request.base.sha || github.event.before"));
|
||||
assert.ok(changes.includes("BEFORE_SHA: ${{ github.event.before }}"));
|
||||
assert.ok(changes.includes('BASE_SHA="$(node .github/scripts/ci-change-base.mjs)"'));
|
||||
assert.ok(changes.includes("fetch-depth: 2"));
|
||||
assert.ok(changes.includes('git fetch --no-tags --depth=1 origin "$BASE_SHA"'));
|
||||
assert.ok(changes.includes("base: ${{ steps.change-base.outputs.sha }}"));
|
||||
assert.match(changes, /with:\s+token: ''\s+base: \$\{\{ steps\.change-base\.outputs\.sha \}\}\s+ref: \$\{\{ github\.sha \}\}/);
|
||||
assert.ok(changes.includes("BASE_SHA: ${{ steps.change-base.outputs.sha }}"));
|
||||
assert.ok(changes.includes("node .github/scripts/ci-plan.mjs"));
|
||||
assert.doesNotMatch(changes, /fetch-depth:\s*0/);
|
||||
assert.doesNotMatch(changes, /dtolnay\/rust-toolchain/);
|
||||
for (const flag of ["rust", "rust_full", "rust_matrix", "agents", "agent_go", "agent_rust", "agent_integration",
|
||||
"windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
|
||||
"windows_standard", "windows_win7_bundle", "windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
|
||||
assert.ok(changes.includes(`steps.plan.outputs.${flag}`));
|
||||
}
|
||||
assert.ok(changes.includes("WIN7_CURRENT: ${{ steps.filter.outputs.windows_win7_bundle }}"));
|
||||
assert.ok(workflow.includes("group: ${{ github.workflow }}-${{ github.ref }}"));
|
||||
assert.ok(workflow.includes("cancel-in-progress: true"));
|
||||
assert.doesNotMatch(changes, /steps\.filter\.outputs\.windows_win7_bundle|github\.event\.pull_request\.base\.sha|WIN7_CURRENT/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add Google's public Docker Hub cache without replacing daemon settings."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
MIRROR = "https://mirror.gcr.io"
|
||||
|
||||
|
||||
def configure(path, validate=None):
|
||||
path = Path(path)
|
||||
config = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("Docker daemon config must be a JSON object")
|
||||
mirrors = config.get("registry-mirrors", [])
|
||||
if not isinstance(mirrors, list) or any(not isinstance(item, str) for item in mirrors):
|
||||
raise ValueError("registry-mirrors must be an array of strings")
|
||||
if any(item.rstrip("/") == MIRROR for item in mirrors):
|
||||
return
|
||||
config["registry-mirrors"] = [*mirrors, MIRROR]
|
||||
original_stat = path.stat() if path.exists() else None
|
||||
candidate = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
|
||||
prefix="daemon-mirror-", suffix=".json", delete=False) as output:
|
||||
candidate = Path(output.name)
|
||||
output.write(json.dumps(config, indent=2) + "\n")
|
||||
candidate.chmod(stat.S_IMODE(original_stat.st_mode) if original_stat else 0o644)
|
||||
if original_stat and hasattr(os, "chown"):
|
||||
os.chown(candidate, original_stat.st_uid, original_stat.st_gid)
|
||||
if validate:
|
||||
validate(candidate)
|
||||
if original_stat:
|
||||
backup = path.with_name(path.name + ".before-dbx-mirror")
|
||||
if not backup.exists():
|
||||
descriptor = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IMODE(original_stat.st_mode))
|
||||
with os.fdopen(descriptor, "wb") as output:
|
||||
output.write(path.read_bytes())
|
||||
shutil.copystat(path, backup)
|
||||
if hasattr(os, "chown"):
|
||||
os.chown(backup, original_stat.st_uid, original_stat.st_gid)
|
||||
os.replace(candidate, path)
|
||||
finally:
|
||||
if candidate and candidate.exists():
|
||||
candidate.unlink()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("config", type=Path)
|
||||
parser.add_argument("--validate", action="store_true", help="Validate with dockerd before replacing config")
|
||||
args = parser.parse_args()
|
||||
|
||||
def validate(candidate):
|
||||
subprocess.run(["dockerd", "--validate", "--config-file", str(candidate)], check=True, timeout=15)
|
||||
|
||||
configure(args.config, validate if args.validate else None)
|
||||
@@ -0,0 +1,115 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"docker_mirror", Path(__file__).with_name("configure-docker-mirror.py")
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
class DockerMirrorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.path = Path(self.directory.name) / "daemon.json"
|
||||
|
||||
def test_missing_config(self):
|
||||
module.configure(self.path)
|
||||
self.assertEqual(json.loads(self.path.read_text()), {"registry-mirrors": [module.MIRROR]})
|
||||
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
|
||||
|
||||
def test_preserves_settings_and_existing_mirror_order(self):
|
||||
original = {"log-driver": "json-file", "features": {"containerd-snapshotter": True},
|
||||
"registry-mirrors": ["https://existing.example", "https://second.example/"]}
|
||||
self.path.write_text(json.dumps(original))
|
||||
module.configure(self.path)
|
||||
expected = {**original, "registry-mirrors": [*original["registry-mirrors"], module.MIRROR]}
|
||||
self.assertEqual(json.loads(self.path.read_text()), expected)
|
||||
|
||||
def test_second_run_does_not_write(self):
|
||||
module.configure(self.path)
|
||||
before = self.path.read_bytes()
|
||||
modified = self.path.stat().st_mtime_ns
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertEqual(self.path.stat().st_mtime_ns, modified)
|
||||
|
||||
def test_existing_mirror_with_trailing_slash_does_not_write(self):
|
||||
before = '{"registry-mirrors": ["https://mirror.gcr.io/"], "debug": false}\n'
|
||||
self.path.write_text(before)
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_text(), before)
|
||||
|
||||
def test_invalid_json_fails_without_writing(self):
|
||||
before = b'{"debug":'
|
||||
self.path.write_bytes(before)
|
||||
with self.assertRaises(json.JSONDecodeError):
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_invalid_structure_fails_without_writing(self):
|
||||
for value in [[], {"registry-mirrors": "https://existing.example"},
|
||||
{"registry-mirrors": [None]}]:
|
||||
with self.subTest(value=value):
|
||||
before = json.dumps(value).encode()
|
||||
self.path.write_bytes(before)
|
||||
with self.assertRaises(ValueError):
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_existing_file_permissions_preserved(self):
|
||||
self.path.write_text('{"debug": false}')
|
||||
self.path.chmod(0o600)
|
||||
mode = self.path.stat().st_mode
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.stat().st_mode, mode)
|
||||
|
||||
def test_backup_preserves_original_bytes_and_mode(self):
|
||||
before = b'{ "debug": false, "log-driver": "json-file" }\n'
|
||||
self.path.write_bytes(before)
|
||||
self.path.chmod(0o600)
|
||||
mode = self.path.stat().st_mode
|
||||
module.configure(self.path)
|
||||
backup = self.path.with_name(self.path.name + ".before-dbx-mirror")
|
||||
self.assertEqual(backup.read_bytes(), before)
|
||||
self.assertEqual(backup.stat().st_mode, mode)
|
||||
module.configure(self.path)
|
||||
self.assertEqual(backup.read_bytes(), before)
|
||||
|
||||
def test_validator_failure_preserves_original_and_removes_candidate(self):
|
||||
before = b'{"debug": false}\n'
|
||||
self.path.write_bytes(before)
|
||||
|
||||
def reject(candidate):
|
||||
self.assertEqual(json.loads(candidate.read_text())["registry-mirrors"], [module.MIRROR])
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
raise ValueError("daemon validation failed")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "daemon validation failed"):
|
||||
module.configure(self.path, reject)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
|
||||
|
||||
def test_validation_precedes_atomic_replace(self):
|
||||
before = b'{"debug": true}\n'
|
||||
self.path.write_bytes(before)
|
||||
candidates = []
|
||||
|
||||
def validate(candidate):
|
||||
self.assertEqual(candidate.parent, self.path.parent)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertTrue(json.loads(candidate.read_text())["debug"])
|
||||
candidates.append(candidate)
|
||||
|
||||
module.configure(self.path, validate)
|
||||
self.assertEqual(len(candidates), 1)
|
||||
self.assertFalse(candidates[0].exists())
|
||||
self.assertIn(module.MIRROR, json.loads(self.path.read_text())["registry-mirrors"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1793,12 +1793,48 @@ jobs:
|
||||
cat RELEASE_NOTES.md
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
token: ${{ steps.release-token.outputs.token }}
|
||||
files: release/*
|
||||
body_path: RELEASE_NOTES.md
|
||||
make_latest: false
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.release-token.outputs.token }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Idempotent release publish: create the release only when missing and
|
||||
# upload only assets it does not have yet, in small spaced batches.
|
||||
# Re-uploading the full set on every retry trips GitHub secondary
|
||||
# rate limits and dropped connections strand the whole job.
|
||||
if gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
# --draft=false: a tag deleted mid-recovery leaves the release as a
|
||||
# draft, which /releases/tags then 404s for reuse jobs and users.
|
||||
gh release edit "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--notes-file RELEASE_NOTES.md --draft=false --latest=false >/dev/null
|
||||
else
|
||||
gh release create "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--verify-tag --title "$TAG_NAME" \
|
||||
--notes-file RELEASE_NOTES.md --latest=false
|
||||
fi
|
||||
|
||||
mapfile -t existing < <(gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--json assets --jq '.assets[].name')
|
||||
declare -A have=()
|
||||
for name in "${existing[@]:-}"; do
|
||||
[ -n "$name" ] && have["$name"]=1
|
||||
done
|
||||
|
||||
missing=()
|
||||
for f in release/*; do
|
||||
[ -f "$f" ] || continue
|
||||
[ -n "${have["$(basename "$f")"]:-}" ] || missing+=("$f")
|
||||
done
|
||||
echo "release already has ${#existing[@]} assets; uploading ${#missing[@]} missing"
|
||||
|
||||
batch_size=5
|
||||
i=0
|
||||
while [ "$i" -lt "${#missing[@]}" ]; do
|
||||
gh release upload "$TAG_NAME" "${missing[@]:$i:$batch_size}" --repo "$GITHUB_REPOSITORY"
|
||||
i=$((i + batch_size))
|
||||
if [ "$i" -lt "${#missing[@]}" ]; then sleep 20; fi
|
||||
done
|
||||
echo "uploaded ${#missing[@]} assets in batches of $batch_size"
|
||||
|
||||
- name: Update agents-latest GitHub release
|
||||
env:
|
||||
|
||||
+60
-42
@@ -7,8 +7,9 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && format('pr-author-{0}-{1}', github.event.pull_request.user.id, (endsWith(github.event.pull_request.number, '0') || endsWith(github.event.pull_request.number, '2') || endsWith(github.event.pull_request.number, '4') || endsWith(github.event.pull_request.number, '6') || endsWith(github.event.pull_request.number, '8')) && 'even' || 'odd') || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login == github.repository_owner }}
|
||||
queue: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && 'max' || 'single' }}
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only
|
||||
@@ -156,7 +157,20 @@ jobs:
|
||||
node-version: 22.13.0
|
||||
|
||||
- name: GitHub script tests
|
||||
run: node --test .github/scripts/*.test.mjs
|
||||
env:
|
||||
FAST_CHECKS: ${{ needs.changes.outputs.fast }}
|
||||
run: |
|
||||
tests=()
|
||||
for file in .github/scripts/*.test.mjs; do
|
||||
if [[ "$FAST_CHECKS" == true && "${file##*/}" == ci-* ]]; then
|
||||
continue
|
||||
fi
|
||||
tests+=("$file")
|
||||
done
|
||||
node --test "${tests[@]}"
|
||||
|
||||
- name: Docker mirror config tests
|
||||
run: python3 .github/scripts/configure-docker-mirror.test.py
|
||||
|
||||
- name: PowerShell MCP installer tests
|
||||
shell: pwsh
|
||||
@@ -217,7 +231,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.68.13
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
@@ -287,7 +301,7 @@ jobs:
|
||||
|
||||
windows-standard-check:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.windows_win7_bundle == 'true'
|
||||
if: needs.changes.outputs.windows_standard == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -869,6 +883,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Configure public Docker Hub cache
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
source /etc/os-release
|
||||
[[ "$ID" == ubuntu ]] || exit 0
|
||||
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
|
||||
timeout 30s sudo systemctl restart docker
|
||||
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
|
||||
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
@@ -944,7 +968,7 @@ jobs:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
@@ -1077,7 +1101,7 @@ jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend == 'true' && steps.plan.outputs.contributor_snapshot_only != 'true' }}
|
||||
packages: ${{ steps.filter.outputs.packages }}
|
||||
rust: ${{ steps.plan.outputs.rust }}
|
||||
rust_full: ${{ steps.plan.outputs.rust_full }}
|
||||
@@ -1086,7 +1110,8 @@ jobs:
|
||||
agents: ${{ steps.plan.outputs.agents }}
|
||||
duckdb_windows: ${{ steps.filter.outputs.duckdb_windows == 'true' || steps.plan.outputs.duckdb_windows == 'true' }}
|
||||
nix: ${{ steps.filter.outputs.nix }}
|
||||
windows_win7_bundle: ${{ steps.filter.outputs.windows_win7_bundle }}
|
||||
windows_standard: ${{ steps.plan.outputs.windows_standard }}
|
||||
windows_win7_bundle: ${{ steps.plan.outputs.windows_win7_bundle }}
|
||||
windows_win7_candidate: ${{ steps.plan.outputs.windows_win7_candidate }}
|
||||
windows_win7_affected_packages: ${{ steps.plan.outputs.windows_win7_affected_packages }}
|
||||
windows_win7_reasons: ${{ steps.plan.outputs.windows_win7_reasons }}
|
||||
@@ -1105,14 +1130,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Fetch change base
|
||||
id: change-base
|
||||
env:
|
||||
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
if [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]]; then
|
||||
BASE_SHA="$(node .github/scripts/ci-change-base.mjs)"
|
||||
if [[ -z "$BASE_SHA" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
git fetch --no-tags --depth=1 origin "$BASE_SHA"
|
||||
@@ -1122,7 +1148,9 @@ jobs:
|
||||
uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
token: ''
|
||||
base: ${{ steps.change-base.outputs.sha }}
|
||||
ref: ${{ github.sha }}
|
||||
filters: |
|
||||
frontend:
|
||||
- 'apps/desktop/**'
|
||||
@@ -1205,35 +1233,16 @@ jobs:
|
||||
- 'flake.lock'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/update-nix-pnpm-hash.yml'
|
||||
windows_win7_bundle:
|
||||
- '.github/scripts/assert-win7-pe-compat.ps1'
|
||||
- '.github/scripts/assert-webview2-win7-loader.ps1'
|
||||
- '.github/scripts/assert-win7-installer-content.ps1'
|
||||
- '.github/scripts/assert-webview2-win7-runtime.ps1'
|
||||
- '.github/scripts/prepare-webview2-win7-loader.ps1'
|
||||
- '.github/scripts/prepare-webview2-win7-runtime.ps1'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/release.yml'
|
||||
- 'src-tauri/tauri.webview2-win7-fixed.conf.json'
|
||||
- 'src-tauri/build.rs'
|
||||
- 'src-tauri/Cargo.toml'
|
||||
- 'src-tauri/windows/nsis/**'
|
||||
- 'src-tauri/src/commands/update.rs'
|
||||
- 'crates/dbx-core/Cargo.toml'
|
||||
- 'crates/dbx-drivers/**'
|
||||
- 'crates/dbx-driver-*/**'
|
||||
- 'crates/dbx-platform/**'
|
||||
- 'crates/dbx-core/src/host/update.rs'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'vendor/ctor/**'
|
||||
- 'vendor/dirs-sys/**'
|
||||
- 'vendor/pageant/**'
|
||||
- 'vendor/webview2-com-sys/**'
|
||||
- 'vendor/wry/**'
|
||||
github_scripts:
|
||||
- '.github/scripts/**'
|
||||
- 'docs/scripts/contributor-snapshot.mjs'
|
||||
- 'docs/scripts/update-contributors.mjs'
|
||||
- 'docs/public/install-mcp*'
|
||||
- '.github/workflows/contributors.yml'
|
||||
- '.github/workflows/issue-claim.yml'
|
||||
- '.github/workflows/notify.yml'
|
||||
- '.github/workflows/plugin-dev-host.yml'
|
||||
- '.github/workflows/spam-comment-guard.yml'
|
||||
- '.github/workflows/mcp-release.yml'
|
||||
- '.github/workflows/publish-packages.yml'
|
||||
- '.github/workflows/plugin-release-reusable.yml'
|
||||
@@ -1246,7 +1255,6 @@ jobs:
|
||||
BASE_SHA: ${{ steps.change-base.outputs.sha }}
|
||||
RUST_CHANGED: ${{ steps.filter.outputs.rust }}
|
||||
AGENTS_CHANGED: ${{ steps.filter.outputs.agents }}
|
||||
WIN7_CURRENT: ${{ steps.filter.outputs.windows_win7_bundle }}
|
||||
run: node .github/scripts/ci-plan.mjs
|
||||
|
||||
agents:
|
||||
@@ -1439,7 +1447,7 @@ jobs:
|
||||
cache-on-failure: false
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
# mold -run wraps the whole process tree (see the packages job note);
|
||||
@@ -1492,7 +1500,7 @@ jobs:
|
||||
run: bash ../.github/scripts/ci-agent-go.sh "$DRIVER" "$BINARY" "$RACE"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 4
|
||||
matrix: ${{ fromJSON(needs.changes.outputs.agent_go) }}
|
||||
agent-integration:
|
||||
needs: [changes, fast-checks, agent-checks]
|
||||
@@ -1503,6 +1511,16 @@ jobs:
|
||||
working-directory: agents
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Configure public Docker Hub cache
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
source /etc/os-release
|
||||
[[ "$ID" == ubuntu ]] || exit 0
|
||||
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
|
||||
timeout 30s sudo systemctl restart docker
|
||||
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
|
||||
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
|
||||
- name: Setup RocketMQ server Java
|
||||
if: matrix.scenario == 'rocketmq'
|
||||
uses: actions/setup-java@v6
|
||||
@@ -1520,7 +1538,7 @@ jobs:
|
||||
if: matrix.driver == 'tdengine'
|
||||
- name: Install nextest
|
||||
if: matrix.driver == 'tdengine'
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
- name: TDengine Rust cache
|
||||
@@ -1540,7 +1558,7 @@ jobs:
|
||||
name: agent-integration (${{ matrix.scenario }}, ${{ matrix.version }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 4
|
||||
matrix: ${{ fromJSON(needs.changes.outputs.agent_integration) }}
|
||||
ci:
|
||||
needs:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Update Contributors
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 18 * * *"
|
||||
- cron: "0 18 * * 0"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -16,7 +16,11 @@ permissions:
|
||||
jobs:
|
||||
claim:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !github.event.issue.pull_request }}
|
||||
if: >-
|
||||
${{ !github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
|
||||
(contains(github.event.comment.body, '/claim') ||
|
||||
contains(github.event.comment.body, '/unclaim') ||
|
||||
contains(github.event.comment.body, '/close')) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
run: cargo check -p dbx-mcp -p dbx-cli --no-default-features
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.68.13
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ name: Notifications
|
||||
on:
|
||||
# issues:
|
||||
# types: [opened]
|
||||
pull_request_target:
|
||||
types: [opened, closed]
|
||||
# pull_request_target:
|
||||
# types: [closed]
|
||||
workflow_dispatch:
|
||||
# Release 不在此通知:桌面正式版由 publish-packages.yml 在转正后发飞书卡片;
|
||||
# 组件包(agents-v*/packages-v*)发布不进群。
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build message
|
||||
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
|
||||
@@ -11,8 +11,22 @@ on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: npm ci --prefix plugins/sdk/dev-host
|
||||
- run: npm run lint --prefix plugins/sdk/dev-host
|
||||
- run: npm run fmt:check --prefix plugins/sdk/dev-host
|
||||
test:
|
||||
needs: checks
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -29,11 +43,9 @@ jobs:
|
||||
go-version: '1.22'
|
||||
cache: false
|
||||
- run: npm ci --prefix plugins/sdk/dev-host
|
||||
- run: npm run lint --prefix plugins/sdk/dev-host
|
||||
- run: npm run fmt:check --prefix plugins/sdk/dev-host
|
||||
- run: npm test --prefix plugins/sdk/dev-host
|
||||
timeout-minutes: 5
|
||||
- uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
- uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
- run: cargo nextest run --locked --manifest-path plugins/sdk/cli/Cargo.toml --no-fail-fast
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
name: Pull Request Labels
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened, ready_for_review]
|
||||
# Temporarily disabled (2026-10-10): informational labels only, no
|
||||
# mechanical consumers, fires on every PR event. Uncomment to re-enable.
|
||||
# pull_request_target:
|
||||
# types: [opened, edited, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: pull-request-labels-${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -12,7 +12,12 @@ jobs:
|
||||
delete-malicious-link:
|
||||
runs-on: ubuntu-latest
|
||||
# 跳过机器人与仓库 owner,避免误删维护者的正当评论
|
||||
if: ${{ github.event.comment.user.type != 'Bot' && github.event.comment.user.login != github.repository_owner }}
|
||||
if: >-
|
||||
${{ github.event.comment.user.type != 'Bot' && github.event.comment.user.login != github.repository_owner &&
|
||||
(contains(github.event.comment.body, '.zip') ||
|
||||
contains(github.event.comment.body, '.7z') ||
|
||||
contains(github.event.comment.body, '.exe') ||
|
||||
contains(github.event.comment.body, '.scr')) }}
|
||||
env:
|
||||
# owner PAT:GITHUB_TOKEN 非 admin 删不掉别人的评论,必须用 owner 令牌
|
||||
GH_TOKEN: ${{ secrets.PROJECT_AUTOMATION_TOKEN }}
|
||||
|
||||
@@ -19,8 +19,10 @@ name: UI preview
|
||||
# PR's own frontend build is what's on screen (DBX_STATIC_DIR).
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
# Temporarily disabled (2026-10-10) while jobs queue without a runner;
|
||||
# manual dispatch below still works. Uncomment to re-enable.
|
||||
# pull_request_target:
|
||||
# types: [opened, synchronize, reopened, labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
|
||||
Generated
+45
-17
@@ -262,6 +262,12 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arcstr"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
@@ -1527,9 +1533,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -2137,7 +2143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx"
|
||||
version = "0.6.38"
|
||||
version = "0.6.42"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2153,6 +2159,7 @@ dependencies = [
|
||||
"font-kit",
|
||||
"futures",
|
||||
"grep",
|
||||
"gtk",
|
||||
"ignore",
|
||||
"libloading 0.8.9",
|
||||
"log",
|
||||
@@ -2220,7 +2227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-cli"
|
||||
version = "0.4.111"
|
||||
version = "0.4.117"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbx-core",
|
||||
@@ -2428,7 +2435,9 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dbx-driver-support",
|
||||
"dbx-types",
|
||||
"futures-util",
|
||||
"redis",
|
||||
"rustls 0.23.43",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -2557,7 +2566,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-mcp"
|
||||
version = "0.4.111"
|
||||
version = "0.4.117"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -2741,7 +2750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-web"
|
||||
version = "0.6.38"
|
||||
version = "0.6.42"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"argon2",
|
||||
@@ -5892,7 +5901,7 @@ dependencies = [
|
||||
"darling 0.23.0",
|
||||
"heck 0.5.0",
|
||||
"manyhow",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5947,7 +5956,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"hex",
|
||||
"mysql-common-derive",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
"regex",
|
||||
"rust_decimal",
|
||||
@@ -6098,7 +6107,7 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
@@ -6116,6 +6125,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -6172,7 +6191,7 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
@@ -7627,22 +7646,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "0.32.7"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
|
||||
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
|
||||
dependencies = [
|
||||
"arcstr",
|
||||
"async-lock",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"crc16",
|
||||
"futures-sink",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"itoa",
|
||||
"log",
|
||||
"num-bigint",
|
||||
"num-bigint 0.5.1",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.5",
|
||||
"rand 0.10.2",
|
||||
"rustls 0.23.43",
|
||||
"rustls-native-certs 0.8.4",
|
||||
"ryu",
|
||||
@@ -7652,6 +7673,7 @@ dependencies = [
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8055,7 +8077,7 @@ dependencies = [
|
||||
"md5",
|
||||
"ml-kem",
|
||||
"module-lattice",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
@@ -9055,7 +9077,7 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
@@ -12112,6 +12134,12 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "yeslogic-fontconfig-sys"
|
||||
version = "6.0.1"
|
||||
|
||||
@@ -124,6 +124,17 @@
|
||||
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">Visit HuaLongAI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-en" target="_blank">
|
||||
<img src="docs/public/sponsors/hiapi-card.png" alt="HiAPI" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
HiAPI is a developer-focused API platform for image, video, audio, and text models — GPT Image 2.5 (Flare & Sunburst), OpenAI-compatible text endpoints, and Remote MCP/Agent Skills for Claude Code, Cursor, and more. Pay as you go with no minimum spend; new users get 200 Credits on signup, plus 10% bonus Credits on top-ups.
|
||||
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-en" target="_blank">Visit HiAPI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
|
||||
@@ -135,6 +146,29 @@
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">Visit UCloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">
|
||||
<img src="docs/public/sponsors/packyapi-card.png" alt="PackyCode" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
PackyCode is a stable, efficient API relay for mainstream AI models. Access them with one API endpoint and one API key — automatic failover keeps access fast and reliable, with dedicated high-speed routes for Codex and Claude Code. Get started with $1 in free credits, a first top-up discount, and up to 80% off eligible routes; pay in RMB with no currency conversion markups or extra top-up fees.
|
||||
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">Visit PackyCode</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://mate.vip/ee/" target="_blank">
|
||||
<img src="docs/public/sponsors/taiyi-card.png" alt="太一企业版" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
太一智能体企业版是面向央国企的私有化智能体平台,采用“1+N+N”架构:1 个平台、N 个终端、N 个场景。<strong>可信</strong>:Java 微服务架构,私有化部署,全程留痕可审计。<strong>可控</strong>:关键操作人工审批,资料按密级管理。<strong>可达</strong>:按单位模板直接生成公文、台账和汇报材料。<strong>可复制</strong>:场景方案包化交付,便于推广和二次开发。欢迎预约演示,或在 GitHub 搜索 MateClaw。
|
||||
<a href="https://mate.vip/ee/" target="_blank">访问太一企业版</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
|
||||
@@ -124,6 +124,17 @@
|
||||
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">访问 HuaLongAI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-zh" target="_blank">
|
||||
<img src="docs/public/sponsors/hiapi-card.png" alt="HiAPI" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
HiAPI 是面向开发者的图片、视频、音频及文本模型 API 平台——GPT Image 2.5(Flare 与 Sunburst)、OpenAI 兼容文本接口,并可通过 Remote MCP/Agent Skills 接入 Claude Code、Cursor 等编程工具。按量付费、无最低消费;新用户注册赠送 200 Credits,充值送 10% 积分。
|
||||
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-zh" target="_blank">访问 HiAPI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
|
||||
@@ -135,6 +146,29 @@
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">访问 UCloud 优刻得</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">
|
||||
<img src="docs/public/sponsors/packyapi-card.png" alt="PackyCode" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
PackyCode 是一家稳定、高效的 API 中转服务商,一句话接入主流大模型。统一域名、统一密钥、智能容灾切换,97% 可用性;人民币 1:1 充值,无汇率无手续费坑,新用户首充立享折扣 + $1 免费体验额度,多分组折扣低至 2 折起,并提供专属 Codex/Claude Code 高速通道。
|
||||
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">访问 PackyCode</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://mate.vip/ee/" target="_blank">
|
||||
<img src="docs/public/sponsors/taiyi-card.png" alt="太一企业版" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
太一智能体企业版是面向央国企的私有化智能体平台,采用“1+N+N”架构:1 个平台、N 个终端、N 个场景。<strong>可信</strong>:Java 微服务架构,私有化部署,全程留痕可审计。<strong>可控</strong>:关键操作人工审批,资料按密级管理。<strong>可达</strong>:按单位模板直接生成公文、台账和汇报材料。<strong>可复制</strong>:场景方案包化交付,便于推广和二次开发。欢迎预约演示,或在 GitHub 搜索 MateClaw。
|
||||
<a href="https://mate.vip/ee/" target="_blank">访问太一企业版</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
|
||||
@@ -421,7 +421,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
releasePooledConnection(true);
|
||||
return;
|
||||
}
|
||||
if (sessionAffinity || executor.hasOpenSessions() || executor.hasActiveStatements()) {
|
||||
if (sessionAffinity || executor.hasOpenSessions() || executor.hasActiveStatements() || hasRetainedResultResources()) {
|
||||
return;
|
||||
}
|
||||
releasePooledConnection(!preparePooledConnectionForReturn());
|
||||
@@ -438,7 +438,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
if (sessionAffinity) {
|
||||
return;
|
||||
}
|
||||
if (executor.hasOpenSessions() || executor.hasActiveStatements()) {
|
||||
if (executor.hasOpenSessions() || executor.hasActiveStatements() || hasRetainedResultResources()) {
|
||||
return;
|
||||
}
|
||||
releasePooledConnection(!preparePooledConnectionForReturn());
|
||||
@@ -713,7 +713,12 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
);
|
||||
}
|
||||
|
||||
protected boolean hasRetainedResultResources() { return false; }
|
||||
|
||||
protected void releaseRetainedResultResources() { }
|
||||
|
||||
private void closeCurrentConnection() throws Exception {
|
||||
releaseRetainedResultResources();
|
||||
if (pooledLease != null) {
|
||||
boolean evict = pooledConnectionPoisoned || sessionAffinity || !preparePooledConnectionForReturn();
|
||||
releasePooledConnection(evict);
|
||||
@@ -732,7 +737,17 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
return JdbcSchemaSwitcher.resetBeforeReturn(connection);
|
||||
}
|
||||
|
||||
protected final synchronized void discardConnection(Connection failedConnection) throws java.sql.SQLException {
|
||||
if (pooledLease != null && connection == failedConnection) {
|
||||
// Evict the checked-out lease before closing its proxy; close alone returns it to the pool.
|
||||
releasePooledConnection(true);
|
||||
} else {
|
||||
failedConnection.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void releasePooledConnection(boolean evict) {
|
||||
releaseRetainedResultResources();
|
||||
JdbcConnectionPoolRegistry.Lease lease = pooledLease;
|
||||
pooledLease = null;
|
||||
connection = null;
|
||||
|
||||
@@ -38,6 +38,8 @@ public final class AgentProtocol {
|
||||
public static final String METHOD_EXECUTE_QUERY_PAGE = "execute_query_page";
|
||||
public static final String METHOD_FETCH_QUERY_PAGE = "fetch_query_page";
|
||||
public static final String METHOD_CLOSE_QUERY_SESSION = "close_query_session";
|
||||
public static final String METHOD_READ_LARGE_VALUE_CHUNK = "read_large_value_chunk";
|
||||
public static final String METHOD_RELEASE_LARGE_VALUE = "release_large_value";
|
||||
public static final String METHOD_START_TABLE_READ = "start_table_read";
|
||||
public static final String METHOD_FETCH_TABLE_READ_PAGE = "fetch_table_read_page";
|
||||
public static final String METHOD_CLOSE_TABLE_READ_SESSION = "close_table_read_session";
|
||||
@@ -141,6 +143,7 @@ public final class AgentProtocol {
|
||||
public static final String CAPABILITY_MONGO_FIND_CURSOR = "mongo_find_cursor";
|
||||
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
|
||||
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
|
||||
public static final String CAPABILITY_BLOB_BIND_STATEMENTS_V1 = "blob_bind_statements_v1";
|
||||
|
||||
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
|
||||
CAPABILITY_CONNECT,
|
||||
@@ -215,6 +218,8 @@ public final class AgentProtocol {
|
||||
METHOD_EXECUTE_QUERY_PAGE,
|
||||
METHOD_FETCH_QUERY_PAGE,
|
||||
METHOD_CLOSE_QUERY_SESSION,
|
||||
METHOD_READ_LARGE_VALUE_CHUNK,
|
||||
METHOD_RELEASE_LARGE_VALUE,
|
||||
METHOD_START_TABLE_READ,
|
||||
METHOD_FETCH_TABLE_READ_PAGE,
|
||||
METHOD_CLOSE_TABLE_READ_SESSION,
|
||||
@@ -267,6 +272,7 @@ public final class AgentProtocol {
|
||||
|
||||
List<String> jdbcAllCapabilities = new java.util.ArrayList<>(MULTI_SESSION_ALL_CAPABILITIES);
|
||||
jdbcAllCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
|
||||
jdbcAllCapabilities.add(CAPABILITY_BLOB_BIND_STATEMENTS_V1);
|
||||
MULTI_SESSION_JDBC_ALL_CAPABILITIES = Collections.unmodifiableList(jdbcAllCapabilities);
|
||||
|
||||
List<String> methods = new java.util.ArrayList<>(COMMON_METHODS);
|
||||
@@ -383,6 +389,13 @@ public final class AgentProtocol {
|
||||
);
|
||||
}
|
||||
|
||||
public static HandshakeResult multiSessionJdbcHandshakeResult(boolean blobBindStatements) {
|
||||
if (!blobBindStatements) return multiSessionJdbcHandshakeResult();
|
||||
java.util.ArrayList<String> capabilities = new java.util.ArrayList<>(MULTI_SESSION_JDBC_CAPABILITIES);
|
||||
capabilities.add(CAPABILITY_BLOB_BIND_STATEMENTS_V1);
|
||||
return new HandshakeResult(MULTI_SESSION_PROTOCOL_VERSION, MULTI_SESSION_PROTOCOL_VERSION, capabilities);
|
||||
}
|
||||
|
||||
public static final class HandshakeResult {
|
||||
private final int protocolVersion;
|
||||
private final int agentProtocolVersion;
|
||||
|
||||
@@ -57,6 +57,11 @@ final class AgentRpcError extends RuntimeException {
|
||||
);
|
||||
}
|
||||
|
||||
static AgentRpcError rollbackConfirmedSql(SQLException error) {
|
||||
return new AgentRpcError(message(error), "sql", false, "keep", "execute", "unknown",
|
||||
safeSqlState(error.getSQLState()), error.getErrorCode(), safeClassName(error), error);
|
||||
}
|
||||
|
||||
static AgentRpcError backpressure(String stage, Throwable cause) {
|
||||
return new AgentRpcError(
|
||||
"Agent request capacity is temporarily exhausted",
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Savepoint;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class BlobBoundExecutor {
|
||||
private BlobBoundExecutor() { }
|
||||
|
||||
@FunctionalInterface
|
||||
public interface PreparedStatementConfigurer {
|
||||
void configure(PreparedStatement statement) throws SQLException;
|
||||
default void discardConnection(Connection connection) throws SQLException { connection.close(); }
|
||||
default boolean isRollbackConfirmedBusinessError(SQLException error) { return false; }
|
||||
default boolean isUnsupportedSavepointRelease(SQLException error) { return false; }
|
||||
}
|
||||
|
||||
public static QueryResult execute(Connection conn, List<String> previews, List<BlobBoundStatement> statements,
|
||||
String schema, Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql,
|
||||
int timeoutSecs, boolean transaction) {
|
||||
return execute(conn, previews, statements, schema, setSchemaSql, resetSchemaSql, timeoutSecs,
|
||||
transaction, statement -> { });
|
||||
}
|
||||
|
||||
public static QueryResult execute(Connection conn, List<String> previews, List<BlobBoundStatement> statements,
|
||||
String schema, Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql,
|
||||
int timeoutSecs, boolean transaction, PreparedStatementConfigurer configurer) {
|
||||
BlobBoundStatement.validate(previews, statements);
|
||||
if (timeoutSecs < 0) throw new IllegalArgumentException("BLOB binding timeout must be non-negative");
|
||||
try (JdbcExecutor.NativeOperation operation = JdbcExecutor.current().beginNativeOperation()) {
|
||||
operation.checkCancelled();
|
||||
boolean autoCommit = conn.getAutoCommit();
|
||||
if (transaction && !autoCommit) {
|
||||
throw new IllegalStateException("Cannot start a one-shot transaction while a manual transaction is open");
|
||||
}
|
||||
// Typed grid saves are atomic even when execute_batch omits useTransaction.
|
||||
// Existing manual transactions keep their batch-only savepoint boundary.
|
||||
boolean oneShot = transaction || autoCommit;
|
||||
if (oneShot && !conn.getMetaData().supportsTransactions()) {
|
||||
throw new UnsupportedOperationException("Transactions are not supported by this JDBC driver");
|
||||
}
|
||||
if (oneShot) conn.setAutoCommit(false);
|
||||
Savepoint savepoint = !oneShot ? conn.setSavepoint() : null;
|
||||
boolean finished = !oneShot;
|
||||
boolean discarded = false;
|
||||
boolean commitAttempted = false;
|
||||
try {
|
||||
long start = System.currentTimeMillis();
|
||||
long affected = executeAll(conn, statements, schema, setSchemaSql, resetSchemaSql, timeoutSecs, operation, configurer);
|
||||
operation.checkCancelled();
|
||||
if (oneShot) { commitAttempted = true; conn.commit(); }
|
||||
if (savepoint != null) releaseSavepoint(conn, savepoint, configurer);
|
||||
finished = true;
|
||||
return new QueryResult(Collections.emptyList(), Collections.emptyList(), affected,
|
||||
System.currentTimeMillis() - start, false);
|
||||
} catch (Exception failure) {
|
||||
if (oneShot) {
|
||||
try { conn.rollback(); finished = true; }
|
||||
catch (Exception rollbackFailure) {
|
||||
discarded = true;
|
||||
throw rollbackUnconfirmed(conn, failure, rollbackFailure, configurer);
|
||||
}
|
||||
if (!commitAttempted && failure instanceof SQLException sqlFailure
|
||||
&& configurer.isRollbackConfirmedBusinessError(sqlFailure)) {
|
||||
throw AgentRpcError.rollbackConfirmedSql(sqlFailure);
|
||||
}
|
||||
}
|
||||
if (savepoint != null) {
|
||||
try { conn.rollback(savepoint); }
|
||||
catch (Exception rollbackFailure) { throw rollbackUnconfirmed(conn, failure, rollbackFailure, configurer); }
|
||||
try { releaseSavepoint(conn, savepoint, configurer); }
|
||||
catch (Exception releaseFailure) {
|
||||
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
|
||||
"BLOB savepoint cleanup failed after batch rollback; discard this connection and treat transaction state as unknown", "08007", releaseFailure);
|
||||
unsafe.addSuppressed(failure);
|
||||
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
|
||||
throw unsafe;
|
||||
}
|
||||
if (failure instanceof SQLException sqlFailure && configurer.isRollbackConfirmedBusinessError(sqlFailure)) {
|
||||
throw AgentRpcError.rollbackConfirmedSql(sqlFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
} finally {
|
||||
if (oneShot) {
|
||||
if (finished) {
|
||||
try { conn.setAutoCommit(autoCommit); }
|
||||
catch (Exception resetFailure) {
|
||||
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
|
||||
"BLOB transaction connection reset failed; committed writes may exist and operation outcome is unknown", "08007", resetFailure);
|
||||
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
|
||||
throw unsafe;
|
||||
}
|
||||
} else if (!discarded) configurer.discardConnection(conn); // Never enable auto-commit after an unconfirmed rollback.
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
throw failure;
|
||||
} catch (Exception failure) {
|
||||
throw new RuntimeException(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static SQLTransientConnectionException rollbackUnconfirmed(Connection conn, Exception failure,
|
||||
Exception rollbackFailure, PreparedStatementConfigurer configurer) {
|
||||
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
|
||||
"BLOB binding rollback was not confirmed; discard this connection and treat the operation outcome as unknown", "08007", failure);
|
||||
unsafe.addSuppressed(rollbackFailure);
|
||||
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
|
||||
return unsafe;
|
||||
}
|
||||
|
||||
private static void releaseSavepoint(Connection conn, Savepoint savepoint,
|
||||
PreparedStatementConfigurer configurer) throws SQLException {
|
||||
try { conn.releaseSavepoint(savepoint); }
|
||||
catch (SQLException error) {
|
||||
if (!configurer.isUnsupportedSavepointRelease(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static long executeAll(Connection conn, List<BlobBoundStatement> statements, String schema,
|
||||
Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql, int timeoutSecs,
|
||||
JdbcExecutor.NativeOperation operation, PreparedStatementConfigurer configurer) throws Exception {
|
||||
JdbcExecutor executor = JdbcExecutor.current();
|
||||
try (BoundResources resources = new BoundResources()) {
|
||||
operation.checkCancelled();
|
||||
JdbcSchemaSwitcher.apply(conn, schema, setSchemaSql, resetSchemaSql);
|
||||
for (BlobBoundStatement bound : statements) {
|
||||
operation.checkCancelled();
|
||||
String sql = JdbcExecutor.trimSql(bound.sql());
|
||||
Statement statement = bound.blobParameters().isEmpty() ? conn.createStatement()
|
||||
: isAnonymousBlock(sql) ? conn.prepareCall(sql) : conn.prepareStatement(sql);
|
||||
resources.statements.add(executor.trackStatement(statement));
|
||||
statement.setQueryTimeout(timeoutSecs);
|
||||
if (statement instanceof PreparedStatement prepared) {
|
||||
configurer.configure(prepared);
|
||||
int index = 1;
|
||||
for (String hex : bound.blobParameters()) {
|
||||
InputStream stream = new HexStream(hex);
|
||||
resources.streams.add(stream);
|
||||
prepared.setBlob(index++, stream, (long) hex.length() / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prepare and bind the complete batch before the first write.
|
||||
long affected = 0;
|
||||
for (int i = 0; i < statements.size(); i++) {
|
||||
operation.checkCancelled();
|
||||
Statement statement = resources.statements.get(i).statement();
|
||||
boolean result = statement instanceof PreparedStatement prepared ? prepared.execute()
|
||||
: statement.execute(JdbcExecutor.trimSql(statements.get(i).sql()));
|
||||
if (!result) affected += Math.max(0, statement.getUpdateCount());
|
||||
operation.checkCancelled();
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BoundResources implements AutoCloseable {
|
||||
final List<JdbcExecutor.TrackedStatement<Statement>> statements = new ArrayList<>();
|
||||
final List<InputStream> streams = new ArrayList<>();
|
||||
@Override public void close() throws Exception {
|
||||
Exception failure = null;
|
||||
List<AutoCloseable> resources = new ArrayList<>(statements);
|
||||
resources.addAll(streams);
|
||||
for (AutoCloseable resource : resources) {
|
||||
try { resource.close(); }
|
||||
catch (Exception error) {
|
||||
if (failure == null) failure = error;
|
||||
else failure.addSuppressed(error);
|
||||
}
|
||||
}
|
||||
if (failure != null) throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isAnonymousBlock(String sql) {
|
||||
String normalized = sql.toUpperCase(Locale.ROOT);
|
||||
return normalized.startsWith("DECLARE") || normalized.startsWith("BEGIN");
|
||||
}
|
||||
|
||||
/** Decode already-validated hex without retaining a second full byte array. */
|
||||
private static final class HexStream extends InputStream {
|
||||
private final String hex;
|
||||
private int offset;
|
||||
private boolean closed;
|
||||
HexStream(String hex) { this.hex = hex; }
|
||||
@Override public int available() { return closed ? 0 : (hex.length() - offset) / 2; }
|
||||
@Override public int read() throws java.io.IOException {
|
||||
if (closed) throw new java.io.IOException("BLOB binding stream is closed");
|
||||
if (offset == hex.length()) return -1;
|
||||
int value = Character.digit(hex.charAt(offset), 16) * 16 + Character.digit(hex.charAt(offset + 1), 16);
|
||||
offset += 2;
|
||||
return value;
|
||||
}
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Optional BLOB-only binding payload; preview text stays authoritative. */
|
||||
public final class BlobBoundStatement {
|
||||
private String previewSql;
|
||||
private String sql;
|
||||
private List<String> blobParameters;
|
||||
|
||||
public BlobBoundStatement(String previewSql, String sql, List<String> blobParameters) {
|
||||
this.previewSql = previewSql;
|
||||
this.sql = sql;
|
||||
this.blobParameters = blobParameters;
|
||||
}
|
||||
|
||||
public String sql() { return sql; }
|
||||
public List<String> blobParameters() { return blobParameters; }
|
||||
|
||||
public static void validate(List<String> statements, List<BlobBoundStatement> boundStatements) {
|
||||
if (statements == null || boundStatements == null || statements.size() != boundStatements.size()) {
|
||||
throw new IllegalArgumentException("BLOB bound statements must match the complete statement batch");
|
||||
}
|
||||
for (int i = 0; i < statements.size(); i++) {
|
||||
BlobBoundStatement bound = boundStatements.get(i);
|
||||
if (bound == null || statements.get(i) == null || !statements.get(i).equals(bound.previewSql)
|
||||
|| bound.sql == null || bound.sql.trim().isEmpty() || bound.blobParameters == null) {
|
||||
throw new IllegalArgumentException("BLOB binding preview mismatch at statement " + (i + 1));
|
||||
}
|
||||
if (bound.blobParameters.isEmpty() && !bound.sql.equals(bound.previewSql)) {
|
||||
throw new IllegalArgumentException("Unbound statement SQL must match its preview");
|
||||
}
|
||||
SqlScan scan = scanSql(bound.sql);
|
||||
String visible = scan.visible().stripLeading().toUpperCase(java.util.Locale.ROOT);
|
||||
if (!visible.matches("(?s)^(?:UPDATE|INSERT|DELETE)\\b.*")
|
||||
&& !visible.matches("(?s)^(?:DECLARE\\b.*?)?BEGIN\\s+(?:UPDATE|INSERT|DELETE)\\b.*")
|
||||
|| java.util.regex.Pattern.compile("\\b(?:CREATE|ALTER|DROP|TRUNCATE|COMMIT|ROLLBACK|SAVEPOINT|GRANT|REVOKE|EXECUTE)\\b").matcher(visible).find()) {
|
||||
throw new IllegalArgumentException("BLOB binding only supports data-grid DML, without DDL or transaction control");
|
||||
}
|
||||
if (!bound.blobParameters.isEmpty() && scan.parameters() != bound.blobParameters.size()) {
|
||||
throw new IllegalArgumentException("BLOB parameter count does not match statement " + (i + 1));
|
||||
}
|
||||
for (String hex : bound.blobParameters) {
|
||||
if (hex == null || (hex.length() & 1) != 0) {
|
||||
throw new IllegalArgumentException("BLOB parameters must contain even-length hexadecimal bytes");
|
||||
}
|
||||
for (int j = 0; j < hex.length(); j++) {
|
||||
if (Character.digit(hex.charAt(j), 16) < 0 || hex.charAt(j) > 127) {
|
||||
throw new IllegalArgumentException("BLOB parameters must contain hexadecimal bytes only");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record SqlScan(int parameters, String visible) { }
|
||||
|
||||
private static SqlScan scanSql(String sql) {
|
||||
int count = 0;
|
||||
StringBuilder visible = new StringBuilder();
|
||||
for (int i = 0; i < sql.length(); i++) {
|
||||
char ch = sql.charAt(i);
|
||||
char next = i + 1 < sql.length() ? sql.charAt(i + 1) : '\0';
|
||||
if (ch == '-' && next == '-') {
|
||||
visible.append(' ');
|
||||
while (i + 1 < sql.length() && sql.charAt(i + 1) != '\n') i++;
|
||||
} else if (ch == '/' && next == '*') {
|
||||
visible.append(' ');
|
||||
int end = sql.indexOf("*/", i + 2);
|
||||
i = end < 0 ? sql.length() : end + 1;
|
||||
} else if ((ch == 'q' || ch == 'Q') && next == '\'' && i + 2 < sql.length()) {
|
||||
visible.append(' ');
|
||||
char open = sql.charAt(i + 2);
|
||||
char close = open == '[' ? ']' : open == '(' ? ')' : open == '{' ? '}' : open == '<' ? '>' : open;
|
||||
int end = sql.indexOf("" + close + '\'', i + 3);
|
||||
i = end < 0 ? sql.length() : end + 1;
|
||||
} else if (ch == '\'' || ch == '"') {
|
||||
visible.append(' ');
|
||||
char quote = ch;
|
||||
while (++i < sql.length()) {
|
||||
if (sql.charAt(i) == quote) {
|
||||
if (i + 1 < sql.length() && sql.charAt(i + 1) == quote) i++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
visible.append(ch);
|
||||
if (ch == '?') count++;
|
||||
}
|
||||
}
|
||||
return new SqlScan(count, visible.toString());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ public final class ColumnInfo {
|
||||
private Integer character_maximum_length;
|
||||
private String character_set;
|
||||
private String collation;
|
||||
private String resolved_schema;
|
||||
private String resolved_table;
|
||||
private String resolved_object_type;
|
||||
|
||||
public ColumnInfo() {
|
||||
this("", "", true, null, false);
|
||||
@@ -128,6 +131,18 @@ public final class ColumnInfo {
|
||||
return collation;
|
||||
}
|
||||
|
||||
public String getResolved_schema() {
|
||||
return resolved_schema;
|
||||
}
|
||||
|
||||
public String getResolved_table() {
|
||||
return resolved_table;
|
||||
}
|
||||
|
||||
public String getResolved_object_type() {
|
||||
return resolved_object_type;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
@@ -176,6 +191,18 @@ public final class ColumnInfo {
|
||||
this.collation = collation;
|
||||
}
|
||||
|
||||
public void setResolved_schema(String resolved_schema) {
|
||||
this.resolved_schema = resolved_schema;
|
||||
}
|
||||
|
||||
public void setResolved_table(String resolved_table) {
|
||||
this.resolved_table = resolved_table;
|
||||
}
|
||||
|
||||
public void setResolved_object_type(String resolved_object_type) {
|
||||
this.resolved_object_type = resolved_object_type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -192,7 +219,10 @@ public final class ColumnInfo {
|
||||
&& Objects.equals(numeric_scale, that.numeric_scale)
|
||||
&& Objects.equals(character_maximum_length, that.character_maximum_length)
|
||||
&& Objects.equals(character_set, that.character_set)
|
||||
&& Objects.equals(collation, that.collation);
|
||||
&& Objects.equals(collation, that.collation)
|
||||
&& Objects.equals(resolved_schema, that.resolved_schema)
|
||||
&& Objects.equals(resolved_table, that.resolved_table)
|
||||
&& Objects.equals(resolved_object_type, that.resolved_object_type);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -209,7 +239,10 @@ public final class ColumnInfo {
|
||||
numeric_scale,
|
||||
character_maximum_length,
|
||||
character_set,
|
||||
collation
|
||||
collation,
|
||||
resolved_schema,
|
||||
resolved_table,
|
||||
resolved_object_type
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,6 +260,9 @@ public final class ColumnInfo {
|
||||
+ ", character_maximum_length=" + character_maximum_length
|
||||
+ ", character_set=" + character_set
|
||||
+ ", collation=" + collation
|
||||
+ ", resolved_schema=" + resolved_schema
|
||||
+ ", resolved_table=" + resolved_table
|
||||
+ ", resolved_object_type=" + resolved_object_type
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ public final class CompletionAssistantCandidate {
|
||||
private final String parent_name;
|
||||
private final String comment;
|
||||
private final String data_type;
|
||||
private final String signature;
|
||||
private final String routine_id;
|
||||
|
||||
public CompletionAssistantCandidate(
|
||||
String name,
|
||||
@@ -19,6 +21,21 @@ public final class CompletionAssistantCandidate {
|
||||
String parentName,
|
||||
String comment,
|
||||
String dataType
|
||||
) {
|
||||
this(name, kind, database, schema, parentSchema, parentName, comment, dataType, null, null);
|
||||
}
|
||||
|
||||
public CompletionAssistantCandidate(
|
||||
String name,
|
||||
CompletionAssistantCandidateKind kind,
|
||||
String database,
|
||||
String schema,
|
||||
String parentSchema,
|
||||
String parentName,
|
||||
String comment,
|
||||
String dataType,
|
||||
String signature,
|
||||
String routineId
|
||||
) {
|
||||
this.name = name;
|
||||
this.kind = kind;
|
||||
@@ -28,6 +45,8 @@ public final class CompletionAssistantCandidate {
|
||||
this.parent_name = parentName;
|
||||
this.comment = comment;
|
||||
this.data_type = dataType;
|
||||
this.signature = signature;
|
||||
this.routine_id = routineId;
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
@@ -38,4 +57,6 @@ public final class CompletionAssistantCandidate {
|
||||
public String getParent_name() { return parent_name; }
|
||||
public String getComment() { return comment; }
|
||||
public String getData_type() { return data_type; }
|
||||
public String getSignature() { return signature; }
|
||||
public String getRoutine_id() { return routine_id; }
|
||||
}
|
||||
|
||||
@@ -6,11 +6,17 @@ public final class CompletionAssistantResponse {
|
||||
private final List<CompletionAssistantCandidate> candidates;
|
||||
private final boolean incomplete;
|
||||
private final boolean fallback_used;
|
||||
private final Boolean routine_search_supported;
|
||||
|
||||
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed) {
|
||||
this(candidates, incomplete, fallbackUsed, null);
|
||||
}
|
||||
|
||||
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed, Boolean routineSearchSupported) {
|
||||
this.candidates = candidates;
|
||||
this.incomplete = incomplete;
|
||||
this.fallback_used = fallbackUsed;
|
||||
this.routine_search_supported = routineSearchSupported;
|
||||
}
|
||||
|
||||
public List<CompletionAssistantCandidate> getCandidates() { return candidates; }
|
||||
|
||||
@@ -14,6 +14,13 @@ public interface DatabaseAgent {
|
||||
/** Opt in to request-boundary timing, including pooled connection lifecycle. */
|
||||
default boolean supportsQueryTiming() { return false; }
|
||||
|
||||
default boolean supportsBlobBindStatements() { return false; }
|
||||
|
||||
default QueryResult executeBlobBoundStatements(List<String> previews, List<BlobBoundStatement> statements,
|
||||
String schema, int timeoutSecs, boolean transaction) {
|
||||
throw new UnsupportedOperationException("BLOB statement binding is not supported by this agent");
|
||||
}
|
||||
|
||||
/** A dedicated manual transaction must never migrate to a replacement connection. */
|
||||
default boolean permitsAutomaticReconnect() { return true; }
|
||||
|
||||
@@ -86,6 +93,10 @@ public interface DatabaseAgent {
|
||||
|
||||
List<ColumnInfo> getColumns(String schema, String table);
|
||||
|
||||
default List<ColumnInfo> getColumnsInContext(String schema, String table, String currentSchema) {
|
||||
return getColumns(schema, table);
|
||||
}
|
||||
|
||||
default ObjectSource getObjectSource(String schema, String name, String objectType) {
|
||||
throw new UnsupportedOperationException("Object source is not supported");
|
||||
}
|
||||
@@ -204,6 +215,14 @@ public interface DatabaseAgent {
|
||||
return AgentExecutionContext.jdbcExecutor().closeQuerySession(sessionId);
|
||||
}
|
||||
|
||||
default Object readLargeValueChunk(String valueRef, long offset, int limit) {
|
||||
throw new UnsupportedOperationException("LOB snapshot chunks are not supported by this agent");
|
||||
}
|
||||
|
||||
default boolean releaseLargeValue(String valueRef) { return false; }
|
||||
|
||||
default void invalidateLargeValues() { }
|
||||
|
||||
default QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
|
||||
Connection conn = getConnection();
|
||||
if (conn == null) {
|
||||
|
||||
@@ -589,10 +589,12 @@ public final class JdbcExecutor {
|
||||
Math.max(options.getMaxRows(), 1),
|
||||
statementMessageReader
|
||||
);
|
||||
} else {
|
||||
appendStatementWarnings(result, stmt);
|
||||
}
|
||||
activeStatements.remove(stmt);
|
||||
stmt.close();
|
||||
return new QueryPageResult(
|
||||
QueryPageResult page = new QueryPageResult(
|
||||
result.getColumns(),
|
||||
result.getColumn_types(),
|
||||
result.getRows(),
|
||||
@@ -602,6 +604,8 @@ public final class JdbcExecutor {
|
||||
null,
|
||||
false
|
||||
);
|
||||
page.setMessages(result.getMessages());
|
||||
return page;
|
||||
}
|
||||
|
||||
phaseStarted = System.nanoTime();
|
||||
@@ -926,8 +930,9 @@ public final class JdbcExecutor {
|
||||
|
||||
while (rows.size() < effectivePageSize && session.rowsRead < session.maxRows) {
|
||||
if (!session.resultSet.next()) {
|
||||
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, false, null, false);
|
||||
closeSession(targetSessions, session.id);
|
||||
return sessionPageResult(session, rows, executionTimeMs, false, null, false);
|
||||
return page;
|
||||
}
|
||||
rows.add(rowValues(session.resultSet, session.valueReader, session.sqlTypeByIndex, session.typeNameByIndex));
|
||||
session.rowsRead += 1;
|
||||
@@ -935,14 +940,16 @@ public final class JdbcExecutor {
|
||||
|
||||
if (session.rowsRead >= session.maxRows) {
|
||||
boolean truncated = session.resultSet.next();
|
||||
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, truncated, null, false);
|
||||
closeSession(targetSessions, session.id);
|
||||
return sessionPageResult(session, rows, executionTimeMs, truncated, null, false);
|
||||
return page;
|
||||
}
|
||||
|
||||
boolean hasMore = session.resultSet.next();
|
||||
if (!hasMore) {
|
||||
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, false, null, false);
|
||||
closeSession(targetSessions, session.id);
|
||||
return sessionPageResult(session, rows, executionTimeMs, false, null, false);
|
||||
return page;
|
||||
}
|
||||
|
||||
session.pendingRow = rowValues(session.resultSet, session.valueReader, session.sqlTypeByIndex, session.typeNameByIndex);
|
||||
@@ -960,6 +967,9 @@ public final class JdbcExecutor {
|
||||
boolean hasMore
|
||||
) {
|
||||
QueryPageResult result = new QueryPageResult(session.columns, session.columnTypes, rows, 0L, executionTimeMs, truncated, sessionId, hasMore);
|
||||
QueryResult messages = new QueryResult();
|
||||
appendStatementWarnings(messages, session.statement);
|
||||
result.setMessages(messages.getMessages());
|
||||
result.setCursor_rows_read(session.rowsRead);
|
||||
return result;
|
||||
}
|
||||
@@ -1066,6 +1076,7 @@ public final class JdbcExecutor {
|
||||
StatementMessageReader statementMessageReader
|
||||
) {
|
||||
if (!result.getColumns().isEmpty() || !result.getRows().isEmpty()) {
|
||||
appendStatementWarnings(result, stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1082,6 +1093,7 @@ public final class JdbcExecutor {
|
||||
break;
|
||||
}
|
||||
rows.add(Collections.singletonList(message));
|
||||
result.addInformationalMessage(message, warning.getSQLState());
|
||||
}
|
||||
}
|
||||
stmt.clearWarnings();
|
||||
@@ -1102,6 +1114,7 @@ public final class JdbcExecutor {
|
||||
break;
|
||||
}
|
||||
rows.add(Collections.singletonList(message));
|
||||
result.addInformationalMessage(message, null);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
@@ -1111,7 +1124,7 @@ public final class JdbcExecutor {
|
||||
if (rows.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
return new QueryResult(
|
||||
QueryResult messageResult = new QueryResult(
|
||||
Collections.singletonList("Message"),
|
||||
Collections.singletonList("nvarchar"),
|
||||
rows,
|
||||
@@ -1119,6 +1132,8 @@ public final class JdbcExecutor {
|
||||
result.getExecution_time_ms(),
|
||||
truncated
|
||||
);
|
||||
messageResult.setMessages(result.getMessages());
|
||||
return messageResult;
|
||||
}
|
||||
|
||||
private QueryResult emptyQueryResult(long start) {
|
||||
@@ -1202,6 +1217,20 @@ public final class JdbcExecutor {
|
||||
* agents to convert vendor-specific column types (e.g. PostGIS
|
||||
* {@code geometry}) without re-querying the metadata.
|
||||
*/
|
||||
public <T> T withActiveStatement(Statement statement, SqlOperation<T> operation) throws SQLException {
|
||||
activeStatements.add(statement);
|
||||
try {
|
||||
return operation.run();
|
||||
} finally {
|
||||
activeStatements.remove(statement);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface SqlOperation<T> {
|
||||
T run() throws SQLException;
|
||||
}
|
||||
|
||||
public interface ColumnAwareResultValueReader extends ResultValueReader {
|
||||
Object read(ResultSet rs, int index, int sqlType, String columnTypeName) throws SQLException;
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ public final class JsonRpcServer {
|
||||
|
||||
void cancelActiveStatements() {
|
||||
jdbcExecutor.cancelActiveStatements();
|
||||
agent.invalidateLargeValues();
|
||||
AbstractJdbcAgent jdbcAgent = pooledJdbcAgent();
|
||||
if (jdbcAgent != null) {
|
||||
jdbcAgent.releaseIdlePooledConnection(jdbcExecutor);
|
||||
@@ -265,7 +266,9 @@ public final class JsonRpcServer {
|
||||
}
|
||||
if (AgentProtocol.METHOD_GET_COLUMNS.equals(method)) {
|
||||
switchCatalog(params);
|
||||
return agent.getColumns(params.get("schema").getAsString(), params.get("table").getAsString());
|
||||
String currentSchema = params.has("current_schema") && !params.get("current_schema").isJsonNull()
|
||||
? params.get("current_schema").getAsString() : null;
|
||||
return agent.getColumnsInContext(params.get("schema").getAsString(), params.get("table").getAsString(), currentSchema);
|
||||
}
|
||||
if (AgentProtocol.METHOD_LIST_INDEXES.equals(method)) {
|
||||
switchCatalog(params);
|
||||
@@ -323,6 +326,13 @@ public final class JsonRpcServer {
|
||||
if (AgentProtocol.METHOD_CLOSE_QUERY_SESSION.equals(method)) {
|
||||
return agent.closeQuerySession(params.get("sessionId").getAsString());
|
||||
}
|
||||
if (AgentProtocol.METHOD_READ_LARGE_VALUE_CHUNK.equals(method)) {
|
||||
return agent.readLargeValueChunk(params.get("valueRef").getAsString(),
|
||||
params.get("offset").getAsLong(), intOrDefault(params, "limit", 4096));
|
||||
}
|
||||
if (AgentProtocol.METHOD_RELEASE_LARGE_VALUE.equals(method)) {
|
||||
return agent.releaseLargeValue(params.get("valueRef").getAsString());
|
||||
}
|
||||
if (AgentProtocol.METHOD_START_TABLE_READ.equals(method)) {
|
||||
return agent.startTableRead(
|
||||
params.get("sql").getAsString(),
|
||||
@@ -359,22 +369,33 @@ public final class JsonRpcServer {
|
||||
return result;
|
||||
}
|
||||
if (AgentProtocol.METHOD_EXECUTE_TRANSACTION.equals(method)) {
|
||||
agent.invalidateLargeValues();
|
||||
Type statementsType = new TypeToken<List<String>>() {}.getType();
|
||||
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
|
||||
if (params.has("boundStatements")) {
|
||||
return executeBlobBoundStatements(params, statements, true);
|
||||
}
|
||||
return agent.executeTransaction(statements, stringOrNull(params, "schema"));
|
||||
}
|
||||
if (AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION.equals(method)) {
|
||||
agent.invalidateLargeValues();
|
||||
return agent.beginManualTransaction(stringOrNull(params, "schema"));
|
||||
}
|
||||
if (AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION.equals(method)) {
|
||||
agent.invalidateLargeValues();
|
||||
return agent.commitManualTransaction();
|
||||
}
|
||||
if (AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION.equals(method)) {
|
||||
agent.invalidateLargeValues();
|
||||
return agent.rollbackManualTransaction();
|
||||
}
|
||||
if (AgentProtocol.METHOD_EXECUTE_BATCH.equals(method)) {
|
||||
agent.invalidateLargeValues();
|
||||
Type statementsType = new TypeToken<List<String>>() {}.getType();
|
||||
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
|
||||
if (params.has("boundStatements")) {
|
||||
return executeBlobBoundStatements(params, statements, false);
|
||||
}
|
||||
return agent.executeBatch(statements, stringOrNull(params, "schema"));
|
||||
}
|
||||
if (AgentProtocol.METHOD_DISCONNECT.equals(method)) {
|
||||
@@ -394,6 +415,38 @@ public final class JsonRpcServer {
|
||||
throw new IllegalArgumentException("Unknown method: " + method);
|
||||
}
|
||||
|
||||
private QueryResult executeBlobBoundStatements(JsonObject params, List<String> previews, boolean transaction) {
|
||||
if (!agent.supportsBlobBindStatements()) {
|
||||
throw new UnsupportedOperationException("Agent does not support blob_bind_statements_v1");
|
||||
}
|
||||
if (!params.get("boundStatements").isJsonArray()) {
|
||||
throw new IllegalArgumentException("BLOB bound statements must be an array");
|
||||
}
|
||||
for (com.google.gson.JsonElement entry : params.getAsJsonArray("boundStatements")) {
|
||||
if (!entry.isJsonObject()) throw new IllegalArgumentException("BLOB bound statement must be an object");
|
||||
JsonObject item = entry.getAsJsonObject();
|
||||
for (String field : java.util.Arrays.asList("previewSql", "sql")) {
|
||||
if (!item.has(field) || !item.get(field).isJsonPrimitive()
|
||||
|| !item.get(field).getAsJsonPrimitive().isString()) {
|
||||
throw new IllegalArgumentException("BLOB bound statement text fields must be strings");
|
||||
}
|
||||
}
|
||||
if (!item.has("blobParameters") || !item.get("blobParameters").isJsonArray()) {
|
||||
throw new IllegalArgumentException("BLOB parameters must be an array of hexadecimal strings");
|
||||
}
|
||||
for (com.google.gson.JsonElement value : item.getAsJsonArray("blobParameters")) {
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
|
||||
throw new IllegalArgumentException("BLOB parameter must be a hexadecimal string");
|
||||
}
|
||||
}
|
||||
}
|
||||
Type boundType = new TypeToken<List<BlobBoundStatement>>() {}.getType();
|
||||
List<BlobBoundStatement> statements = gson.fromJson(params.get("boundStatements"), boundType);
|
||||
BlobBoundStatement.validate(previews, statements);
|
||||
return agent.executeBlobBoundStatements(previews, statements, stringOrNull(params, "schema"),
|
||||
intOrDefault(params, "queryTimeoutSecs", 0), transaction);
|
||||
}
|
||||
|
||||
private void switchCatalog(JsonObject params) throws Exception {
|
||||
String database = stringOrNull(params, "database");
|
||||
if (database != null && !database.trim().isEmpty() && agent.getConnection() != null) {
|
||||
|
||||
@@ -45,6 +45,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
|
||||
private final Object maintenanceLock = new Object();
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private ScheduledExecutorService maintenance;
|
||||
private boolean blobBindStatements;
|
||||
|
||||
public MultiSessionJsonRpcServer(Supplier<? extends DatabaseAgent> agentFactory) {
|
||||
this(agentFactory, new JdbcConnectionPoolRegistry(), RuntimeLimits.defaults());
|
||||
@@ -91,6 +92,12 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
|
||||
return new MultiSessionJsonRpcServer(sessionHandlerFactory, true);
|
||||
}
|
||||
|
||||
public static MultiSessionJsonRpcServer withBlobBindings(Supplier<? extends DatabaseAgent> agentFactory) {
|
||||
MultiSessionJsonRpcServer server = new MultiSessionJsonRpcServer(agentFactory);
|
||||
server.blobBindStatements = true;
|
||||
return server;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
synchronized (outputLock) {
|
||||
protocolOutput.println("{\"ready\":true}");
|
||||
@@ -138,7 +145,9 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
|
||||
try {
|
||||
Object result;
|
||||
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
|
||||
result = sessionHandlerFactory == null ? AgentProtocol.multiSessionJdbcHandshakeResult() : customHandshake();
|
||||
result = sessionHandlerFactory == null
|
||||
? AgentProtocol.multiSessionJdbcHandshakeResult(blobBindStatements)
|
||||
: customHandshake();
|
||||
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
|
||||
result = openSession(requiredSessionId(params), params);
|
||||
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
|
||||
|
||||
@@ -8,6 +8,7 @@ public final class ObjectInfo {
|
||||
private String schema;
|
||||
private String comment;
|
||||
private Boolean valid;
|
||||
private String parent_name;
|
||||
|
||||
public ObjectInfo() {
|
||||
this("", "", null, null, null);
|
||||
@@ -69,6 +70,14 @@ public final class ObjectInfo {
|
||||
this.valid = valid;
|
||||
}
|
||||
|
||||
public String getParent_name() {
|
||||
return parent_name;
|
||||
}
|
||||
|
||||
public void setParent_name(String parentName) {
|
||||
this.parent_name = parentName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -78,12 +87,13 @@ public final class ObjectInfo {
|
||||
&& Objects.equals(object_type, that.object_type)
|
||||
&& Objects.equals(schema, that.schema)
|
||||
&& Objects.equals(comment, that.comment)
|
||||
&& Objects.equals(parent_name, that.parent_name)
|
||||
&& Objects.equals(valid, that.valid);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(name, object_type, schema, comment, valid);
|
||||
return Objects.hash(name, object_type, schema, comment, valid, parent_name);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -19,6 +19,15 @@ public final class QueryPageResult {
|
||||
private boolean truncated;
|
||||
private String session_id;
|
||||
private boolean has_more;
|
||||
private List<java.util.Map<String, Object>> messages;
|
||||
|
||||
public List<java.util.Map<String, Object>> getMessages() {
|
||||
return messages == null ? Collections.emptyList() : messages;
|
||||
}
|
||||
|
||||
public void setMessages(List<java.util.Map<String, Object>> messages) {
|
||||
this.messages = messages;
|
||||
}
|
||||
|
||||
public QueryPageResult() {
|
||||
this(Collections.emptyList(), Collections.emptyList(), 0L, 0L, false, null, false);
|
||||
|
||||
@@ -27,6 +27,10 @@ public final class QueryResult {
|
||||
return messages == null ? Collections.emptyList() : messages;
|
||||
}
|
||||
|
||||
public void setMessages(List<Map<String, Object>> messages) {
|
||||
this.messages = messages;
|
||||
}
|
||||
|
||||
public void addInformationalMessage(String message, String code) {
|
||||
if (messages == null) messages = new ArrayList<>();
|
||||
if (messages.size() >= 512) return;
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
"execute_query_page",
|
||||
"fetch_query_page",
|
||||
"close_query_session",
|
||||
"read_large_value_chunk",
|
||||
"release_large_value",
|
||||
"start_table_read",
|
||||
"fetch_table_read_page",
|
||||
"close_table_read_session",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"protocolVersion": 2,
|
||||
"handshakeMethod": "handshake",
|
||||
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
|
||||
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor", "multi_session", "structured_error_v1"],
|
||||
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor", "multi_session", "structured_error_v1", "blob_bind_statements_v1"],
|
||||
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
|
||||
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session", "structured_error_v1"],
|
||||
"commonMethods": [
|
||||
@@ -36,6 +36,8 @@
|
||||
"execute_query_page",
|
||||
"fetch_query_page",
|
||||
"close_query_session",
|
||||
"read_large_value_chunk",
|
||||
"release_large_value",
|
||||
"start_table_read",
|
||||
"fetch_table_read_page",
|
||||
"close_table_read_session",
|
||||
@@ -51,5 +53,15 @@
|
||||
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "bulk_write", "rename_collection", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"],
|
||||
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"],
|
||||
"sessionField": "agentSessionId",
|
||||
"cursorSessionField": "sessionId"
|
||||
"cursorSessionField": "sessionId",
|
||||
"blobBindStatementsV1": {
|
||||
"capability": "blob_bind_statements_v1",
|
||||
"methods": ["execute_batch", "execute_transaction"],
|
||||
"optionalFields": ["boundStatements", "queryTimeoutSecs"],
|
||||
"statementFields": ["previewSql", "sql", "blobParameters"],
|
||||
"blobEncoding": "even-length hexadecimal without 0x",
|
||||
"previewRule": "complete array length and each previewSql must exactly match statements; unbound sql equals previewSql",
|
||||
"manualTransaction": "same connection, per-batch savepoint, no implicit commit; rollback to savepoint on failure",
|
||||
"unsupported": "reject complete request before executing any statement; no SQL-text fallback"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.Savepoint;
|
||||
import java.sql.SQLTimeoutException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class BlobBoundExecutorTest {
|
||||
private static final String PREVIEW = "UPDATE t SET b=HEXTORAW('00ff80')";
|
||||
private static final String SQL = "BEGIN UPDATE t SET b=? WHERE DBMS_LOB.COMPARE(b,?)=0; END;";
|
||||
private static BlobBoundStatement bound() { return new BlobBoundStatement(PREVIEW, SQL, Arrays.asList("00ff80", "cafe")); }
|
||||
|
||||
@Test void bindsStreamsOnFixedCallableSqlAndClosesThemBeforeCommit() throws Exception {
|
||||
Fake jdbc = new Fake();
|
||||
QueryResult result = run(jdbc, Arrays.asList(bound()), true);
|
||||
assertEquals(SQL, jdbc.preparedSql);
|
||||
assertEquals(Arrays.asList("00ff80", "cafe"), jdbc.boundHex);
|
||||
assertEquals(Arrays.asList(3L, 2L), jdbc.lengths);
|
||||
assertEquals(7, jdbc.timeout);
|
||||
assertEquals(1, jdbc.closedStatements);
|
||||
assertTrue(jdbc.events.indexOf("close") < jdbc.events.indexOf("commit"));
|
||||
assertEquals(1, jdbc.commits);
|
||||
assertEquals(0, jdbc.rollbacks);
|
||||
assertTrue(jdbc.autoCommit);
|
||||
assertEquals(1, result.getAffected_rows());
|
||||
for (InputStream stream : jdbc.streams) assertThrows(java.io.IOException.class, stream::read);
|
||||
}
|
||||
|
||||
@Test void piecewiseDriverSeesRemainingBytesUntilEntireHexStreamIsSent() throws Exception {
|
||||
Fake jdbc=new Fake(); jdbc.piecewiseRead=true;
|
||||
String hex="00ff80".repeat(40000);
|
||||
BlobBoundStatement statement=new BlobBoundStatement(PREVIEW,"UPDATE t SET b=?",Arrays.asList(hex));
|
||||
run(jdbc,Arrays.asList(statement),false);
|
||||
assertEquals(Arrays.asList(hex),jdbc.boundHex);
|
||||
assertEquals(4,jdbc.pieces);
|
||||
assertEquals(0,jdbc.streams.get(0).available());
|
||||
}
|
||||
|
||||
@Test void timeoutRollsBackClosesStreamsAndNeverFallsBackToText() throws Exception {
|
||||
Fake jdbc = new Fake(); jdbc.fail = true;
|
||||
RuntimeException failure = assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound()), true));
|
||||
assertInstanceOf(SQLTimeoutException.class, failure.getCause());
|
||||
assertEquals(1, jdbc.executions); assertEquals(1, jdbc.rollbacks); assertEquals(0, jdbc.commits);
|
||||
assertEquals(1, jdbc.closedStatements); assertTrue(jdbc.autoCommit);
|
||||
for (InputStream stream : jdbc.streams) assertThrows(java.io.IOException.class, stream::read);
|
||||
}
|
||||
|
||||
@Test void manualFailureRollsBackOnlyBatchSavepoint() {
|
||||
Fake jdbc = new Fake(); jdbc.autoCommit = false; jdbc.fail = true;
|
||||
assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound()), false));
|
||||
assertEquals(1, jdbc.savepointRollbacks); assertEquals(0, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.commits); assertFalse(jdbc.autoCommit); assertEquals(1, jdbc.releases);
|
||||
}
|
||||
|
||||
@Test void manualSuccessDoesNotCommitOrChangeAutoCommit() {
|
||||
Fake jdbc = new Fake(); jdbc.autoCommit = false;
|
||||
run(jdbc, Arrays.asList(bound()), false);
|
||||
assertEquals(1, jdbc.savepoints); assertEquals(1, jdbc.releases);
|
||||
assertEquals(0, jdbc.commits); assertEquals(0, jdbc.rollbacks); assertFalse(jdbc.autoCommit);
|
||||
}
|
||||
|
||||
@Test void laterManualConflictRollsBackFirstWriteButPreservesPriorTransactionWork() {
|
||||
Fake jdbc = new Fake(); jdbc.autoCommit=false; jdbc.failAt=2;
|
||||
assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound(),bound()), false));
|
||||
assertEquals(2,jdbc.executions); assertEquals(42,jdbc.value);
|
||||
assertEquals(1,jdbc.savepointRollbacks); assertEquals(0,jdbc.rollbacks);
|
||||
assertEquals(0,jdbc.commits); assertFalse(jdbc.autoCommit);
|
||||
}
|
||||
|
||||
@Test void failedManualRollbackQuarantinesConnectionWithUnknownOutcome() {
|
||||
Fake jdbc = new Fake(); jdbc.autoCommit=false; jdbc.failAt=1; jdbc.failRollback=true;
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->run(jdbc,Arrays.asList(bound()),false));
|
||||
assertTrue(jdbc.connectionClosed); assertEquals(0,jdbc.commits);
|
||||
com.google.gson.JsonObject data=AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data");
|
||||
assertEquals("quarantine",data.get("sessionDisposition").getAsString());
|
||||
assertEquals("unknown",data.get("operationOutcome").getAsString());
|
||||
assertEquals("connection",data.get("category").getAsString());
|
||||
assertEquals("08007",data.get("sqlState").getAsString());
|
||||
}
|
||||
|
||||
@Test void confirmedObBusinessErrorKeepsPriorWorkButRollbackFailureStillQuarantines() {
|
||||
for (int cleanupFailure : new int[]{0,1,2}) {
|
||||
boolean cleanupFails=cleanupFailure!=0;
|
||||
Fake jdbc=new Fake(); jdbc.autoCommit=false; jdbc.failAt=2; jdbc.failRollback=cleanupFailure==1; jdbc.failRelease=cleanupFailure==2;
|
||||
jdbc.executionFailure=new java.sql.SQLTransientConnectionException("ORA-20001: stale target", "HY000", 20001);
|
||||
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
|
||||
public void configure(PreparedStatement statement) { }
|
||||
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
|
||||
};
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
|
||||
Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer));
|
||||
var data=AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data");
|
||||
assertEquals(cleanupFails?"quarantine":"keep",data.get("sessionDisposition").getAsString());
|
||||
assertEquals(cleanupFails?"connection":"sql",data.get("category").getAsString());
|
||||
assertEquals("unknown",data.get("operationOutcome").getAsString());
|
||||
assertEquals(0,jdbc.commits); assertEquals(0,jdbc.rollbacks);
|
||||
if(!cleanupFails) { assertEquals(42,jdbc.value); assertEquals(1,jdbc.savepointRollbacks); assertFalse(jdbc.connectionClosed); }
|
||||
else { assertTrue(jdbc.connectionClosed); assertEquals("08007",data.get("sqlState").getAsString()); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactUnsupportedReleaseDoesNotCommitOrDiscardSuccessfulOrRolledBackManualBatch() {
|
||||
for(boolean conflict : new boolean[]{false,true}) {
|
||||
Fake jdbc=new Fake(); jdbc.autoCommit=false; jdbc.failRelease=true;
|
||||
jdbc.releaseFailure=new java.sql.SQLException("releaseSavepoint is not supported","99999",17023);
|
||||
if(conflict) { jdbc.failAt=2; jdbc.executionFailure=new java.sql.SQLTransientConnectionException("ORA-20001: stale", "HY000",20001); }
|
||||
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
|
||||
public void configure(PreparedStatement statement) { }
|
||||
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
|
||||
public boolean isUnsupportedSavepointRelease(java.sql.SQLException error) { return error.getErrorCode()==17023 && "99999".equals(error.getSQLState()); }
|
||||
};
|
||||
if(conflict) {
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
|
||||
Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer));
|
||||
assertEquals("keep",AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data").get("sessionDisposition").getAsString());
|
||||
assertEquals(42,jdbc.value); assertEquals(1,jdbc.savepointRollbacks);
|
||||
} else {
|
||||
BlobBoundExecutor.execute(jdbc.connection(),Arrays.asList(PREVIEW),Arrays.asList(bound()),null,s->s,()->null,7,false,configurer);
|
||||
assertEquals(43,jdbc.value); assertEquals(0,jdbc.savepointRollbacks);
|
||||
}
|
||||
assertEquals(0,jdbc.commits); assertFalse(jdbc.autoCommit); assertFalse(jdbc.connectionClosed);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void defaultTypedBatchCommitsOrRollsBackBeforeRestoringAutoCommit() {
|
||||
for(boolean conflict : new boolean[]{false,true}) {
|
||||
Fake jdbc=new Fake();
|
||||
if(conflict) { jdbc.failAt=2; jdbc.executionFailure=new java.sql.SQLTransientConnectionException("ORA-20001: stale", "HY000",20001); }
|
||||
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
|
||||
public void configure(PreparedStatement statement) { }
|
||||
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
|
||||
};
|
||||
if(conflict) {
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
|
||||
Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer));
|
||||
assertEquals("keep",AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data").get("sessionDisposition").getAsString());
|
||||
assertEquals(42,jdbc.value); assertEquals(1,jdbc.rollbacks); assertEquals(0,jdbc.commits);
|
||||
} else {
|
||||
BlobBoundExecutor.execute(jdbc.connection(),Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer);
|
||||
assertEquals(44,jdbc.value); assertEquals(1,jdbc.commits); assertEquals(0,jdbc.rollbacks);
|
||||
}
|
||||
assertTrue(jdbc.autoCommit); assertEquals(0,jdbc.savepoints);
|
||||
}
|
||||
Fake manual=new Fake(); manual.autoCommit=false;
|
||||
assertThrows(IllegalStateException.class,()->run(manual,Arrays.asList(bound()),true));
|
||||
assertEquals(0,manual.executions); assertFalse(manual.autoCommit);
|
||||
}
|
||||
|
||||
@Test void typedDdlOrTransactionControlRejectsCompleteBatchBeforeOpeningConnection() {
|
||||
for(String sql : List.of("CREATE TABLE t (b BLOB)","BEGIN UPDATE t SET b=?; COMMIT; END;", "BEGIN EXECUTE IMMEDIATE 'DROP TABLE t'; END;")) {
|
||||
Fake jdbc=new Fake();
|
||||
BlobBoundStatement invalid=new BlobBoundStatement(sql,sql,sql.contains("?")?List.of("00"):List.of());
|
||||
assertThrows(IllegalArgumentException.class,()->run(jdbc,Arrays.asList(bound(),invalid),false));
|
||||
assertEquals(0,jdbc.executions); assertNull(jdbc.preparedSql); assertTrue(jdbc.autoCommit);
|
||||
}
|
||||
BlobBoundStatement.validate(List.of(PREVIEW),List.of(new BlobBoundStatement(PREVIEW,"UPDATE t SET b=? /* COMMIT */ WHERE note='CREATE TABLE x'",List.of("00"))));
|
||||
}
|
||||
|
||||
@Test void defaultTypedCommitRollbackOrResetFailuresNeverBecomeKnownBusinessKeep() {
|
||||
for(String stage : List.of("commit","rollback","reset")) {
|
||||
Fake jdbc=new Fake(); jdbc.failCommit=stage.equals("commit"); jdbc.failReset=stage.equals("reset");
|
||||
if(stage.equals("rollback")) { jdbc.failAt=1; jdbc.failRollback=true; jdbc.executionFailure=new java.sql.SQLTransientConnectionException("stale", "HY000",20001); }
|
||||
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
|
||||
public void configure(PreparedStatement statement) { }
|
||||
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
|
||||
};
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
|
||||
Arrays.asList(PREVIEW),Arrays.asList(bound()),null,s->s,()->null,7,false,configurer));
|
||||
var data=AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data");
|
||||
assertEquals("quarantine",data.get("sessionDisposition").getAsString());
|
||||
assertEquals("unknown",data.get("operationOutcome").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unsupportedSavepointFailsBeforeAnyWrite() {
|
||||
Fake jdbc = new Fake(); jdbc.autoCommit = false; jdbc.unsupportedSavepoint = true;
|
||||
assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound()), false));
|
||||
assertEquals(0, jdbc.executions); assertEquals(0, jdbc.commits); assertEquals(0, jdbc.rollbacks);
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidLaterBindingBeforeOpeningAnyStatement() {
|
||||
Fake jdbc = new Fake();
|
||||
assertThrows(IllegalArgumentException.class, () -> run(jdbc,
|
||||
Arrays.asList(bound(), new BlobBoundStatement(PREVIEW, SQL, Arrays.asList("0x00"))), true));
|
||||
assertEquals(0, jdbc.executions); assertNull(jdbc.preparedSql); assertTrue(jdbc.autoCommit);
|
||||
assertThrows(IllegalArgumentException.class, () -> BlobBoundStatement.validate(Arrays.asList("edited"), Arrays.asList(bound())));
|
||||
}
|
||||
|
||||
@Test void cancellationTracksStatementAndPreventsNextStatement() {
|
||||
Fake jdbc = new Fake(); jdbc.cancel = true;
|
||||
assertThrows(java.util.concurrent.CancellationException.class, () -> run(jdbc, Arrays.asList(bound(), bound()), true));
|
||||
assertEquals(2, jdbc.cancels); assertEquals(1, jdbc.executions);
|
||||
assertEquals(1, jdbc.rollbacks); assertEquals(2, jdbc.closedStatements);
|
||||
}
|
||||
|
||||
@Test void invalidLaterParameterCountFailsBeforeAnyWriteAndIgnoresQuotedMarkers() {
|
||||
Fake jdbc=new Fake();
|
||||
assertThrows(IllegalArgumentException.class,()->run(jdbc,Arrays.asList(bound(),new BlobBoundStatement(PREVIEW,SQL,Arrays.asList("cafe"))),true));
|
||||
assertEquals(0,jdbc.executions); assertNull(jdbc.preparedSql);
|
||||
BlobBoundStatement.validate(Arrays.asList(PREVIEW),Arrays.asList(new BlobBoundStatement(PREVIEW,
|
||||
"BEGIN UPDATE t SET b=?; x:='?''?'; x:=q'[?]'; -- ?\n/* ? */ END;",Arrays.asList("cafe"))));
|
||||
}
|
||||
|
||||
@Test void legacyAgentRejectsBindingBeforeCallingLegacyBatch() {
|
||||
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(), new Class<?>[]{DatabaseAgent.class},
|
||||
(p, m, a) -> { if (m.getName().equals("supportsBlobBindStatements")) return false;
|
||||
if (m.getName().equals("getConnection")) return new Fake().connection();
|
||||
if (m.getName().startsWith("execute")) throw new AssertionError("Must not execute unsupported payload"); return defaultValue(m.getReturnType()); });
|
||||
String response = new JsonRpcServer(agent).handleRequest("{\"id\":1,\"method\":\"execute_batch\",\"params\":{\"statements\":[],\"boundStatements\":[]}}");
|
||||
assertTrue(JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error").get("message").getAsString().contains("blob_bind_statements_v1"), response);
|
||||
}
|
||||
|
||||
@Test void onlyOptedInServerAdvertisesBindingCapability() {
|
||||
com.google.gson.Gson gson = new com.google.gson.Gson();
|
||||
assertFalse(gson.toJson(AgentProtocol.multiSessionJdbcHandshakeResult()).contains("blob_bind_statements_v1"));
|
||||
assertTrue(gson.toJson(AgentProtocol.multiSessionJdbcHandshakeResult(true)).contains("blob_bind_statements_v1"));
|
||||
}
|
||||
|
||||
@Test void laterSetterFailureOccursBeforeAnyWriteAndClosesAllResources() {
|
||||
Fake jdbc=new Fake(); jdbc.failBindAt=3;
|
||||
assertThrows(RuntimeException.class,()->run(jdbc,Arrays.asList(bound(),bound()),true));
|
||||
assertEquals(0,jdbc.executions); assertEquals(1,jdbc.rollbacks); assertEquals(2,jdbc.closedStatements);
|
||||
for(InputStream stream:jdbc.streams)assertThrows(java.io.IOException.class,stream::read);
|
||||
}
|
||||
|
||||
@Test void cancellationDuringResourceCloseStillPreventsCommit() {
|
||||
Fake jdbc=new Fake();jdbc.cancelOnClose=true;
|
||||
assertThrows(java.util.concurrent.CancellationException.class,()->run(jdbc,Arrays.asList(bound()),true));
|
||||
assertEquals(1,jdbc.executions);assertEquals(0,jdbc.commits);assertEquals(1,jdbc.rollbacks);
|
||||
}
|
||||
|
||||
@Test void connectionResetAfterCommitReportsUnknownInsteadOfClaimingNoWrite() {
|
||||
Fake jdbc=new Fake();jdbc.failReset=true;
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->run(jdbc,Arrays.asList(bound()),true));
|
||||
assertEquals(1,jdbc.commits);assertTrue(jdbc.connectionClosed);
|
||||
com.google.gson.JsonObject data=AgentRpcError.toJson(failure,"execute_transaction","test").getAsJsonObject("data");
|
||||
assertEquals("quarantine",data.get("sessionDisposition").getAsString());
|
||||
assertEquals("unknown",data.get("operationOutcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void rpcRejectsNonStringHexRatherThanGsonCoercingIt() {
|
||||
DatabaseAgent agent=(DatabaseAgent)Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),new Class<?>[]{DatabaseAgent.class},
|
||||
(p,m,a)->{if(m.getName().equals("supportsBlobBindStatements"))return true;
|
||||
if(m.getName().equals("getConnection"))return new Fake().connection();
|
||||
if(m.getName().startsWith("execute"))throw new AssertionError("Malformed payload must not execute");return defaultValue(m.getReturnType());});
|
||||
String response=new JsonRpcServer(agent).handleRequest("{\"id\":2,\"method\":\"execute_transaction\",\"params\":{\"statements\":[\"preview\"],\"boundStatements\":[{\"previewSql\":\"preview\",\"sql\":\"BEGIN x:=?; END;\",\"blobParameters\":[12]}]}}");
|
||||
assertTrue(response.contains("hexadecimal string"),response);
|
||||
}
|
||||
|
||||
private static QueryResult run(Fake jdbc, List<BlobBoundStatement> statements, boolean transaction) {
|
||||
List<String> previews = new ArrayList<>(); for (BlobBoundStatement ignored : statements) previews.add(PREVIEW);
|
||||
return BlobBoundExecutor.execute(jdbc.connection(), previews, statements, null, schema -> null, () -> "", 7, transaction);
|
||||
}
|
||||
|
||||
private static final class Fake {
|
||||
boolean autoCommit = true, fail, cancel, unsupportedSavepoint, failRollback, connectionClosed, failReset, cancelOnClose, piecewiseRead, failRelease, failCommit;
|
||||
int pieces;
|
||||
int value=42,savedValue=42,transactionStartValue=42,failAt,failBindAt;
|
||||
int commits, rollbacks, savepointRollbacks, savepoints, releases, executions, closedStatements, cancels, timeout;
|
||||
String preparedSql;
|
||||
java.sql.SQLException executionFailure;
|
||||
java.sql.SQLException releaseFailure;
|
||||
List<String> events = new ArrayList<>(), boundHex = new ArrayList<>();
|
||||
List<Long> lengths = new ArrayList<>(); List<InputStream> streams = new ArrayList<>();
|
||||
Connection connection() {
|
||||
return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[]{Connection.class}, (p,m,a) -> {
|
||||
switch (m.getName()) {
|
||||
case "getAutoCommit": return autoCommit;
|
||||
case "isValid": return true;
|
||||
case "close": connectionClosed=true; return null;
|
||||
case "setAutoCommit": if(failReset && (boolean)a[0])throw new java.sql.SQLException("reset failed"); if(autoCommit && !(boolean)a[0])transactionStartValue=value; autoCommit = (boolean)a[0]; return null;
|
||||
case "getMetaData": return Proxy.newProxyInstance(DatabaseMetaData.class.getClassLoader(), new Class<?>[]{DatabaseMetaData.class}, (x,y,z) -> y.getName().equals("supportsTransactions") ? true : defaultValue(y.getReturnType()));
|
||||
case "setSavepoint": if (unsupportedSavepoint) throw new java.sql.SQLFeatureNotSupportedException(); savepoints++; savedValue=value; return Proxy.newProxyInstance(Savepoint.class.getClassLoader(),new Class<?>[]{Savepoint.class},(x,y,z)->defaultValue(y.getReturnType()));
|
||||
case "releaseSavepoint": releases++; if(failRelease)throw releaseFailure!=null?releaseFailure:new java.sql.SQLTransientConnectionException("release lost connection","08006"); return null;
|
||||
case "commit": commits++; events.add("commit"); if(failCommit)throw new java.sql.SQLTransientConnectionException("commit outcome unknown","HY000",20001); return null;
|
||||
case "rollback": if(failRollback)throw new java.sql.SQLException("rollback lost connection","08006"); if (a == null || a.length == 0) {rollbacks++;value=transactionStartValue;} else {savepointRollbacks++;value=savedValue;} return null;
|
||||
case "prepareCall": case "prepareStatement": preparedSql=(String)a[0]; return statement(m.getName().equals("prepareCall"));
|
||||
default: return defaultValue(m.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
PreparedStatement statement(boolean callable) {
|
||||
Class<?> type = callable ? CallableStatement.class : PreparedStatement.class;
|
||||
return (PreparedStatement) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, (p,m,a) -> {
|
||||
switch (m.getName()) {
|
||||
case "setQueryTimeout": timeout=(int)a[0]; return null;
|
||||
case "setBlob": assertInstanceOf(InputStream.class,a[1]); assertInstanceOf(Long.class,a[2]);
|
||||
InputStream stream=(InputStream)a[1]; streams.add(stream); lengths.add((Long)a[2]);
|
||||
if(streams.size()==failBindAt)throw new java.sql.SQLException("binding failed");
|
||||
byte[] bytes;
|
||||
if(piecewiseRead) {
|
||||
java.io.ByteArrayOutputStream sent=new java.io.ByteArrayOutputStream(); byte[] buffer=new byte[32768];
|
||||
int read;
|
||||
while((read=stream.read(buffer))!=-1) { sent.write(buffer,0,read); pieces++; if(stream.available()==0)break; }
|
||||
bytes=sent.toByteArray();
|
||||
} else bytes=stream.readAllBytes();
|
||||
StringBuilder hex=new StringBuilder(); for(byte b:bytes)hex.append(String.format("%02x",b&255)); boundHex.add(hex.toString()); return null;
|
||||
case "execute": executions++;value++; if(fail)throw new SQLTimeoutException("timed out","HYT00"); if(executions==failAt)throw executionFailure!=null?executionFailure:new java.sql.SQLException("stale target","23000"); if(cancel)JdbcExecutor.current().cancelActiveStatements(); return false;
|
||||
case "getUpdateCount": return 1;
|
||||
case "cancel": cancels++; return null;
|
||||
case "close": closedStatements++; events.add("close"); if(cancelOnClose)JdbcExecutor.current().cancelActiveStatements(); return null;
|
||||
case "hashCode": return System.identityHashCode(p);
|
||||
case "equals": return p==a[0];
|
||||
default: return defaultValue(m.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if(type==boolean.class)return false;if(type==int.class)return 0;if(type==long.class)return 0L;return null;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,86 @@ import static org.junit.jupiter.api.Assertions.fail;
|
||||
class JdbcConnectionPoolingTest {
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
@Test
|
||||
void blobRollbackFailureCannotReturnDirtyConnectionToAnotherSession() throws Exception {
|
||||
for (boolean manual : List.of(false, true)) {
|
||||
AtomicInteger physicalOpens = new AtomicInteger();
|
||||
CountDownLatch batchFinished = new CountDownLatch(1);
|
||||
CountDownLatch finishRequest = new CountDownLatch(1);
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
String url = h2Url("blob_rollback");
|
||||
try (Connection setup = DriverManager.getConnection(url, "sa", "");
|
||||
Statement statement = setup.createStatement();
|
||||
JdbcConnectionPoolRegistry registry = new JdbcConnectionPoolRegistry(poolSettings(2))) {
|
||||
statement.execute("CREATE TABLE rollback_probe (id INT)");
|
||||
List<JsonRpcServer> sessions = new java.util.ArrayList<>();
|
||||
for (int index = 0; index < 2; index++) {
|
||||
H2TestAgent agent = new H2TestAgent(url, physicalOpens) {
|
||||
@Override public boolean supportsBlobBindStatements() { return true; }
|
||||
|
||||
@Override protected Connection openConnection(ConnectParams params) throws Exception {
|
||||
Connection physical = openH2(url, physicalOpens);
|
||||
if (physicalOpens.get() != 1) return physical;
|
||||
return (Connection) Proxy.newProxyInstance(getClass().getClassLoader(),
|
||||
new Class<?>[]{Connection.class}, (proxy, method, args) -> {
|
||||
if (method.getName().equals("rollback")) {
|
||||
throw new SQLException("Rollback was not acknowledged", "HY000");
|
||||
}
|
||||
try { return method.invoke(physical, args); }
|
||||
catch (InvocationTargetException error) { throw error.getCause(); }
|
||||
});
|
||||
}
|
||||
|
||||
@Override public QueryResult executeBlobBoundStatements(List<String> previews,
|
||||
List<BlobBoundStatement> statements, String schema, int timeoutSecs, boolean transaction) {
|
||||
try {
|
||||
return BlobBoundExecutor.execute(requireConnected(), previews, statements,
|
||||
schema, value -> null, () -> null, timeoutSecs, transaction,
|
||||
new BlobBoundExecutor.PreparedStatementConfigurer() {
|
||||
@Override public void configure(java.sql.PreparedStatement statement) { }
|
||||
@Override public void discardConnection(Connection connection) throws SQLException {
|
||||
agentDiscard(connection);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
batchFinished.countDown();
|
||||
awaitUninterruptibly(finishRequest);
|
||||
}
|
||||
}
|
||||
|
||||
private void agentDiscard(Connection connection) throws SQLException {
|
||||
discardConnection(connection);
|
||||
}
|
||||
};
|
||||
agent.attachConnectionPoolRegistry(registry);
|
||||
JsonRpcServer server = new JsonRpcServer(agent);
|
||||
server.dispatchForRuntime(AgentProtocol.METHOD_CONNECT,
|
||||
JsonParser.parseString("{\"database\":\"pooling\",\"username\":\"sa\",\"password\":\"\"}").getAsJsonObject());
|
||||
sessions.add(server);
|
||||
}
|
||||
if (manual) sessions.get(0).dispatchForRuntime(AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION, new JsonObject());
|
||||
List<String> sql = List.of("INSERT INTO rollback_probe VALUES (1)", "INSERT INTO missing_table VALUES (2)");
|
||||
JsonObject request = new JsonObject();
|
||||
request.add("statements", GSON.toJsonTree(sql));
|
||||
request.add("boundStatements", GSON.toJsonTree(sql.stream()
|
||||
.map(value -> new BlobBoundStatement(value, value, List.of())).toList()));
|
||||
Future<Exception> failure = worker.submit(() -> assertThrows(Exception.class,
|
||||
() -> sessions.get(0).dispatchForRuntime(AgentProtocol.METHOD_EXECUTE_BATCH, request)));
|
||||
try {
|
||||
assertTrue(batchFinished.await(3, TimeUnit.SECONDS));
|
||||
QueryResult result = (QueryResult) sessions.get(1).dispatchForRuntime(AgentProtocol.METHOD_EXECUTE_QUERY,
|
||||
JsonParser.parseString("{\"sql\":\"SELECT COUNT(*) FROM rollback_probe\"}").getAsJsonObject());
|
||||
assertEquals(0L, ((Number) result.getRows().get(0).get(0)).longValue(), "manual=" + manual);
|
||||
assertEquals(2, physicalOpens.get());
|
||||
} finally { finishRequest.countDown(); }
|
||||
assertTrue(failure.get(3, TimeUnit.SECONDS).toString().contains("rollback was not confirmed"));
|
||||
} finally {
|
||||
finishRequest.countDown();
|
||||
worker.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void registryReusesOnePhysicalConnectionAcrossSequentialLeases() throws Exception {
|
||||
AtomicInteger physicalOpens = new AtomicInteger();
|
||||
|
||||
@@ -300,7 +300,7 @@ class JdbcExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void executePageKeepsWarningsHiddenWithoutADriverMessageReader() {
|
||||
void executePageRetainsWarningsWithoutReplacingTheResult() {
|
||||
QueryPageResult result = JdbcExecutor.INSTANCE.executePage(
|
||||
executionConnection(
|
||||
false,
|
||||
@@ -318,6 +318,7 @@ class JdbcExecutorTest {
|
||||
|
||||
assertEquals(Collections.emptyList(), result.getColumns());
|
||||
assertEquals(Collections.emptyList(), result.getRows());
|
||||
assertEquals("existing paged warning", result.getMessages().get(0).get("message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -332,6 +333,23 @@ class JdbcExecutorTest {
|
||||
|
||||
assertEquals(Arrays.asList("id", "name"), result.getColumns());
|
||||
assertEquals(Arrays.asList(Arrays.asList(1, "Ada")), result.getRows());
|
||||
assertEquals("informational", result.getMessages().get(0).get("message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void executePageRetainsWarningsWithOrdinaryRows() {
|
||||
CountingResultSetFixture fixture = countingResultSet(new Object[][]{{1, "Ada"}});
|
||||
QueryPageResult result = JdbcExecutor.INSTANCE.executePage(
|
||||
executionConnection(true, -1, new SQLWarning("notice with rows"), new AtomicInteger(), fixture.resultSet(), null),
|
||||
"SELECT id, name FROM people",
|
||||
"",
|
||||
schema -> "",
|
||||
new QueryPageOptions()
|
||||
);
|
||||
|
||||
assertEquals(Arrays.asList("id", "name"), result.getColumns());
|
||||
assertEquals(Arrays.asList(Arrays.asList(1, "Ada")), result.getRows());
|
||||
assertEquals("notice with rows", result.getMessages().get(0).get("message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
class JsonRpcLargeValueTest {
|
||||
@Test
|
||||
void dispatchesOpaqueLocatorAndCharacterOffsetAndReleasesItWithoutSql() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),
|
||||
new Class<?>[]{DatabaseAgent.class}, (object, method, args) -> {
|
||||
if (method.getName().equals("supportsQueryTiming")) return false;
|
||||
if (method.getName().equals("permitsAutomaticReconnect")) return false;
|
||||
if (method.getName().equals("readLargeValueChunk")) {
|
||||
calls.add("read");
|
||||
assertArrayEquals(new Object[]{"opaque-original", 700L, 4096}, args);
|
||||
return Map.of("status", "ok", "data", "中文😀", "next_offset", 703L, "eof", false, "value_kind", "text");
|
||||
}
|
||||
if (method.getName().equals("releaseLargeValue")) { calls.add("release"); assertEquals("opaque-original", args[0]); return true; }
|
||||
return null;
|
||||
});
|
||||
JsonRpcServer server = new JsonRpcServer(agent);
|
||||
Object result = server.dispatchForRuntime(AgentProtocol.METHOD_READ_LARGE_VALUE_CHUNK,
|
||||
JsonParser.parseString("{\"valueRef\":\"opaque-original\",\"offset\":700,\"limit\":4096}").getAsJsonObject());
|
||||
assertEquals("中文😀", ((Map<?, ?>) result).get("data"));
|
||||
assertEquals(true, server.dispatchForRuntime(AgentProtocol.METHOD_RELEASE_LARGE_VALUE,
|
||||
JsonParser.parseString("{\"valueRef\":\"opaque-original\"}").getAsJsonObject()));
|
||||
assertEquals(List.of("read", "release"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidatesLocatorsBeforeTransactionBoundary() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),
|
||||
new Class<?>[]{DatabaseAgent.class}, (object, method, args) -> {
|
||||
if (method.getName().equals("supportsQueryTiming")) return false;
|
||||
if (method.getName().equals("permitsAutomaticReconnect")) return false;
|
||||
if (method.getName().equals("invalidateLargeValues")) { calls.add("invalidate"); return null; }
|
||||
if (method.getName().equals("commitManualTransaction")) { calls.add("commit"); return Map.of("ok", true); }
|
||||
return null;
|
||||
});
|
||||
new JsonRpcServer(agent).dispatchForRuntime(AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION,
|
||||
JsonParser.parseString("{}").getAsJsonObject());
|
||||
assertEquals(List.of("invalidate", "commit"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidatesBeforeFailedWriteAndRollbackAndOnCancellation() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),
|
||||
new Class<?>[]{DatabaseAgent.class}, (object, method, args) -> {
|
||||
if (method.getName().equals("supportsQueryTiming")) return false;
|
||||
if (method.getName().equals("permitsAutomaticReconnect")) return false;
|
||||
if (method.getName().equals("invalidateLargeValues")) { calls.add("invalidate"); return null; }
|
||||
if (method.getName().equals("executeBatch")) { calls.add("write"); throw new java.sql.SQLException("concurrent row removed"); }
|
||||
if (method.getName().equals("rollbackManualTransaction")) { calls.add("rollback"); return Map.of("ok", true); }
|
||||
return null;
|
||||
});
|
||||
JsonRpcServer server = new JsonRpcServer(agent);
|
||||
assertThrows(Exception.class, () -> server.dispatchForRuntime(AgentProtocol.METHOD_EXECUTE_BATCH,
|
||||
JsonParser.parseString("{\"statements\":[\"UPDATE T SET C='full original value'\"]}").getAsJsonObject()));
|
||||
assertEquals(List.of("invalidate", "write"), calls);
|
||||
calls.clear();
|
||||
server.dispatchForRuntime(AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION, JsonParser.parseString("{}").getAsJsonObject());
|
||||
assertEquals(List.of("invalidate", "rollback"), calls);
|
||||
calls.clear();
|
||||
server.cancelActiveStatements();
|
||||
assertEquals(List.of("invalidate"), calls);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation 'io.github.spannm:ucanaccess:5.1.5'
|
||||
implementation 'io.github.spannm:jackcess-encrypt:5.1.0'
|
||||
implementation 'io.github.spannm:ucanaccess:5.1.8'
|
||||
implementation 'io.github.spannm:jackcess-encrypt:5.1.6'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
@@ -744,6 +744,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
if (errorCode == -3325 || errorCode == -2207) {
|
||||
return true;
|
||||
}
|
||||
if (isDamengPermissionError(candidate)) {
|
||||
return true;
|
||||
}
|
||||
String message = candidate.getMessage();
|
||||
if (message == null) {
|
||||
continue;
|
||||
@@ -786,6 +789,27 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 达梦中文环境在无权限时只返回「权限不足」(vendorCode -5669),消息里不带对象名,
|
||||
* 无法通过 {@link #isDamengMetadataUnavailableError} 的对象名匹配识别。
|
||||
* 这类错误同样应按「元数据不可用」处理:走字典视图降级,而不是让查看对象直接报错。
|
||||
*/
|
||||
private static boolean isDamengPermissionError(SQLException error) {
|
||||
if (error.getErrorCode() == -5669) {
|
||||
return true;
|
||||
}
|
||||
String message = error.getMessage();
|
||||
if (message == null) {
|
||||
return false;
|
||||
}
|
||||
String normalized = message.toLowerCase(Locale.ROOT);
|
||||
return normalized.contains("权限")
|
||||
|| normalized.contains("privilege")
|
||||
|| normalized.contains("permission")
|
||||
|| normalized.contains("access denied")
|
||||
|| normalized.contains("not authorized");
|
||||
}
|
||||
|
||||
/** 连接类错误(断连/超时等)不属于元数据不可用,必须继续上抛。 */
|
||||
private static boolean isDamengConnectionError(Throwable error) {
|
||||
for (Throwable current = error; current != null; current = current.getCause()) {
|
||||
|
||||
@@ -1159,6 +1159,37 @@ class DamengAgentMetadataTest {
|
||||
Assertions.assertTrue(source.getSource().contains("系统字典视图"), source.getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTriggerCatalogWhenDbmsMetadataPermissionDenied() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
String sql = (String) args[0];
|
||||
sqls.add(sql);
|
||||
if (sql.contains("DBMS_METADATA.GET_DDL")) {
|
||||
return failingMetadataStatement(new SQLException("权限不足", "28000", -5669));
|
||||
}
|
||||
if (sql.contains("ALL_TRIGGERS")) {
|
||||
return metadataStatement(List.of(List.of("BEGIN NULL; END;")));
|
||||
}
|
||||
}
|
||||
if ("close".equals(method.getName())) {
|
||||
return null;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}));
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "TRG_AUDIT", "TRIGGER");
|
||||
|
||||
Assertions.assertTrue(source.getSource().contains("BEGIN NULL; END;"), source.getSource());
|
||||
Assertions.assertTrue(source.getSource().contains("系统字典视图"), source.getSource());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("ALL_TRIGGERS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableDdlFallsBackToViewAndMaterializedViewCatalogDefinitions() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
dependencies {
|
||||
implementation 'com.databend:databend-jdbc:0.4.6'
|
||||
implementation 'com.databend:databend-jdbc:0.4.8'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
Generated
+74
-12
@@ -160,6 +160,12 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arcstr"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
@@ -236,7 +242,7 @@ checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"half",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
@@ -341,6 +347,17 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
@@ -794,9 +811,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -2060,6 +2077,26 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.2.0"
|
||||
@@ -3547,7 +3584,7 @@ dependencies = [
|
||||
"darling",
|
||||
"heck",
|
||||
"manyhow",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3602,7 +3639,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"hex",
|
||||
"mysql-common-derive",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
"regex",
|
||||
"rust_decimal",
|
||||
@@ -3688,6 +3725,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -3921,6 +3968,12 @@ dependencies = [
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -4614,22 +4667,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "0.32.7"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
|
||||
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
|
||||
dependencies = [
|
||||
"arcstr",
|
||||
"async-lock",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"crc16",
|
||||
"futures-sink",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"itoa",
|
||||
"log",
|
||||
"num-bigint",
|
||||
"num-bigint 0.5.1",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.5",
|
||||
"rand 0.10.2",
|
||||
"rustls 0.23.42",
|
||||
"rustls-native-certs 0.8.4",
|
||||
"ryu",
|
||||
@@ -4639,6 +4694,7 @@ dependencies = [
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4870,7 +4926,7 @@ dependencies = [
|
||||
"md5",
|
||||
"ml-kem",
|
||||
"module-lattice",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
@@ -5512,7 +5568,7 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
@@ -6993,6 +7049,12 @@ dependencies = [
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.exasol:exasol-jdbc:26.2.7'
|
||||
implementation 'com.exasol:exasol-jdbc:26.2.9'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
@@ -9,18 +9,94 @@ import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
import java.nio.charset.Charset;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
private static final String COMMON_OBJECTS_SQL = """
|
||||
SELECT TRIM(RDB$GENERATOR_NAME) AS OBJECT_NAME, 'SEQUENCE' AS OBJECT_TYPE, NULL AS OBJECT_COMMENT
|
||||
FROM RDB$GENERATORS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
|
||||
UNION ALL
|
||||
SELECT TRIM(RDB$TRIGGER_NAME), 'TRIGGER', TRIM(RDB$RELATION_NAME)
|
||||
FROM RDB$TRIGGERS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
|
||||
UNION ALL
|
||||
SELECT TRIM(RDB$INDEX_NAME), 'INDEX', TRIM(RDB$RELATION_NAME)
|
||||
FROM RDB$INDICES WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
|
||||
""".stripIndent().trim();
|
||||
|
||||
static String catalogObjectsSql(int majorVersion) {
|
||||
if (majorVersion < 3) {
|
||||
return COMMON_OBJECTS_SQL + " UNION ALL SELECT TRIM(RDB$FUNCTION_NAME), 'FUNCTION_UDF', TRIM(RDB$MODULE_NAME) FROM RDB$FUNCTIONS WHERE COALESCE(RDB$SYSTEM_FLAG,0)=0";
|
||||
}
|
||||
return COMMON_OBJECTS_SQL + " UNION ALL " + """
|
||||
SELECT TRIM(RDB$PACKAGE_NAME), 'PACKAGE', NULL
|
||||
FROM RDB$PACKAGES WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
|
||||
UNION ALL
|
||||
SELECT CASE WHEN RDB$PACKAGE_NAME IS NULL THEN TRIM(RDB$FUNCTION_NAME)
|
||||
ELSE TRIM(RDB$PACKAGE_NAME) || '.' || TRIM(RDB$FUNCTION_NAME) END,
|
||||
CASE WHEN NULLIF(TRIM(RDB$MODULE_NAME),'') IS NULL AND NULLIF(TRIM(RDB$ENGINE_NAME),'') IS NULL
|
||||
THEN 'FUNCTION_INTERNAL' ELSE 'FUNCTION_UDF' END,
|
||||
COALESCE(NULLIF(TRIM(RDB$MODULE_NAME), ''), NULLIF(TRIM(RDB$ENGINE_NAME), ''))
|
||||
FROM RDB$FUNCTIONS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
|
||||
""".stripIndent().trim();
|
||||
}
|
||||
private Charset dataCharset;
|
||||
|
||||
@Override
|
||||
public List<TriggerInfo> listTriggers(String schema, String table) {
|
||||
if (table == null || table.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String normalizedTable = table.trim();
|
||||
if (normalizedTable.startsWith("\"") && normalizedTable.endsWith("\"") && normalizedTable.length() > 1) {
|
||||
normalizedTable = normalizedTable.substring(1, normalizedTable.length() - 1);
|
||||
}
|
||||
final String searchTable = normalizedTable;
|
||||
return unchecked(() -> {
|
||||
List<TriggerInfo> triggers = new ArrayList<>();
|
||||
try (PreparedStatement statement = requireConnection().prepareStatement(
|
||||
"SELECT TRIM(RDB$TRIGGER_NAME), RDB$TRIGGER_TYPE FROM RDB$TRIGGERS WHERE (TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)) AND COALESCE(RDB$SYSTEM_FLAG,0)=0 ORDER BY RDB$TRIGGER_NAME")) {
|
||||
statement.setString(1, searchTable);
|
||||
statement.setString(2, searchTable);
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
while (result.next()) {
|
||||
int type = result.getInt(2);
|
||||
triggers.add(new TriggerInfo(result.getString(1), triggerEvent(type), type % 2 == 1 ? "BEFORE" : "AFTER"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return triggers;
|
||||
});
|
||||
}
|
||||
|
||||
static String triggerEvent(int type) {
|
||||
return switch (type) {
|
||||
case 1, 2 -> "INSERT";
|
||||
case 3, 4 -> "UPDATE";
|
||||
case 5, 6 -> "DELETE";
|
||||
case 17, 18 -> "INSERT OR UPDATE";
|
||||
case 25, 26 -> "INSERT OR DELETE";
|
||||
case 27, 28 -> "UPDATE OR DELETE";
|
||||
case 113, 114 -> "INSERT OR UPDATE OR DELETE";
|
||||
case 8192 -> "CONNECT";
|
||||
case 8193 -> "DISCONNECT";
|
||||
case 8194 -> "TRANSACTION START";
|
||||
case 8195 -> "TRANSACTION COMMIT";
|
||||
case 8196 -> "TRANSACTION ROLLBACK";
|
||||
default -> Integer.toString(type);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
String url = super.buildJdbcUrl(params);
|
||||
@@ -75,9 +151,44 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
|
||||
@Override
|
||||
public List<ObjectInfo> listObjects(String schema, MetadataListConstraints constraints) {
|
||||
List<ObjectInfo> objects = super.listObjects(schema, constraints);
|
||||
List<ObjectInfo> objects = new ArrayList<>(super.listObjects(schema, MetadataListConstraints.NONE));
|
||||
// JDBC metadata flattens all Firebird functions into FUNCTION and does
|
||||
// not expose generators, packages, database triggers, or indexes. Use
|
||||
// the Firebird catalogs so the object browser can keep those categories
|
||||
// distinct without changing the table-scoped metadata APIs.
|
||||
for (Iterator<ObjectInfo> iterator = objects.iterator(); iterator.hasNext();) {
|
||||
ObjectInfo object = iterator.next();
|
||||
if ("FUNCTION".equalsIgnoreCase(object.getObject_type())) iterator.remove();
|
||||
}
|
||||
unchecked(() -> {
|
||||
try (PreparedStatement statement = requireConnection().prepareStatement(catalogObjectsSql(requireConnection().getMetaData().getDatabaseMajorVersion()));
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
String name = trimToNull(resultSet.getString("OBJECT_NAME"));
|
||||
String type = trimToNull(resultSet.getString("OBJECT_TYPE"));
|
||||
if (name == null || type == null) continue;
|
||||
String comment = decodeLegacyText(trimToNull(resultSet.getString("OBJECT_COMMENT")));
|
||||
ObjectInfo object = new ObjectInfo(name, type, schema, comment);
|
||||
if ("INDEX".equals(type) || "TRIGGER".equals(type)) {
|
||||
object.setParent_name(comment);
|
||||
object.setComment(null);
|
||||
}
|
||||
objects.add(object);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
for (ObjectInfo object : objects) object.setComment(decodeLegacyText(object.getComment()));
|
||||
return objects;
|
||||
MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints);
|
||||
// Preserve the existing FUNCTION metadata request used by older clients
|
||||
// while exposing the two precise categories to the new object browser.
|
||||
if (normalized.getObjectTypes() != null && normalized.getObjectTypes().contains("FUNCTION")) {
|
||||
for (ObjectInfo object : objects) {
|
||||
if (object.getObject_type().startsWith("FUNCTION_")) object.setObject_type("FUNCTION");
|
||||
}
|
||||
}
|
||||
objects.sort(Comparator.comparing(ObjectInfo::getName).thenComparing(ObjectInfo::getObject_type));
|
||||
return normalized.filterObjects(objects);
|
||||
}
|
||||
static final String PROCEDURE_SOURCE_SQL = """
|
||||
SELECT
|
||||
@@ -137,6 +248,10 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
|
||||
@Override
|
||||
public ObjectSource getObjectSource(String schema, String name, String objectType) {
|
||||
String requestedType = normalizeObjectSourceType(objectType);
|
||||
if (!"PROCEDURE".equals(requestedType)) {
|
||||
return unchecked(() -> FirebirdObjectSource.read(requireConnection(), schema, name, requestedType, this::decodeLegacyText));
|
||||
}
|
||||
String normalizedType = normalizeObjectSourceType(objectType);
|
||||
return unchecked(() -> {
|
||||
String body = null;
|
||||
@@ -191,7 +306,7 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
throw new IllegalArgumentException("Unsupported object type: null");
|
||||
}
|
||||
String normalized = objectType.trim().toUpperCase(Locale.ROOT);
|
||||
if (!"PROCEDURE".equals(normalized)) {
|
||||
if (!List.of("PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "PACKAGE", "PACKAGE_BODY", "VIEW").contains(normalized)) {
|
||||
throw new IllegalArgumentException("Unsupported object type: " + objectType);
|
||||
}
|
||||
return normalized;
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/** Read-only catalog source: stored routine bodies are never advertised as executable DDL. */
|
||||
final class FirebirdObjectSource {
|
||||
static ObjectSource read(Connection connection, String schema, String name, String type,
|
||||
UnaryOperator<String> decode) throws SQLException {
|
||||
String targetName = name != null ? name.trim() : "";
|
||||
if (targetName.startsWith("\"") && targetName.endsWith("\"") && targetName.length() > 1) {
|
||||
targetName = targetName.substring(1, targetName.length() - 1);
|
||||
}
|
||||
String sql = switch (type) {
|
||||
case "FUNCTION" -> "SELECT RDB$FUNCTION_SOURCE, RDB$MODULE_NAME, RDB$ENTRYPOINT, RDB$ENGINE_NAME FROM RDB$FUNCTIONS WHERE CASE WHEN RDB$PACKAGE_NAME IS NULL THEN TRIM(RDB$FUNCTION_NAME) ELSE TRIM(RDB$PACKAGE_NAME) || '.' || TRIM(RDB$FUNCTION_NAME) END = ?";
|
||||
case "TRIGGER" -> "SELECT RDB$TRIGGER_SOURCE, TRIM(RDB$RELATION_NAME), RDB$TRIGGER_TYPE, RDB$TRIGGER_SEQUENCE, RDB$TRIGGER_INACTIVE FROM RDB$TRIGGERS WHERE (TRIM(RDB$TRIGGER_NAME) = ? OR TRIM(RDB$TRIGGER_NAME) = UPPER(?))";
|
||||
case "PACKAGE", "PACKAGE_BODY" -> "SELECT RDB$PACKAGE_HEADER_SOURCE, RDB$PACKAGE_BODY_SOURCE FROM RDB$PACKAGES WHERE (TRIM(RDB$PACKAGE_NAME) = ? OR TRIM(RDB$PACKAGE_NAME) = UPPER(?))";
|
||||
case "SEQUENCE" -> "SELECT RDB$GENERATOR_NAME, RDB$INITIAL_VALUE, RDB$GENERATOR_INCREMENT FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = ?";
|
||||
case "VIEW" -> "SELECT RDB$VIEW_SOURCE FROM RDB$RELATIONS WHERE (TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)) AND RDB$VIEW_BLR IS NOT NULL";
|
||||
default -> throw new IllegalArgumentException("Unsupported Firebird object type: " + type);
|
||||
};
|
||||
boolean legacy = connection.getMetaData().getDatabaseMajorVersion() < 3;
|
||||
if (legacy && "FUNCTION".equals(type)) {
|
||||
sql = "SELECT CAST(NULL AS VARCHAR(1)), RDB$MODULE_NAME, RDB$ENTRYPOINT, CAST(NULL AS VARCHAR(1)) FROM RDB$FUNCTIONS WHERE (TRIM(RDB$FUNCTION_NAME) = ? OR TRIM(RDB$FUNCTION_NAME) = UPPER(?))";
|
||||
} else if (legacy && "SEQUENCE".equals(type)) {
|
||||
sql = "SELECT RDB$GENERATOR_NAME, 0, 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = ?";
|
||||
}
|
||||
String source = "";
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, targetName);
|
||||
if (sql.contains("UPPER(?)")) {
|
||||
statement.setString(2, targetName);
|
||||
}
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
if (result.next()) {
|
||||
String quoted = "\"" + targetName.replace("\"", "\"\"") + "\"";
|
||||
source = switch (type) {
|
||||
case "FUNCTION" -> functionSource(result, decode);
|
||||
case "TRIGGER" -> triggerSource(result, targetName, decode);
|
||||
case "PACKAGE", "PACKAGE_BODY" -> packageSource(result, quoted, decode);
|
||||
case "SEQUENCE" -> legacy ? "-- Generator: " + quoted
|
||||
: "CREATE SEQUENCE " + quoted + " START WITH " + result.getLong(2)
|
||||
+ " INCREMENT BY " + result.getLong(3) + ";";
|
||||
case "VIEW" -> "-- Stored view query\n" + text(decode.apply(result.getString(1)));
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ObjectSource(targetName, type, schema, source, false);
|
||||
}
|
||||
|
||||
private static String triggerSource(ResultSet result, String name, UnaryOperator<String> decode) throws SQLException {
|
||||
String relation = result.getString(2);
|
||||
int type = result.getInt(3);
|
||||
int position = result.getInt(4);
|
||||
int inactive = result.getInt(5);
|
||||
String body = text(decode.apply(result.getString(1)));
|
||||
String rel = relation != null ? relation.trim() : "";
|
||||
String trimmedBody = body.stripLeading();
|
||||
if ((trimmedBody.regionMatches(true, 0, "CREATE", 0, 6) && (trimmedBody.length() == 6 || Character.isWhitespace(trimmedBody.charAt(6))))
|
||||
|| (trimmedBody.regionMatches(true, 0, "ALTER", 0, 5) && (trimmedBody.length() == 5 || Character.isWhitespace(trimmedBody.charAt(5))))) {
|
||||
return body;
|
||||
}
|
||||
String quoted = "\"" + name.replace("\"", "\"\"") + "\"";
|
||||
StringBuilder ddl = new StringBuilder("CREATE OR ALTER TRIGGER ").append(quoted);
|
||||
if (!rel.isEmpty()) {
|
||||
ddl.append(" FOR \"").append(rel.replace("\"", "\"\"")).append("\"");
|
||||
}
|
||||
ddl.append(inactive == 1 ? " INACTIVE\n" : " ACTIVE\n");
|
||||
if (type >= 8192) {
|
||||
ddl.append("ON ").append(FirebirdAgent.triggerEvent(type));
|
||||
} else {
|
||||
String timing = (type % 2 == 1) ? "BEFORE" : "AFTER";
|
||||
String event = FirebirdAgent.triggerEvent(type);
|
||||
ddl.append(timing).append(" ").append(event);
|
||||
}
|
||||
if (position > 0) {
|
||||
ddl.append(" POSITION ").append(position);
|
||||
}
|
||||
ddl.append("\n");
|
||||
if (trimmedBody.regionMatches(true, 0, "AS", 0, 2) && (trimmedBody.length() == 2 || Character.isWhitespace(trimmedBody.charAt(2)))) {
|
||||
ddl.append(trimmedBody);
|
||||
} else if (!trimmedBody.isEmpty()) {
|
||||
ddl.append("AS\n").append(trimmedBody);
|
||||
} else {
|
||||
ddl.append("AS\nBEGIN\nEND");
|
||||
}
|
||||
return ddl.toString().stripTrailing();
|
||||
}
|
||||
|
||||
private static String functionSource(ResultSet result, UnaryOperator<String> decode) throws SQLException {
|
||||
String body = decode.apply(result.getString(1));
|
||||
if (body != null && !body.isBlank()) return "-- Stored function body\n" + body;
|
||||
if (text(result.getString(2)).isBlank() && text(result.getString(4)).isBlank()) {
|
||||
return "-- No stored function body is available.";
|
||||
}
|
||||
return "-- External function\n-- Module: " + text(result.getString(2))
|
||||
+ "\n-- Entry point: " + text(result.getString(3))
|
||||
+ "\n-- Engine: " + text(result.getString(4));
|
||||
}
|
||||
|
||||
private static String packageSource(ResultSet result, String name, UnaryOperator<String> decode) throws SQLException {
|
||||
String header = decode.apply(result.getString(1));
|
||||
String body = decode.apply(result.getString(2));
|
||||
return "CREATE OR ALTER PACKAGE " + name + " AS\n" + text(header)
|
||||
+ (body == null ? "" : "\n\nCREATE OR ALTER PACKAGE BODY " + name + " AS\n" + body);
|
||||
}
|
||||
|
||||
private static String text(String value) {
|
||||
return value == null ? "" : value.stripTrailing();
|
||||
}
|
||||
}
|
||||
+39
-2
@@ -1,6 +1,7 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
@@ -333,10 +334,46 @@ class FirebirdAgentTest {
|
||||
|
||||
IllegalArgumentException error = Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> agent.getObjectSource(null, "P_6141", "VIEW")
|
||||
() -> agent.getObjectSource(null, "P_6141", "UNKNOWN")
|
||||
);
|
||||
|
||||
Assertions.assertEquals("Unsupported object type: VIEW", error.getMessage());
|
||||
Assertions.assertEquals("Unsupported object type: UNKNOWN", error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listTriggersNormalizesTableNameAndMapsTriggerTypes() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
List<String> parameters = new ArrayList<>();
|
||||
FirebirdAgent agent = agentWithRows(
|
||||
sql,
|
||||
parameters,
|
||||
row(
|
||||
"1", "TR_ORDERS_BI",
|
||||
"2", 1
|
||||
),
|
||||
row(
|
||||
"1", "TR_ORDERS_AD",
|
||||
"2", 6
|
||||
)
|
||||
);
|
||||
|
||||
List<TriggerInfo> triggers = agent.listTriggers(null, "\"orders\"");
|
||||
Assertions.assertEquals(2, triggers.size());
|
||||
Assertions.assertEquals("TR_ORDERS_BI", triggers.get(0).getName());
|
||||
Assertions.assertEquals("INSERT", triggers.get(0).getEvent());
|
||||
Assertions.assertEquals("BEFORE", triggers.get(0).getTiming());
|
||||
Assertions.assertEquals("TR_ORDERS_AD", triggers.get(1).getName());
|
||||
Assertions.assertEquals("DELETE", triggers.get(1).getEvent());
|
||||
Assertions.assertEquals("AFTER", triggers.get(1).getTiming());
|
||||
Assertions.assertEquals(List.of("1=orders", "2=orders"), parameters);
|
||||
Assertions.assertTrue(sql.get(0).contains("TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listTriggersReturnsEmptyForNullOrBlankTable() {
|
||||
FirebirdAgent agent = new FirebirdAgent();
|
||||
Assertions.assertTrue(agent.listTriggers(null, null).isEmpty());
|
||||
Assertions.assertTrue(agent.listTriggers(null, " ").isEmpty());
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FirebirdObjectSourceTest {
|
||||
@Test
|
||||
void generatorSourceNeverReadsOrIncrementsCurrentValue() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ObjectSource source = FirebirdObjectSource.read(connection(calls, "G\"ID", 10L, 5L), "", "G\"ID", "SEQUENCE", value -> value);
|
||||
assertEquals("CREATE SEQUENCE \"G\"\"ID\" START WITH 10 INCREMENT BY 5;", source.getSource());
|
||||
assertFalse(source.isEditable());
|
||||
assertEquals("G\"ID", calls.get(1));
|
||||
assertFalse(calls.get(0).contains("GEN_ID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void externalFunctionsShowModuleEntrypointAndEngineWithoutCallingUdf() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ObjectSource source = FirebirdObjectSource.read(connection(calls, null, "FreeAdhocUDF", "f_abs", null), "", "F_ABS", "FUNCTION", value -> value);
|
||||
assertTrue(source.getSource().contains("Module: FreeAdhocUDF"));
|
||||
assertTrue(source.getSource().contains("Entry point: f_abs"));
|
||||
assertTrue(calls.get(0).startsWith("SELECT "));
|
||||
assertFalse(source.isEditable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagedFunctionsUseQualifiedCatalogIdentityAndDecodeOnlyBody() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ObjectSource source = FirebirdObjectSource.read(connection(calls, "²Ý¸å", null, null, null), "", "MYUDR2.FN", "FUNCTION",
|
||||
value -> LegacyTextDecoder.decode(value, java.nio.charset.Charset.forName("GBK")));
|
||||
assertEquals("MYUDR2.FN", calls.get(1));
|
||||
assertTrue(calls.get(0).contains("RDB$PACKAGE_NAME"));
|
||||
assertTrue(source.getSource().contains("草稿"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableTriggerSourceBuildsStandardCreateOrAlterTriggerDdl() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ObjectSource source = FirebirdObjectSource.read(
|
||||
connection(calls, "AS\nBEGIN\n NEW.ID = 1;\nEND", "ORDERS", 1, 0, 0),
|
||||
"", "TR_ORDERS_BI", "TRIGGER", value -> value
|
||||
);
|
||||
assertEquals(
|
||||
"CREATE OR ALTER TRIGGER \"TR_ORDERS_BI\" FOR \"ORDERS\" ACTIVE\nBEFORE INSERT\nAS\nBEGIN\n NEW.ID = 1;\nEND",
|
||||
source.getSource()
|
||||
);
|
||||
assertEquals("TR_ORDERS_BI", source.getName());
|
||||
assertEquals("TRIGGER", source.getObject_type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void databaseTriggerSourceFormatsEventAndTiming() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ObjectSource source = FirebirdObjectSource.read(
|
||||
connection(calls, "BEGIN\n EXECUTE PROCEDURE LOG_CONN;\nEND", null, 8192, 1, 1),
|
||||
"", "TR_CONN", "TRIGGER", value -> value
|
||||
);
|
||||
assertEquals(
|
||||
"CREATE OR ALTER TRIGGER \"TR_CONN\" INACTIVE\nON CONNECT POSITION 1\nAS\nBEGIN\n EXECUTE PROCEDURE LOG_CONN;\nEND",
|
||||
source.getSource()
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection connection(List<String> calls, Object... values) {
|
||||
ResultSet result = (ResultSet) Proxy.newProxyInstance(ResultSet.class.getClassLoader(), new Class<?>[] {ResultSet.class},
|
||||
new java.lang.reflect.InvocationHandler() {
|
||||
boolean first = true;
|
||||
public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
|
||||
return switch (method.getName()) {
|
||||
case "next" -> { boolean value = first; first = false; yield value; }
|
||||
case "getString" -> { Object value = values[(Integer) args[0] - 1]; yield value == null ? null : value.toString(); }
|
||||
case "getInt" -> ((Number) values[(Integer) args[0] - 1]).intValue();
|
||||
case "getLong" -> ((Number) values[(Integer) args[0] - 1]).longValue();
|
||||
case "close" -> null;
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
};
|
||||
}
|
||||
});
|
||||
PreparedStatement statement = (PreparedStatement) Proxy.newProxyInstance(PreparedStatement.class.getClassLoader(), new Class<?>[] {PreparedStatement.class},
|
||||
(proxy, method, args) -> switch (method.getName()) {
|
||||
case "setString" -> { calls.add((String) args[1]); yield null; }
|
||||
case "executeQuery" -> result;
|
||||
case "close" -> null;
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[] {Connection.class},
|
||||
(proxy, method, args) -> {
|
||||
if (method.getName().equals("getMetaData")) {
|
||||
return Proxy.newProxyInstance(java.sql.DatabaseMetaData.class.getClassLoader(), new Class<?>[] {java.sql.DatabaseMetaData.class},
|
||||
(metadata, operation, arguments) -> {
|
||||
if (operation.getName().equals("getDatabaseMajorVersion")) return 5;
|
||||
throw new UnsupportedOperationException(operation.getName());
|
||||
});
|
||||
}
|
||||
if (method.getName().equals("prepareStatement")) { calls.add((String) args[0]); return statement; }
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.highgo:HgdbJdbc:6.2.4'
|
||||
implementation 'com.highgo:HgdbJdbc:6.2.5'
|
||||
// PostGIS JDBC is optional: EwkbWktDecoder reflectively probes for
|
||||
// org.postgis.PGgeometry / net.postgis.jdbc.PGgeometry and falls back to
|
||||
// its own EWKB parser when the class is absent. compileOnly keeps the
|
||||
|
||||
@@ -436,6 +436,7 @@ func parseHiveAssignments(raw string, lowercaseKeys bool) map[string]string {
|
||||
if decoded, err := url.QueryUnescape(key); err == nil {
|
||||
key = decoded
|
||||
}
|
||||
key = strings.TrimLeft(strings.TrimSpace(key), "?#")
|
||||
if lowercaseKeys {
|
||||
key = strings.ToLower(key)
|
||||
}
|
||||
@@ -691,22 +692,27 @@ func applyHiveParameters(config *connectionConfig, values, hiveConfs map[string]
|
||||
func applyOpenSessionVariables(config *connectionConfig, values, hiveConfs, hiveVars map[string]string) {
|
||||
config.HiveConfiguration["set:hiveconf:"+resultSetUniqueColumnNames] = "false"
|
||||
for key, value := range values {
|
||||
lowerKey := strings.ToLower(key)
|
||||
cleanKey := strings.TrimPrefix(key, "#")
|
||||
lowerKey := strings.ToLower(cleanKey)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerKey, "hiveconf:"):
|
||||
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(key[len("hiveconf:"):])] = value
|
||||
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(cleanKey[len("hiveconf:"):])] = value
|
||||
case strings.HasPrefix(lowerKey, "hivevar:"):
|
||||
config.HiveConfiguration["set:hivevar:"+key[len("hivevar:"):]] = value
|
||||
config.HiveConfiguration["set:hivevar:"+cleanKey[len("hivevar:"):]] = value
|
||||
case strings.HasPrefix(lowerKey, "kyuubi."):
|
||||
config.HiveConfiguration["set:hivevar:"+cleanKey] = value
|
||||
}
|
||||
}
|
||||
for key, value := range hiveConfs {
|
||||
if strings.EqualFold(key, "hive.server2.transport.mode") || strings.EqualFold(key, "hive.server2.thrift.http.path") {
|
||||
cleanKey := strings.TrimPrefix(key, "#")
|
||||
if strings.EqualFold(cleanKey, "hive.server2.transport.mode") || strings.EqualFold(cleanKey, "hive.server2.thrift.http.path") {
|
||||
continue
|
||||
}
|
||||
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(key)] = value
|
||||
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(cleanKey)] = value
|
||||
}
|
||||
for key, value := range hiveVars {
|
||||
config.HiveConfiguration["set:hivevar:"+key] = value
|
||||
cleanKey := strings.TrimPrefix(key, "#")
|
||||
config.HiveConfiguration["set:hivevar:"+cleanKey] = value
|
||||
}
|
||||
if proxyUser := firstNonEmpty(parameter(values, "proxyuser"), parameter(values, "hive.server2.proxy.user")); proxyUser != "" {
|
||||
config.HiveConfiguration["hive.server2.proxy.user"] = proxyUser
|
||||
@@ -753,11 +759,14 @@ func openSessionConfigurationValue(values map[string]string, key string) string
|
||||
// Hive JDBC-style URL fragments are sent as hive variables, while values
|
||||
// after '?' are sent as hive confs. Kyuubi strips either prefix before it
|
||||
// resolves session-scoped settings such as kyuubi.engine.type.
|
||||
for _, wanted := range []string{"set:hivevar:" + key, "set:hiveconf:" + key, key} {
|
||||
for candidate, value := range values {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), wanted) {
|
||||
return value
|
||||
}
|
||||
normalizedKey := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(key), "#"))
|
||||
for candidate, value := range values {
|
||||
candidateKey := strings.ToLower(strings.TrimSpace(candidate))
|
||||
candidateKey = strings.TrimPrefix(candidateKey, "set:hivevar:")
|
||||
candidateKey = strings.TrimPrefix(candidateKey, "set:hiveconf:")
|
||||
candidateKey = strings.TrimPrefix(candidateKey, "#")
|
||||
if candidateKey == normalizedKey {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -432,6 +432,36 @@ func TestKyuubiIdentifierQuoteFollowsPreservedEngineType(t *testing.T) {
|
||||
configuration: "set:hiveconf:kyuubi.engine.type",
|
||||
identifierQuote: trinoIdentifierQuote,
|
||||
},
|
||||
{
|
||||
name: "URL param without hash selects Trino",
|
||||
params: connectParams{
|
||||
Host: "kyuubi.example.com",
|
||||
DatabaseType: "kyuubi",
|
||||
URLParams: "kyuubi.engine.type=TRINO",
|
||||
},
|
||||
configuration: "set:hivevar:kyuubi.engine.type",
|
||||
identifierQuote: trinoIdentifierQuote,
|
||||
},
|
||||
{
|
||||
name: "Semicolon with hash selects Trino",
|
||||
params: connectParams{
|
||||
Host: "kyuubi.example.com",
|
||||
DatabaseType: "kyuubi",
|
||||
URLParams: ";#kyuubi.engine.type=trino",
|
||||
},
|
||||
configuration: "set:hivevar:kyuubi.engine.type",
|
||||
identifierQuote: trinoIdentifierQuote,
|
||||
},
|
||||
{
|
||||
name: "Session params with auth selects Trino",
|
||||
params: connectParams{
|
||||
Host: "kyuubi.example.com",
|
||||
DatabaseType: "kyuubi",
|
||||
URLParams: "auth=NOSASL;kyuubi.engine.type=TRINO",
|
||||
},
|
||||
configuration: "set:hivevar:kyuubi.engine.type",
|
||||
identifierQuote: trinoIdentifierQuote,
|
||||
},
|
||||
{
|
||||
name: "Kyuubi Spark keeps Hive quote",
|
||||
params: connectParams{
|
||||
|
||||
@@ -90,6 +90,7 @@ func newDiscoveryConnector(config connectionConfig) *discoveryConnector {
|
||||
BrowserResponseTimeout: config.BrowserResponseTimeout,
|
||||
BrowserDisableSSLCheck: config.BrowserDisableSSLCheck,
|
||||
WaitForNonQueryCompletion: waitsForNonQueryCompletion(config.DatabaseType),
|
||||
IdentifierQuote: config.identifierQuote(),
|
||||
})
|
||||
},
|
||||
driver: &gohive.Driver{},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.ibm.informix:jdbc:4.50.10'
|
||||
implementation 'com.ibm.informix:jdbc:4.50.14'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
@@ -8,7 +8,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.apache.iotdb:iotdb-jdbc:2.0.8'
|
||||
implementation 'org.apache.iotdb:iotdb-jdbc:2.0.11'
|
||||
implementation 'org.slf4j:slf4j-nop:2.0.16'
|
||||
}
|
||||
|
||||
|
||||
@@ -137,10 +137,62 @@ public final class IrisAgent extends ConfiguredJdbcAgent {
|
||||
if (!hasPrimaryKey(columns)) {
|
||||
markPrimaryKeys(columns, irisPrimaryKeys(conn, schema, table));
|
||||
}
|
||||
markIdentityColumns(columns, irisIdentityColumns(conn, schema, table));
|
||||
return columns;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* IRIS RowId / IDENTITY columns are system-assigned: their values cannot be
|
||||
* user-specified on INSERT, so the grid must omit them when saving new rows
|
||||
* (mirrors how the Dameng agent reports IDENTITY columns). IRIS exposes this
|
||||
* through INFORMATION_SCHEMA.COLUMNS extension columns (IS_ROWID / IS_IDENTITY,
|
||||
* not part of the SQL standard). The probe is defensive on purpose: when a
|
||||
* server build lacks the extension columns the query fails and column listing
|
||||
* keeps working exactly as before.
|
||||
*/
|
||||
private static Set<String> irisIdentityColumns(Connection conn, String schema, String table) {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
try (PreparedStatement stmt = conn.prepareStatement(identityColumnSql(schema))) {
|
||||
int index = 1;
|
||||
if (!isBlank(schema)) {
|
||||
stmt.setString(index++, schema.trim());
|
||||
}
|
||||
stmt.setString(index, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
addPrimaryKeys(names, rs);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Identity metadata is optional; older builds report it through
|
||||
// neither extension column and must not break column listing.
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private static String identityColumnSql(String schema) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT COLUMN_NAME "
|
||||
+ "FROM INFORMATION_SCHEMA.COLUMNS WHERE "
|
||||
+ "(UPPER(IS_ROWID) = 'YES' OR UPPER(IS_IDENTITY) = 'YES') AND "
|
||||
);
|
||||
if (!isBlank(schema)) {
|
||||
sql.append("UPPER(TABLE_SCHEMA) = UPPER(?) AND ");
|
||||
}
|
||||
sql.append("UPPER(TABLE_NAME) = UPPER(?)");
|
||||
return sql.toString();
|
||||
}
|
||||
|
||||
private static void markIdentityColumns(List<ColumnInfo> columns, Set<String> identityColumns) {
|
||||
if (identityColumns.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (ColumnInfo column : columns) {
|
||||
if (identityColumns.contains(column.getName().toUpperCase(Locale.ROOT))) {
|
||||
column.setExtra("identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> dedupeCaseInsensitiveSchemas(List<String> schemas) {
|
||||
Map<String, String> byNormalizedName = new LinkedHashMap<>();
|
||||
for (String schema : schemas) {
|
||||
|
||||
@@ -219,7 +219,7 @@ class IrisAgentTest {
|
||||
assertNull(columns.get(1).getNumeric_precision());
|
||||
assertNull(columns.get(1).getNumeric_scale());
|
||||
assertEquals(Integer.valueOf(64), columns.get(1).getCharacter_maximum_length());
|
||||
assertEquals(1, calls.size());
|
||||
assertEquals(2, calls.size());
|
||||
assertFalse(calls.get(0).contains("LIMIT"));
|
||||
assertTrue(calls.get(0).contains("PRIMARY_KEY"));
|
||||
assertTrue(calls.get(0).contains("DESCRIPTION"));
|
||||
@@ -242,9 +242,55 @@ class IrisAgentTest {
|
||||
|
||||
assertEquals("identifier", columns.get(0).getComment());
|
||||
assertNull(columns.get(1).getComment());
|
||||
assertEquals(1, calls.size());
|
||||
assertEquals(2, calls.size());
|
||||
assertTrue(calls.get(0).contains("DESCRIPTION"));
|
||||
assertFalse(calls.get(0).contains("TABLE_SCHEMA"));
|
||||
assertTrue(calls.get(1).contains("IS_ROWID"));
|
||||
assertFalse(calls.get(1).contains("TABLE_SCHEMA"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void marksRowIdColumnsAsIdentityExtra() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Connection conn = fakeConnection(
|
||||
calls,
|
||||
true,
|
||||
Collections.emptyList(),
|
||||
null,
|
||||
Collections.emptyList(),
|
||||
List.of(Map.of("COLUMN_NAME", "ID")),
|
||||
null
|
||||
);
|
||||
|
||||
List<ColumnInfo> columns = IrisAgent.irisColumns(conn, "SQLUser", "People");
|
||||
|
||||
assertEquals("identity", columns.get(0).getExtra());
|
||||
assertNull(columns.get(1).getExtra());
|
||||
assertEquals(2, calls.size());
|
||||
assertTrue(calls.get(1).contains("IS_ROWID"));
|
||||
assertTrue(calls.get(1).contains("IS_IDENTITY"));
|
||||
assertTrue(calls.get(1).contains("UPPER(TABLE_SCHEMA) = UPPER(?)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsColumnListingWhenIdentityColumnsAreUnavailable() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Connection conn = fakeConnection(
|
||||
calls,
|
||||
true,
|
||||
Collections.emptyList(),
|
||||
null,
|
||||
Collections.emptyList(),
|
||||
null,
|
||||
new SQLException("Field 'IS_ROWID' not found")
|
||||
);
|
||||
|
||||
List<ColumnInfo> columns = IrisAgent.irisColumns(conn, "SQLUser", "People");
|
||||
|
||||
assertEquals(2, columns.size());
|
||||
assertNull(columns.get(0).getExtra());
|
||||
assertNull(columns.get(1).getExtra());
|
||||
assertEquals(2, calls.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -261,9 +307,10 @@ class IrisAgentTest {
|
||||
List<ColumnInfo> columns = IrisAgent.irisColumns(conn, "SQLUser", "People");
|
||||
|
||||
assertTrue(columns.get(0).getIs_primary_key());
|
||||
assertEquals(2, calls.size());
|
||||
assertEquals(3, calls.size());
|
||||
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
|
||||
assertEquals("JDBC_PRIMARY_KEYS", calls.get(1));
|
||||
assertTrue(calls.get(2).contains("IS_ROWID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -280,9 +327,11 @@ class IrisAgentTest {
|
||||
List<ColumnInfo> columns = IrisAgent.irisColumns(conn, "SQLUser", "People");
|
||||
|
||||
assertTrue(columns.get(0).getIs_primary_key());
|
||||
assertEquals(4, calls.size());
|
||||
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
|
||||
assertEquals("JDBC_PRIMARY_KEYS", calls.get(1));
|
||||
assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE"));
|
||||
assertTrue(calls.get(3).contains("IS_ROWID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -300,7 +349,9 @@ class IrisAgentTest {
|
||||
|
||||
assertFalse(columns.get(0).getIs_primary_key());
|
||||
assertFalse(columns.get(1).getIs_primary_key());
|
||||
assertEquals(4, calls.size());
|
||||
assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE"));
|
||||
assertTrue(calls.get(3).contains("IS_ROWID"));
|
||||
}
|
||||
|
||||
private static Connection fakeConnection(
|
||||
@@ -309,12 +360,26 @@ class IrisAgentTest {
|
||||
List<Map<String, Object>> jdbcPrimaryKeys,
|
||||
SQLException jdbcPrimaryKeyFailure,
|
||||
List<Map<String, Object>> nativePrimaryKeys
|
||||
) {
|
||||
return fakeConnection(
|
||||
callLog, inlinePrimaryKey, jdbcPrimaryKeys, jdbcPrimaryKeyFailure, nativePrimaryKeys, null, null
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection fakeConnection(
|
||||
List<String> callLog,
|
||||
boolean inlinePrimaryKey,
|
||||
List<Map<String, Object>> jdbcPrimaryKeys,
|
||||
SQLException jdbcPrimaryKeyFailure,
|
||||
List<Map<String, Object>> nativePrimaryKeys,
|
||||
List<Map<String, Object>> identityColumns,
|
||||
SQLException identityQueryFailure
|
||||
) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
String sql = (String) args[0];
|
||||
callLog.add(sql);
|
||||
return fakeStatement(sql, inlinePrimaryKey, nativePrimaryKeys);
|
||||
return fakeStatement(sql, inlinePrimaryKey, nativePrimaryKeys, identityColumns, identityQueryFailure);
|
||||
}
|
||||
if ("getMetaData".equals(method.getName())) {
|
||||
return fakeMetadata(callLog, jdbcPrimaryKeys, jdbcPrimaryKeyFailure);
|
||||
@@ -349,10 +414,18 @@ class IrisAgentTest {
|
||||
private static PreparedStatement fakeStatement(
|
||||
String sql,
|
||||
boolean inlinePrimaryKey,
|
||||
List<Map<String, Object>> nativePrimaryKeys
|
||||
List<Map<String, Object>> nativePrimaryKeys,
|
||||
List<Map<String, Object>> identityColumns,
|
||||
SQLException identityQueryFailure
|
||||
) {
|
||||
return proxy(PreparedStatement.class, (method, args) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
if (sql.contains("IS_ROWID")) {
|
||||
if (identityQueryFailure != null) {
|
||||
throw identityQueryFailure;
|
||||
}
|
||||
return fakeResultSet(identityColumns == null ? Collections.emptyList() : identityColumns);
|
||||
}
|
||||
if (sql.contains("KEY_COLUMN_USAGE")) {
|
||||
return fakeResultSet(nativePrimaryKeys);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.12.1'
|
||||
implementation 'org.apache.kafka:kafka-clients:3.9.1'
|
||||
implementation 'org.apache.kafka:kafka-clients:3.9.2'
|
||||
implementation('org.apache.zookeeper:zookeeper:3.8.4') {
|
||||
exclude group: 'log4j', module: 'log4j'
|
||||
exclude group: 'org.slf4j', module: 'slf4j-log4j12'
|
||||
|
||||
@@ -10,6 +10,42 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKingbaseQueryMessagesIntegration(t *testing.T) {
|
||||
host := os.Getenv("KINGBASE_TEST_HOST")
|
||||
portText := os.Getenv("KINGBASE_TEST_PORT")
|
||||
username := os.Getenv("KINGBASE_TEST_USERNAME")
|
||||
password := os.Getenv("KINGBASE_TEST_PASSWORD")
|
||||
if host == "" || portText == "" || username == "" || password == "" {
|
||||
t.Skip("Kingbase integration environment is not configured")
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database := os.Getenv("KINGBASE_TEST_DATABASE")
|
||||
if database == "" {
|
||||
database = "test"
|
||||
}
|
||||
server := newServer()
|
||||
if err := server.connect(connectParams{Host: host, Port: port, Database: database, Username: username, Password: password}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = server.disconnect() })
|
||||
sql := "DO $$ BEGIN RAISE NOTICE '当前用户: %, 时间: %', current_user, now(); END $$"
|
||||
result, err := server.executeQuery(queryOptions{SQL: sql})
|
||||
if err != nil || len(result.Messages) != 1 || !strings.Contains(result.Messages[0].Message, "当前用户:") || result.Messages[0].Code != "00000" {
|
||||
t.Fatalf("query notice missing: %+v, err=%v", result.Messages, err)
|
||||
}
|
||||
page, err := server.executeQueryPage(queryOptions{SQL: sql}, 10)
|
||||
if err != nil || len(page.Messages) != 1 {
|
||||
t.Fatalf("paged command lost notices: %+v, err=%v", page.Messages, err)
|
||||
}
|
||||
result, err = server.executeQuery(queryOptions{SQL: "SELECT 1"})
|
||||
if err != nil || len(result.Messages) != 0 {
|
||||
t.Fatalf("previous command notices leaked: %+v, err=%v", result.Messages, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKingbaseIntegration(t *testing.T) {
|
||||
host := os.Getenv("KINGBASE_TEST_HOST")
|
||||
portText := os.Getenv("KINGBASE_TEST_PORT")
|
||||
|
||||
@@ -624,12 +624,16 @@ type customTypeCatalogQueries struct {
|
||||
// for built-in pg_catalog types.
|
||||
func qualifiedCatalogTypeExpression(typeAlias, namespaceAlias, elementAlias, elementNamespaceAlias, oidExpression, typmodExpression string) string {
|
||||
return fmt.Sprintf(`CASE
|
||||
WHEN %s.typelem <> 0 AND %s.nspname <> 'pg_catalog'
|
||||
WHEN %s.typelem <> 0 AND %s.nspname NOT IN ('pg_catalog', 'sys_catalog')
|
||||
THEN quote_ident(%s.nspname) || '.' || quote_ident(%s.typname) || '[]'
|
||||
WHEN %s.nspname <> 'pg_catalog'
|
||||
WHEN %s.nspname NOT IN ('pg_catalog', 'sys_catalog')
|
||||
THEN quote_ident(%s.nspname) || '.' || quote_ident(%s.typname)
|
||||
WHEN %s.typname = 'json'
|
||||
THEN 'json'
|
||||
WHEN %s.typname = '_json'
|
||||
THEN 'json[]'
|
||||
ELSE format_type(%s, %s)
|
||||
END`, typeAlias, elementNamespaceAlias, elementNamespaceAlias, elementAlias, namespaceAlias, namespaceAlias, typeAlias, oidExpression, typmodExpression)
|
||||
END`, typeAlias, elementNamespaceAlias, elementNamespaceAlias, elementAlias, namespaceAlias, namespaceAlias, typeAlias, typeAlias, typeAlias, oidExpression, typmodExpression)
|
||||
}
|
||||
|
||||
func customTypeCatalogQueriesFor(catalog, prefix, schema, name string) customTypeCatalogQueries {
|
||||
@@ -1893,7 +1897,12 @@ func (s *server) queryCatalogColumns(
|
||||
visibilityFunction := kingbaseCatalogFunction(catalog, "sys_table_is_visible", "pg_table_is_visible")
|
||||
relationPredicate = fmt.Sprintf("c.relname = %s AND %s(c.oid)", quoteLiteral(table), visibilityFunction)
|
||||
}
|
||||
query := fmt.Sprintf(`SELECT n.nspname, a.attname, format_type(a.atttypid, a.atttypmod), NOT a.attnotnull,
|
||||
query := fmt.Sprintf(`SELECT n.nspname, a.attname,
|
||||
CASE
|
||||
WHEN t.typname = 'json' THEN 'json'
|
||||
WHEN t.typname = '_json' THEN 'json[]'
|
||||
ELSE format_type(a.atttypid, a.atttypmod)
|
||||
END, NOT a.attnotnull,
|
||||
%s(ad.adbin, ad.adrelid), col_description(a.attrelid, a.attnum),
|
||||
CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 THEN ((a.atttypmod - 4) >> 16) & 65535 END,
|
||||
CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 THEN (a.atttypmod - 4) & 65535 END,
|
||||
@@ -2738,6 +2747,14 @@ func (s *server) getTableDDL(schema, table string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
if visibleColumns, fallbackErr := s.getColumns("", table); fallbackErr == nil && len(visibleColumns) > 0 {
|
||||
columns = visibleColumns
|
||||
if columns[0].ResolvedSchema != nil && *columns[0].ResolvedSchema != "" {
|
||||
effective = *columns[0].ResolvedSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
tableComment, _ := s.getTableComment(effective, table)
|
||||
ddl := s.renderTableDDL(effective, table, columns, tableComment)
|
||||
ddl, err = s.appendTableIndexDDL(effective, table, ddl)
|
||||
|
||||
@@ -100,33 +100,37 @@ type completionAssistantResponse struct {
|
||||
}
|
||||
|
||||
type queryResult struct {
|
||||
Columns []string `json:"columns"`
|
||||
ColumnTypes []string `json:"column_types"`
|
||||
Rows [][]any `json:"rows"`
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
ExecutionTimeMS int64 `json:"execution_time_ms"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Columns []string `json:"columns"`
|
||||
ColumnTypes []string `json:"column_types"`
|
||||
Rows [][]any `json:"rows"`
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
ExecutionTimeMS int64 `json:"execution_time_ms"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Messages []queryMessage `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
type queryPageResult struct {
|
||||
Columns []string `json:"columns"`
|
||||
ColumnTypes []string `json:"column_types"`
|
||||
Rows [][]any `json:"rows"`
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
ExecutionTimeMS int64 `json:"execution_time_ms"`
|
||||
Truncated bool `json:"truncated"`
|
||||
SessionID *string `json:"session_id"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Columns []string `json:"columns"`
|
||||
ColumnTypes []string `json:"column_types"`
|
||||
Rows [][]any `json:"rows"`
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
ExecutionTimeMS int64 `json:"execution_time_ms"`
|
||||
Truncated bool `json:"truncated"`
|
||||
SessionID *string `json:"session_id"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Messages []queryMessage `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
type querySession struct {
|
||||
rows *sql.Rows
|
||||
conn *sql.Conn
|
||||
columns []string
|
||||
columnTypes []string
|
||||
pending []any
|
||||
remaining int
|
||||
cancel context.CancelFunc
|
||||
rows *sql.Rows
|
||||
conn *sql.Conn
|
||||
columns []string
|
||||
columnTypes []string
|
||||
pending []any
|
||||
remaining int
|
||||
messages *queryMessageBuffer
|
||||
restoreMessages func()
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type server struct {
|
||||
@@ -750,16 +754,18 @@ func (s *server) cancelActiveQuery() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) executeQuery(opts queryOptions) (queryResult, error) {
|
||||
func (s *server) executeQuery(opts queryOptions) (result queryResult, resultErr error) {
|
||||
start := time.Now()
|
||||
sqlText := trimStatementSQL(opts.SQL)
|
||||
if isQuerySQL(sqlText) {
|
||||
rows, conn, cancel, err := s.queryRows(sqlText, opts.Schema, opts.TimeoutSecs)
|
||||
rows, conn, cancel, messages, restore, err := s.queryRowsWithMessages(sqlText, opts.Schema, opts.TimeoutSecs)
|
||||
if err != nil {
|
||||
return queryResult{}, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rows.Close()
|
||||
result.Messages = messages.drain()
|
||||
restore()
|
||||
_ = conn.Close()
|
||||
s.endOperation(cancel)
|
||||
}()
|
||||
@@ -767,7 +773,7 @@ func (s *server) executeQuery(opts queryOptions) (queryResult, error) {
|
||||
if maxRows <= 0 {
|
||||
maxRows = defaultMaxRows
|
||||
}
|
||||
result, err := readRows(rows, maxRows)
|
||||
result, err = readRows(rows, maxRows)
|
||||
result.ExecutionTimeMS = time.Since(start).Milliseconds()
|
||||
return result, err
|
||||
}
|
||||
@@ -775,7 +781,15 @@ func (s *server) executeQuery(opts queryOptions) (queryResult, error) {
|
||||
if err != nil {
|
||||
return queryResult{}, err
|
||||
}
|
||||
messages, restore, err := captureQueryMessages(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
s.endOperation(cancel)
|
||||
return queryResult{}, err
|
||||
}
|
||||
defer func() {
|
||||
result.Messages = messages.drain()
|
||||
restore()
|
||||
_ = conn.Close()
|
||||
s.endOperation(cancel)
|
||||
}()
|
||||
@@ -801,20 +815,42 @@ func (s *server) queryRows(sqlText string, schema string, timeoutSecs int) (*sql
|
||||
return rows, conn, cancel, nil
|
||||
}
|
||||
|
||||
func (s *server) queryRowsWithMessages(sqlText string, schema string, timeoutSecs int) (*sql.Rows, *sql.Conn, context.CancelFunc, *queryMessageBuffer, func(), error) {
|
||||
conn, ctx, cancel, err := s.operationConn(schema, timeoutSecs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
messages, restore, err := captureQueryMessages(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
s.endOperation(cancel)
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
rows, err := conn.QueryContext(ctx, sqlText)
|
||||
if err != nil {
|
||||
restore()
|
||||
_ = conn.Close()
|
||||
s.endOperation(cancel)
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
return rows, conn, cancel, messages, restore, nil
|
||||
}
|
||||
|
||||
func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageResult, error) {
|
||||
start := time.Now()
|
||||
sqlText := trimStatementSQL(opts.SQL)
|
||||
if !isQuerySQL(sqlText) {
|
||||
result, err := s.executeQuery(opts)
|
||||
return queryPageResult{Columns: result.Columns, ColumnTypes: result.ColumnTypes, Rows: result.Rows, AffectedRows: result.AffectedRows, ExecutionTimeMS: result.ExecutionTimeMS, Truncated: result.Truncated}, err
|
||||
return queryPageResult{Columns: result.Columns, ColumnTypes: result.ColumnTypes, Rows: result.Rows, AffectedRows: result.AffectedRows, ExecutionTimeMS: result.ExecutionTimeMS, Truncated: result.Truncated, Messages: result.Messages}, err
|
||||
}
|
||||
rows, conn, cancel, err := s.queryRows(sqlText, opts.Schema, opts.TimeoutSecs)
|
||||
rows, conn, cancel, messages, restore, err := s.queryRowsWithMessages(sqlText, opts.Schema, opts.TimeoutSecs)
|
||||
if err != nil {
|
||||
return queryPageResult{}, err
|
||||
}
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
restore()
|
||||
_ = conn.Close()
|
||||
s.endOperation(cancel)
|
||||
return queryPageResult{}, err
|
||||
@@ -824,11 +860,13 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes
|
||||
if maxRows <= 0 {
|
||||
maxRows = defaultMaxRows
|
||||
}
|
||||
session := &querySession{rows: rows, conn: conn, columns: columns, columnTypes: columnTypeNames(rows), remaining: maxRows, cancel: cancel}
|
||||
session := &querySession{rows: rows, conn: conn, columns: columns, columnTypes: columnTypeNames(rows), remaining: maxRows, cancel: cancel, messages: messages, restoreMessages: restore}
|
||||
result, err := readQuerySessionPage(session, pageSize)
|
||||
result.Messages = messages.drain()
|
||||
result.ExecutionTimeMS = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
restore()
|
||||
_ = conn.Close()
|
||||
s.endOperation(cancel)
|
||||
return queryPageResult{}, err
|
||||
@@ -840,6 +878,7 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes
|
||||
result.SessionID = &id
|
||||
} else {
|
||||
_ = rows.Close()
|
||||
restore()
|
||||
_ = conn.Close()
|
||||
s.endOperation(cancel)
|
||||
}
|
||||
@@ -852,6 +891,9 @@ func (s *server) fetchQueryPage(id string, pageSize int) (queryPageResult, error
|
||||
return queryPageResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}}, nil
|
||||
}
|
||||
result, err := readQuerySessionPage(session, pageSize)
|
||||
if session.messages != nil {
|
||||
result.Messages = session.messages.drain()
|
||||
}
|
||||
if err != nil {
|
||||
s.closeQuerySession(id)
|
||||
return queryPageResult{}, err
|
||||
@@ -870,6 +912,9 @@ func (s *server) closeQuerySession(id string) bool {
|
||||
return false
|
||||
}
|
||||
_ = session.rows.Close()
|
||||
if session.restoreMessages != nil {
|
||||
session.restoreMessages()
|
||||
}
|
||||
if session.conn != nil {
|
||||
_ = session.conn.Close()
|
||||
}
|
||||
@@ -997,13 +1042,21 @@ func columnTypeNames(rows *sql.Rows) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *server) executeTransaction(params map[string]json.RawMessage) (queryResult, error) {
|
||||
func (s *server) executeTransaction(params map[string]json.RawMessage) (result queryResult, resultErr error) {
|
||||
statements := stringSliceParam(params, "statements")
|
||||
conn, ctx, cancel, err := s.operationConn(stringParam(params, "schema"), intParam(params, "timeoutSecs"))
|
||||
if err != nil {
|
||||
return queryResult{}, err
|
||||
}
|
||||
messages, restore, err := captureQueryMessages(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
s.endOperation(cancel)
|
||||
return queryResult{}, err
|
||||
}
|
||||
defer func() {
|
||||
result.Messages = messages.drain()
|
||||
restore()
|
||||
_ = conn.Close()
|
||||
s.endOperation(cancel)
|
||||
}()
|
||||
@@ -1031,14 +1084,18 @@ func (s *server) executeTransaction(params map[string]json.RawMessage) (queryRes
|
||||
func (s *server) executeBatch(params map[string]json.RawMessage) (queryResult, error) {
|
||||
start := time.Now()
|
||||
var affected int64
|
||||
messages := &queryMessageBuffer{}
|
||||
for _, statement := range stringSliceParam(params, "statements") {
|
||||
result, err := s.executeQuery(queryOptions{SQL: statement, Schema: stringParam(params, "schema")})
|
||||
if err != nil {
|
||||
return queryResult{}, err
|
||||
}
|
||||
affected += result.AffectedRows
|
||||
for _, message := range result.Messages {
|
||||
messages.addMessage(message)
|
||||
}
|
||||
}
|
||||
return queryResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}, AffectedRows: affected, ExecutionTimeMS: time.Since(start).Milliseconds()}, nil
|
||||
return queryResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}, AffectedRows: affected, ExecutionTimeMS: time.Since(start).Milliseconds(), Messages: messages.drain()}, nil
|
||||
}
|
||||
|
||||
func (s *server) operationConn(schema string, timeoutSecs int) (*sql.Conn, context.Context, context.CancelFunc, error) {
|
||||
|
||||
@@ -3917,6 +3917,14 @@ func TestTableDDLIncludesIdentityIndexesTriggersAndComments(t *testing.T) {
|
||||
t.Fatalf("table DDL missing %q:\n%s", expected, ddl)
|
||||
}
|
||||
}
|
||||
|
||||
ddlUnqualified, err := server.getTableDDL("", "orders")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(ddlUnqualified, `"id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL`) {
|
||||
t.Fatalf("identity clause missing from unqualified table DDL: %s", ddlUnqualified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTableDDLIncludesEscapedComments(t *testing.T) {
|
||||
@@ -4958,3 +4966,58 @@ func TestNormalizeValueKingbaseTimezoneLessDateTime(t *testing.T) {
|
||||
t.Fatalf("TIMESTAMPTZ column: got %#v, want RFC3339Nano-encoded UTC instant", tzAware)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogColumnsPreservesJsonAndJsonbTypes(t *testing.T) {
|
||||
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
||||
if !strings.Contains(query, "WHEN t.typname = 'json' THEN 'json'") ||
|
||||
!strings.Contains(query, "WHEN t.typname = '_json' THEN 'json[]'") {
|
||||
return nil, fmt.Errorf("columns query must distinguish json from jsonb: %s", query)
|
||||
}
|
||||
return &valueRows{
|
||||
columns: []string{"nspname", "attname", "data_type", "nullable", "default", "comment", "precision", "scale", "length", "identity"},
|
||||
rows: [][]driver.Value{
|
||||
{"public", "data_json", "json", true, nil, nil, nil, nil, nil, nil},
|
||||
{"public", "data_jsonb", "jsonb", true, nil, nil, nil, nil, nil, nil},
|
||||
{"public", "data_json_arr", "json[]", true, nil, nil, nil, nil, nil, nil},
|
||||
},
|
||||
}, nil
|
||||
}}
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
columns, err := server.getColumns("public", "test_json_table")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(columns) != 3 {
|
||||
t.Fatalf("expected 3 columns, got %d", len(columns))
|
||||
}
|
||||
if columns[0].DataType != "json" {
|
||||
t.Fatalf("expected DataType 'json', got %q", columns[0].DataType)
|
||||
}
|
||||
if columns[1].DataType != "jsonb" {
|
||||
t.Fatalf("expected DataType 'jsonb', got %q", columns[1].DataType)
|
||||
}
|
||||
if columns[2].DataType != "json[]" {
|
||||
t.Fatalf("expected DataType 'json[]', got %q", columns[2].DataType)
|
||||
}
|
||||
|
||||
ddl := server.columnDDLDefinition(columns[0])
|
||||
if ddl != `"data_json" json` {
|
||||
t.Fatalf("expected column DDL '\"data_json\" json', got %q", ddl)
|
||||
}
|
||||
ddlB := server.columnDDLDefinition(columns[1])
|
||||
if ddlB != `"data_jsonb" jsonb` {
|
||||
t.Fatalf("expected column DDL '\"data_jsonb\" jsonb', got %q", ddlB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualifiedCatalogTypeExpressionHandlesJsonTypes(t *testing.T) {
|
||||
expr := qualifiedCatalogTypeExpression("t", "n", "elem", "elem_n", "t.oid", "t.typtypmod")
|
||||
if !strings.Contains(expr, "WHEN t.typname = 'json'") ||
|
||||
!strings.Contains(expr, "THEN 'json'") ||
|
||||
!strings.Contains(expr, "WHEN t.typname = '_json'") ||
|
||||
!strings.Contains(expr, "THEN 'json[]'") {
|
||||
t.Fatalf("qualifiedCatalogTypeExpression missing json handling: %s", expr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"gitea.com/kingbase/gokb"
|
||||
)
|
||||
|
||||
const maxQueryMessages = 1000
|
||||
const maxQueryMessageBytes = 1024 * 1024
|
||||
|
||||
type queryMessage struct {
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
type queryMessageBuffer struct {
|
||||
mu sync.Mutex
|
||||
messages []queryMessage
|
||||
count int
|
||||
bytes int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (buffer *queryMessageBuffer) add(notice *gokb.Error) {
|
||||
buffer.addMessage(queryMessage{Severity: notice.Severity, Message: notice.Message, Code: string(notice.Code), Detail: notice.Detail, Hint: notice.Hint})
|
||||
}
|
||||
|
||||
func (buffer *queryMessageBuffer) addMessage(message queryMessage) {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
if buffer.truncated {
|
||||
return
|
||||
}
|
||||
size := len(message.Severity) + len(message.Message) + len(message.Code) + len(message.Detail) + len(message.Hint)
|
||||
if buffer.count >= maxQueryMessages || size > maxQueryMessageBytes-buffer.bytes {
|
||||
buffer.truncated = true
|
||||
buffer.messages = append(buffer.messages, queryMessage{Severity: "WARNING", Message: "Server output limit reached; further messages are not retained."})
|
||||
return
|
||||
}
|
||||
buffer.messages = append(buffer.messages, message)
|
||||
buffer.count++
|
||||
buffer.bytes += size
|
||||
}
|
||||
|
||||
func (buffer *queryMessageBuffer) drain() []queryMessage {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
messages := buffer.messages
|
||||
buffer.messages = nil
|
||||
return messages
|
||||
}
|
||||
|
||||
func captureQueryMessages(conn *sql.Conn) (*queryMessageBuffer, func(), error) {
|
||||
buffer := &queryMessageBuffer{}
|
||||
var restore func(driver.Conn)
|
||||
err := conn.Raw(func(raw any) error {
|
||||
driverConn := raw.(driver.Conn)
|
||||
connectionType := reflect.TypeOf(driverConn)
|
||||
if connectionType.Kind() != reflect.Pointer || connectionType.Elem().PkgPath() != "gitea.com/kingbase/gokb" {
|
||||
return nil
|
||||
}
|
||||
previous := gokb.NoticeHandler(driverConn)
|
||||
gokb.SetNoticeHandler(driverConn, buffer.add)
|
||||
restore = func(driverConn driver.Conn) { gokb.SetNoticeHandler(driverConn, previous) }
|
||||
return nil
|
||||
})
|
||||
return buffer, func() {
|
||||
if restore != nil {
|
||||
_ = conn.Raw(func(raw any) error { restore(raw.(driver.Conn)); return nil })
|
||||
}
|
||||
}, err
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/kingbase/gokb"
|
||||
)
|
||||
|
||||
func TestQueryMessagesPreserveNoticeFieldsAndRepeatedOutput(t *testing.T) {
|
||||
buffer := &queryMessageBuffer{}
|
||||
notice := &gokb.Error{Severity: "NOTICE", Message: "当前用户", Code: "00000", Detail: "detail", Hint: "hint"}
|
||||
buffer.add(notice)
|
||||
buffer.add(notice)
|
||||
messages := buffer.drain()
|
||||
if len(messages) != 2 || messages[0].Message != notice.Message || messages[0].Code != "00000" || messages[0].Detail != "detail" || messages[0].Hint != "hint" {
|
||||
t.Fatalf("missing notice fields: %+v", messages)
|
||||
}
|
||||
if len(buffer.drain()) != 0 {
|
||||
t.Fatal("page messages were delivered twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryMessagesBoundCountAndBytesAcrossPages(t *testing.T) {
|
||||
buffer := &queryMessageBuffer{}
|
||||
for index := 0; index < maxQueryMessages; index++ {
|
||||
buffer.add(&gokb.Error{Severity: "NOTICE", Message: "same"})
|
||||
buffer.drain()
|
||||
}
|
||||
buffer.add(&gokb.Error{Message: "overflow"})
|
||||
buffer.add(&gokb.Error{Message: "overflow again"})
|
||||
if messages := buffer.drain(); len(messages) != 1 || messages[0].Severity != "WARNING" {
|
||||
t.Fatalf("missing output cap: %+v", messages)
|
||||
}
|
||||
buffer = &queryMessageBuffer{}
|
||||
buffer.add(&gokb.Error{Message: strings.Repeat("x", maxQueryMessageBytes+1)})
|
||||
if messages := buffer.drain(); len(messages) != 1 || messages[0].Severity != "WARNING" {
|
||||
t.Fatal("oversized notice was retained")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'org.apache.kylin:kylin-jdbc:5.0.0'
|
||||
implementation 'org.apache.kylin:kylin-jdbc:5.0.4'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.oceanbase:oceanbase-client:2.4.18'
|
||||
implementation 'com.oceanbase:oceanbase-client:2.4.19'
|
||||
testImplementation 'com.google.code.gson:gson:2.12.1'
|
||||
}
|
||||
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import com.oceanbase.jdbc.OceanBaseStatement;
|
||||
import com.dbx.agent.BlobBoundExecutor;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
final class OceanBaseLobStatements {
|
||||
private OceanBaseLobStatements() { }
|
||||
|
||||
@FunctionalInterface
|
||||
interface ConnectionDiscarder {
|
||||
void discard(java.sql.Connection connection) throws SQLException;
|
||||
}
|
||||
|
||||
static final BlobBoundExecutor.PreparedStatementConfigurer BINDING = binding(java.sql.Connection::close);
|
||||
|
||||
static BlobBoundExecutor.PreparedStatementConfigurer binding(ConnectionDiscarder discard) {
|
||||
return new BlobBoundExecutor.PreparedStatementConfigurer() {
|
||||
@Override public void discardConnection(java.sql.Connection connection) throws SQLException {
|
||||
discard.discard(connection);
|
||||
}
|
||||
@Override public void configure(java.sql.PreparedStatement statement) throws SQLException {
|
||||
OceanBaseLobStatements.configure(statement);
|
||||
}
|
||||
@Override public boolean isRollbackConfirmedBusinessError(SQLException error) {
|
||||
if (!(error instanceof java.sql.SQLTransientConnectionException)
|
||||
|| error.getErrorCode() != 20001 || !"HY000".equals(error.getSQLState())) return false;
|
||||
for (Throwable cause = error.getCause(); cause != null; cause = cause.getCause()) {
|
||||
if (cause instanceof java.sql.SQLRecoverableException || cause instanceof java.sql.SQLTimeoutException
|
||||
|| cause instanceof SQLException sql && sql.getSQLState() != null && sql.getSQLState().startsWith("08")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@Override public boolean isUnsupportedSavepointRelease(SQLException error) {
|
||||
// OB Oracle mode rejects release locally; the transaction end releases its savepoints.
|
||||
return error.getErrorCode() == 17023 && "99999".equals(error.getSQLState())
|
||||
&& !(error instanceof java.sql.SQLTimeoutException)
|
||||
&& !(error instanceof java.sql.SQLRecoverableException);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static void configure(Statement statement) throws SQLException {
|
||||
OceanBaseStatement vendor;
|
||||
try {
|
||||
vendor = statement instanceof OceanBaseStatement ? (OceanBaseStatement) statement
|
||||
: statement.unwrap(OceanBaseStatement.class);
|
||||
} catch (SQLException error) {
|
||||
throw new SQLException("Unsupported OceanBase LOB statement", error);
|
||||
}
|
||||
if (vendor == null) throw new SQLException("Unsupported OceanBase LOB statement");
|
||||
// Set only the native LOB mode; binding, cancellation and close retain the pool proxy.
|
||||
vendor.setInternal();
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.oceanbase.jdbc.DbxLobResourceBytes;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.Clob;
|
||||
import java.sql.Blob;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Captured locators belong to this agent's pinned connection, never to a new SELECT. */
|
||||
final class OceanBaseLobValues {
|
||||
static final String MARKER_PREFIX = "__DBX_LARGE_VALUE_BYTES_";
|
||||
static final int PREVIEW_CHARACTERS = 256;
|
||||
static final int MAX_CHUNK_CHARACTERS = 4096;
|
||||
private static final int MAX_REFS = 4096;
|
||||
private static final long MAX_RETAINED_BYTES = 64L * 1024 * 1024;
|
||||
private static final long IDLE_MILLIS = 5 * 60_000L;
|
||||
private static final long MAX_AGE_MILLIS = 30 * 60_000L;
|
||||
private final Map<String, Entry> entries = new LinkedHashMap<>();
|
||||
private final BlockReader reader;
|
||||
private final java.util.function.ToLongFunction<Object> retainedBytes;
|
||||
private long retainedByteCount;
|
||||
|
||||
@FunctionalInterface
|
||||
interface BlockReader {
|
||||
Chunk read(Connection connection, Object locator, long offset, int limit) throws SQLException;
|
||||
}
|
||||
|
||||
OceanBaseLobValues() { this(OceanBaseLobValues::read, DbxLobResourceBytes::retainedBytes); }
|
||||
OceanBaseLobValues(BlockReader reader) { this(reader, ignored -> 0); }
|
||||
OceanBaseLobValues(BlockReader reader, java.util.function.ToLongFunction<Object> retainedBytes) { this.reader = reader; this.retainedBytes = retainedBytes; }
|
||||
|
||||
record Preview(String text, String ref, String kind) {
|
||||
Preview(String text, String ref) { this(text, ref, "clob"); }
|
||||
}
|
||||
record Chunk(String status, String data, long next_offset, boolean eof, String value_kind) {}
|
||||
record MarkedRows(List<String> columns, List<String> types, List<List<Object>> rows) {}
|
||||
private static final class Entry {
|
||||
final Connection connection;
|
||||
final Object locator;
|
||||
final long retainedBytes;
|
||||
final long created = System.currentTimeMillis();
|
||||
long accessed = created;
|
||||
Entry(Connection connection, Object locator, long retainedBytes) {
|
||||
|
||||
this.connection = connection;
|
||||
this.locator = locator;
|
||||
this.retainedBytes = retainedBytes;
|
||||
}
|
||||
}
|
||||
|
||||
synchronized Object preview(ResultSet rs, int index, int sqlType, String typeName) throws SQLException {
|
||||
boolean binary = sqlType == Types.BLOB || "BLOB".equalsIgnoreCase(typeName);
|
||||
if ((!binary && !isCharacterLob(sqlType, typeName)) || hasMarkerCollision(rs)) return null;
|
||||
// NCLOB's charset form must be verified separately; retain the complete existing read.
|
||||
if (sqlType == Types.NCLOB || "NCLOB".equalsIgnoreCase(typeName)) return null;
|
||||
Object locator = binary ? rs.getBlob(index) : rs.getClob(index);
|
||||
String kind = binary ? "blob" : "clob";
|
||||
if (locator == null || rs.wasNull()) return new Preview(null, null, kind);
|
||||
Connection connection = rs.getStatement().getConnection();
|
||||
prune();
|
||||
long bytes = retainedBytes.applyAsLong(locator);
|
||||
if (bytes < 0 || bytes > MAX_RETAINED_BYTES - retainedByteCount || entries.size() >= MAX_REFS) {
|
||||
freeLocator(locator);
|
||||
|
||||
throw new SQLException("LOB result resource limit reached; close older results and execute again", "HY001");
|
||||
}
|
||||
boolean retained = false;
|
||||
try {
|
||||
Chunk chunk = reader.read(connection, locator, 0, PREVIEW_CHARACTERS + 1);
|
||||
if (chunk.eof()) return new Preview(binary ? "0x" + chunk.data() : chunk.data(), null, kind);
|
||||
String text = chunk.data();
|
||||
int count = binary ? text.length() / 2 : text.codePointCount(0, text.length());
|
||||
if (count <= PREVIEW_CHARACTERS) return new Preview(binary ? "0x" + text : text, null, kind);
|
||||
String ref = UUID.randomUUID().toString();
|
||||
entries.put(ref, new Entry(connection, locator, bytes));
|
||||
retainedByteCount += bytes;
|
||||
retained = true;
|
||||
String preview = binary ? "0x" + text.substring(0, PREVIEW_CHARACTERS * 2)
|
||||
: text.substring(0, text.offsetByCodePoints(0, PREVIEW_CHARACTERS));
|
||||
return new Preview(preview, ref, kind);
|
||||
} finally {
|
||||
if (!retained) freeLocator(locator);
|
||||
}
|
||||
}
|
||||
|
||||
synchronized Chunk fetch(Connection connection, String ref, long offset, int limit) throws SQLException {
|
||||
if (offset < 0 || offset == Long.MAX_VALUE || limit < 1 || limit > MAX_CHUNK_CHARACTERS) {
|
||||
throw new IllegalArgumentException("Invalid LOB chunk offset or limit");
|
||||
}
|
||||
prune();
|
||||
Entry entry = entries.get(ref);
|
||||
if (entry == null || entry.connection != connection || connection.isClosed()) {
|
||||
release(ref);
|
||||
return new Chunk("expired", "", offset, false, "text");
|
||||
}
|
||||
entry.accessed = System.currentTimeMillis();
|
||||
try {
|
||||
return reader.read(connection, entry.locator, offset, limit);
|
||||
} catch (SQLException error) {
|
||||
release(ref);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
synchronized boolean release(String ref) {
|
||||
Entry entry = entries.remove(ref);
|
||||
if (entry == null) return false;
|
||||
retainedByteCount -= entry.retainedBytes;
|
||||
free(entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
synchronized boolean hasValues() {
|
||||
prune();
|
||||
return !entries.isEmpty();
|
||||
}
|
||||
|
||||
synchronized void clear() {
|
||||
entries.values().forEach(OceanBaseLobValues::free);
|
||||
entries.clear();
|
||||
retainedByteCount = 0;
|
||||
}
|
||||
|
||||
synchronized java.util.Set<String> checkpoint() {
|
||||
return java.util.Set.copyOf(entries.keySet());
|
||||
}
|
||||
|
||||
synchronized void releaseSince(java.util.Set<String> checkpoint) {
|
||||
for (String ref : List.copyOf(entries.keySet())) {
|
||||
if (!checkpoint.contains(ref)) release(ref);
|
||||
}
|
||||
}
|
||||
|
||||
private void prune() {
|
||||
long now = System.currentTimeMillis();
|
||||
Iterator<Entry> iterator = entries.values().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Entry entry = iterator.next();
|
||||
if (now - entry.accessed >= IDLE_MILLIS || now - entry.created >= MAX_AGE_MILLIS) {
|
||||
iterator.remove();
|
||||
retainedByteCount -= entry.retainedBytes;
|
||||
free(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void free(Entry entry) {
|
||||
try { freeLocator(entry.locator); } catch (SQLException ignored) { }
|
||||
}
|
||||
|
||||
private static void freeLocator(Object locator) throws SQLException {
|
||||
if (locator instanceof Blob) ((Blob) locator).free();
|
||||
else ((Clob) locator).free();
|
||||
}
|
||||
|
||||
static Chunk read(Connection connection, Object locator, long offset, int limit) throws SQLException {
|
||||
boolean binary = locator instanceof Blob;
|
||||
// OceanBase READ raises ORA-06502 for empty LOBs. Only zero is safe here:
|
||||
// JDBC CLOB length counts UTF-16 units while READ offsets count code points.
|
||||
long length = binary ? ((Blob) locator).length() : ((Clob) locator).length();
|
||||
if (length == 0) return new Chunk("ok", "", offset, true, binary ? "binary" : "text");
|
||||
try (CallableStatement call = connection.prepareCall(
|
||||
"DECLARE v_lob " + (binary ? "BLOB" : "CLOB") + " := ?; v_amount INTEGER := ?; v_offset INTEGER := ?; "
|
||||
+ "v_buffer " + (binary ? "RAW(32767)" : "VARCHAR2(32767)") + "; v_length INTEGER; BEGIN v_length := DBMS_LOB.GETLENGTH(v_lob); "
|
||||
+ "IF v_offset > v_length THEN v_amount := 0; ELSE DBMS_LOB.READ(v_lob,v_amount,v_offset,v_buffer); END IF; "
|
||||
+ "? := v_amount; ? := v_buffer; ? := v_length; END;")) {
|
||||
OceanBaseLobStatements.configure(call);
|
||||
if (binary) call.setBlob(1, (Blob) locator);
|
||||
else call.setClob(1, (Clob) locator);
|
||||
call.setInt(2, limit);
|
||||
call.setLong(3, offset + 1);
|
||||
call.registerOutParameter(4, Types.INTEGER);
|
||||
call.registerOutParameter(5, binary ? Types.VARBINARY : Types.VARCHAR);
|
||||
call.registerOutParameter(6, Types.BIGINT);
|
||||
call.setQueryTimeout(20);
|
||||
JdbcExecutor.current().withActiveStatement(call, () -> { call.execute(); return null; });
|
||||
int amount = call.getInt(4);
|
||||
byte[] bytes = call.getBytes(5);
|
||||
long serverLength = call.getLong(6);
|
||||
long nextOffset = Math.addExact(offset, amount);
|
||||
if (amount < 0 || amount > limit || serverLength < 0 || bytes == null && amount != 0
|
||||
|| offset < serverLength && (amount == 0 || nextOffset > serverLength)
|
||||
|| offset >= serverLength && amount != 0) {
|
||||
throw new SQLException("Invalid OceanBase LOB chunk response");
|
||||
}
|
||||
if (binary) {
|
||||
if (bytes != null && bytes.length != amount) throw new SQLException("OceanBase BLOB byte count mismatch");
|
||||
String hex = bytes == null ? "" : JdbcExecutor.bytesToHex(bytes).substring(2);
|
||||
return new Chunk("ok", hex, nextOffset, nextOffset >= serverLength, "binary");
|
||||
}
|
||||
String text;
|
||||
try {
|
||||
text = bytes == null ? "" : StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
} catch (java.nio.charset.CharacterCodingException error) {
|
||||
throw new SQLException("Invalid UTF-8 in OceanBase LOB chunk", error);
|
||||
}
|
||||
if (text.codePointCount(0, text.length()) != amount) throw new SQLException("OceanBase LOB character count mismatch");
|
||||
return new Chunk("ok", text, nextOffset, nextOffset >= serverLength, "text");
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isCharacterLob(int sqlType, String typeName) {
|
||||
return sqlType == Types.CLOB || sqlType == Types.NCLOB
|
||||
|| "CLOB".equalsIgnoreCase(typeName) || "NCLOB".equalsIgnoreCase(typeName);
|
||||
}
|
||||
|
||||
private static boolean hasMarkerCollision(ResultSet rs) throws SQLException {
|
||||
var metadata = rs.getMetaData();
|
||||
for (int index = 1; index <= metadata.getColumnCount(); index++) {
|
||||
if (metadata.getColumnLabel(index).toUpperCase(Locale.ROOT).startsWith(MARKER_PREFIX)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static MarkedRows mark(List<String> columns, List<String> types, List<List<Object>> rows) {
|
||||
List<String> expandedColumns = new ArrayList<>();
|
||||
List<String> expandedTypes = new ArrayList<>();
|
||||
boolean[] marked = new boolean[columns.size()];
|
||||
String[] kinds = new String[columns.size()];
|
||||
for (List<Object> row : rows) {
|
||||
for (int index = 0; index < row.size(); index++) {
|
||||
if (row.get(index) instanceof Preview preview) { marked[index] = true; kinds[index] = preview.kind(); }
|
||||
}
|
||||
}
|
||||
boolean anyMarked = false;
|
||||
for (boolean value : marked) anyMarked |= value;
|
||||
if (!anyMarked) return null;
|
||||
for (int index = 0; index < columns.size(); index++) {
|
||||
expandedColumns.add(columns.get(index));
|
||||
expandedTypes.add(index < types.size() ? types.get(index) : "");
|
||||
if (marked[index]) {
|
||||
expandedColumns.add(MARKER_PREFIX + ("blob".equals(kinds[index]) ? "L_" : "C_") + index);
|
||||
expandedTypes.add("VARCHAR");
|
||||
}
|
||||
}
|
||||
List<List<Object>> expandedRows = new ArrayList<>();
|
||||
for (List<Object> row : rows) {
|
||||
List<Object> expanded = new ArrayList<>();
|
||||
for (int index = 0; index < columns.size(); index++) {
|
||||
Object value = row.get(index);
|
||||
Preview preview = value instanceof Preview ? (Preview) value : null;
|
||||
expanded.add(preview == null ? value : preview.text());
|
||||
if (marked[index]) expanded.add(preview == null || preview.ref() == null ? null : "D:1:unknown:" + preview.ref());
|
||||
}
|
||||
expandedRows.add(expanded);
|
||||
}
|
||||
return new MarkedRows(expandedColumns, expandedTypes, expandedRows);
|
||||
}
|
||||
}
|
||||
+541
-16
@@ -1,6 +1,8 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.BlobBoundStatement;
|
||||
import com.dbx.agent.BlobBoundExecutor;
|
||||
import com.dbx.agent.AgentProtocol;
|
||||
import com.dbx.agent.CompletionAssistantCandidate;
|
||||
import com.dbx.agent.CompletionAssistantCandidateKind;
|
||||
@@ -29,6 +31,7 @@ import com.dbx.agent.QueryPageOptions;
|
||||
import com.dbx.agent.QueryPageResult;
|
||||
import com.dbx.agent.QueryResult;
|
||||
import com.dbx.agent.QueryTiming;
|
||||
import com.dbx.agent.SpatialColumn;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
|
||||
@@ -67,6 +70,8 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
"GGSYS", "FLOWS_FILES", "APEX_PUBLIC_USER", "GSMROOTUSER", "SYSRAC"
|
||||
);
|
||||
private boolean queryTimeoutChanged;
|
||||
private final OceanBaseLobValues lobValues = new OceanBaseLobValues();
|
||||
private boolean deferCharacterLobs;
|
||||
|
||||
public static final JdbcAgentProfile OCEANBASE_ORACLE_PROFILE = new JdbcAgentProfile(
|
||||
"com.oceanbase.jdbc.Driver",
|
||||
@@ -89,18 +94,47 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
@Override
|
||||
public boolean supportsQueryTiming() { return true; }
|
||||
|
||||
@Override
|
||||
public boolean supportsBlobBindStatements() { return true; }
|
||||
|
||||
@Override
|
||||
public QueryResult executeBlobBoundStatements(List<String> previews, List<BlobBoundStatement> statements,
|
||||
String schema, int timeoutSecs, boolean transaction) {
|
||||
return BlobBoundExecutor.execute(requireConnected(), previews, statements, schema,
|
||||
this::setSchemaSQL, this::resetSchemaSQL, timeoutSecs, transaction, OceanBaseLobStatements.binding(this::discardConnection));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
|
||||
var lobCheckpoint = lobValues.checkpoint();
|
||||
try (QueryTiming timing = QueryTiming.begin()) {
|
||||
prepareLobExecution(sql, options.getDeferLobs());
|
||||
QueryResult result = super.executeQuery(sql, schema, options);
|
||||
var marked = OceanBaseLobValues.mark(result.getColumns(), result.getColumn_types(), result.getRows());
|
||||
if (marked != null) {
|
||||
var spatialColumns = remapLobSpatialColumns(result.getSpatial_columns(), marked.columns());
|
||||
var spatialValues = remapLobSpatialValues(result.getSpatial_values(), marked.columns());
|
||||
result.setColumns(marked.columns());
|
||||
result.setColumn_types(marked.types());
|
||||
result.setRows(marked.rows());
|
||||
result.setSpatial_columns(spatialColumns);
|
||||
result.setSpatial_values(spatialValues);
|
||||
}
|
||||
result.setQuery_timings_ms(timing.finish());
|
||||
return result;
|
||||
} catch (RuntimeException error) {
|
||||
lobValues.releaseSince(lobCheckpoint);
|
||||
throw error;
|
||||
} finally {
|
||||
deferCharacterLobs = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
|
||||
var lobCheckpoint = lobValues.checkpoint();
|
||||
try (QueryTiming timing = QueryTiming.begin()) {
|
||||
prepareLobExecution(sql, options.getDeferLobs());
|
||||
long prepareStarted = System.nanoTime();
|
||||
Connection connection = requireConnected();
|
||||
uncheckedVoid(() -> beforeQueryExecution(connection, options.getTimeoutSecs()));
|
||||
@@ -108,8 +142,14 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
QueryPageResult result = JdbcExecutor.current().executeBoundedPage(
|
||||
connection, sql, schema, this::setSchemaSQL, this::resetSchemaSQL, options, resultValueReader()
|
||||
);
|
||||
markLobPage(result);
|
||||
result.setQuery_timings_ms(timing.finish());
|
||||
return result;
|
||||
} catch (RuntimeException error) {
|
||||
lobValues.releaseSince(lobCheckpoint);
|
||||
throw error;
|
||||
} finally {
|
||||
deferCharacterLobs = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,11 +157,166 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
public QueryPageResult fetchQueryPage(String sessionId, int pageSize) {
|
||||
try (QueryTiming timing = QueryTiming.begin()) {
|
||||
QueryPageResult result = super.fetchQueryPage(sessionId, pageSize);
|
||||
markLobPage(result);
|
||||
result.setQuery_timings_ms(timing.finish());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
|
||||
var lobCheckpoint = lobValues.checkpoint();
|
||||
prepareLobExecution(sql, options.getDeferLobs());
|
||||
try {
|
||||
QueryPageResult result = super.startTableRead(sql, schema, options);
|
||||
markLobPage(result);
|
||||
return result;
|
||||
} catch (RuntimeException error) {
|
||||
lobValues.releaseSince(lobCheckpoint);
|
||||
throw error;
|
||||
} finally {
|
||||
deferCharacterLobs = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryPageResult fetchTableReadPage(String sessionId, int pageSize) {
|
||||
QueryPageResult result = super.fetchTableReadPage(sessionId, pageSize);
|
||||
markLobPage(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void markLobPage(QueryPageResult result) {
|
||||
var marked = OceanBaseLobValues.mark(result.getColumns(), result.getColumn_types(), result.getRows());
|
||||
if (marked == null) return;
|
||||
var spatialColumns = remapLobSpatialColumns(result.getSpatial_columns(), marked.columns());
|
||||
var spatialValues = remapLobSpatialValues(result.getSpatial_values(), marked.columns());
|
||||
result.setColumns(marked.columns());
|
||||
result.setColumn_types(marked.types());
|
||||
result.setRows(marked.rows());
|
||||
result.setSpatial_columns(spatialColumns);
|
||||
result.setSpatial_values(spatialValues);
|
||||
}
|
||||
|
||||
private static List<SpatialColumn> remapLobSpatialColumns(List<SpatialColumn> spatial, List<String> columns) {
|
||||
List<Integer> retained = new ArrayList<>();
|
||||
for (int index = 0; index < columns.size(); index++) {
|
||||
if (!columns.get(index).startsWith(OceanBaseLobValues.MARKER_PREFIX)) retained.add(index);
|
||||
}
|
||||
List<SpatialColumn> remapped = new ArrayList<>();
|
||||
for (SpatialColumn column : spatial) remapped.add(new SpatialColumn(retained.get(column.getColumn_index()), column.getSrid()));
|
||||
return remapped;
|
||||
}
|
||||
|
||||
private static List<List<Integer>> remapLobSpatialValues(List<List<Integer>> spatial, List<String> columns) {
|
||||
List<List<Integer>> remapped = new ArrayList<>();
|
||||
for (List<Integer> row : spatial) {
|
||||
List<Integer> expanded = new ArrayList<>();
|
||||
int sourceIndex = 0;
|
||||
for (String column : columns) {
|
||||
expanded.add(column.startsWith(OceanBaseLobValues.MARKER_PREFIX) ? null : row.get(sourceIndex++));
|
||||
}
|
||||
remapped.add(expanded);
|
||||
}
|
||||
return remapped;
|
||||
}
|
||||
|
||||
private void prepareLobExecution(String sql, boolean requested) {
|
||||
// Unproven statements may end the transaction or replace an object. Fail closed.
|
||||
String leading = sql.replaceFirst("(?s)^(?:\\s|--[^\\r\\n]*(?:\\r?\\n|$)|/\\*.*?\\*/)*", "");
|
||||
if (!leading.matches("(?is)^SELECT\\b.*") && !isSelectWithClause(leading)) lobValues.clear();
|
||||
deferCharacterLobs = requested;
|
||||
}
|
||||
|
||||
// Recognize ordinary subquery factoring only. PL/SQL declarations and unproven
|
||||
// WITH forms retain the conservative invalidation behavior.
|
||||
private static boolean isSelectWithClause(String sql) {
|
||||
if (!sql.matches("(?is)^WITH\\b.*")) return false;
|
||||
var matcher = java.util.regex.Pattern.compile("(?s)--[^\\r\\n]*|/\\*.*?\\*/|'(?:''|[^'])*+'|\"(?:\"\"|[^\"])*+\"|[A-Za-z_][A-Za-z0-9_$#]*|\\S").matcher(sql);
|
||||
List<String> tokens = new ArrayList<>();
|
||||
while (matcher.find()) {
|
||||
String token = matcher.group();
|
||||
int end = matcher.end();
|
||||
if ((token.equalsIgnoreCase("q") || token.equalsIgnoreCase("nq")) && end + 1 < sql.length() && sql.charAt(end) == '\'') {
|
||||
char opening = sql.charAt(end + 1);
|
||||
char closing = switch (opening) {
|
||||
case '[' -> ']';
|
||||
case '{' -> '}';
|
||||
case '(' -> ')';
|
||||
case '<' -> '>';
|
||||
default -> opening;
|
||||
};
|
||||
int close = sql.indexOf("" + closing + '\'', end + 2);
|
||||
if (Character.isWhitespace(opening) || close < 0) return false;
|
||||
tokens.add("'literal'");
|
||||
matcher.region(close + 2, sql.length());
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith("--") && !token.startsWith("/*")) tokens.add(token);
|
||||
}
|
||||
if (tokens.isEmpty() || !tokens.get(0).equalsIgnoreCase("WITH")) return false;
|
||||
int index = 1;
|
||||
while (index < tokens.size()) {
|
||||
String name = tokens.get(index++);
|
||||
if (!name.matches("[A-Za-z_][A-Za-z0-9_$#]*|\"(?:\"\"|[^\"])++\"")) return false;
|
||||
if (index < tokens.size() && tokens.get(index).equals("(")) {
|
||||
index = afterLobQueryParentheses(tokens, index);
|
||||
if (index < 0) return false;
|
||||
}
|
||||
if (index >= tokens.size() || !tokens.get(index++).equalsIgnoreCase("AS")) return false;
|
||||
if (index + 1 >= tokens.size() || !tokens.get(index).equals("(") || !tokens.get(index + 1).equalsIgnoreCase("SELECT")) return false;
|
||||
index = afterLobQueryParentheses(tokens, index);
|
||||
if (index < 0 || index >= tokens.size()) return false;
|
||||
if (tokens.get(index).equals(",")) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (!tokens.get(index).equalsIgnoreCase("SELECT")) return false;
|
||||
for (int rest = index + 1; rest < tokens.size(); rest++) {
|
||||
if (tokens.get(rest).equals(";") && rest != tokens.size() - 1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int afterLobQueryParentheses(List<String> tokens, int start) {
|
||||
int depth = 0;
|
||||
for (int index = start; index < tokens.size(); index++) {
|
||||
String token = tokens.get(index);
|
||||
if (token.equals(";")) return -1;
|
||||
if (token.equals("(")) depth++;
|
||||
else if (token.equals(")") && --depth == 0) return index + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected JdbcExecutor.ResultValueReader resultValueReader() {
|
||||
if (!deferCharacterLobs) return super.resultValueReader();
|
||||
return (JdbcExecutor.ColumnAwareResultValueReader) (rs, index, sqlType, typeName) -> {
|
||||
Object preview = lobValues.preview(rs, index, sqlType, typeName);
|
||||
return preview == null ? resultValue(rs, index, sqlType) : preview;
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean hasRetainedResultResources() { return lobValues.hasValues(); }
|
||||
|
||||
@Override
|
||||
protected void releaseRetainedResultResources() { lobValues.clear(); }
|
||||
|
||||
@Override
|
||||
public void invalidateLargeValues() { lobValues.clear(); }
|
||||
|
||||
@Override
|
||||
public Object readLargeValueChunk(String valueRef, long offset, int limit) {
|
||||
return unchecked(() -> lobValues.fetch(requireConnected(), valueRef, offset, limit));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean releaseLargeValue(String valueRef) { return lobValues.release(valueRef); }
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
return buildUrl(params);
|
||||
@@ -262,7 +457,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
String.format(baseSql, placeholders(objectTypes.size())),
|
||||
"OBJECT_NAME, TABLE_TYPE, COMMENTS",
|
||||
"o.OBJECT_NAME",
|
||||
"ORDER BY OBJECT_NAME",
|
||||
"ORDER BY OBJECT_NAME, o.OBJECT_ID",
|
||||
owner,
|
||||
objectTypes,
|
||||
constraints
|
||||
@@ -319,7 +514,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
WHEN 'PACKAGE BODY' THEN 5
|
||||
WHEN 'SEQUENCE' THEN 6
|
||||
ELSE 7
|
||||
END, OBJECT_NAME
|
||||
END, OBJECT_NAME, o.OBJECT_ID
|
||||
""".stripIndent().trim(),
|
||||
owner,
|
||||
objectTypes,
|
||||
@@ -327,17 +522,13 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
);
|
||||
|
||||
List<ObjectInfo> result = new ArrayList<>();
|
||||
String sql = query.sql;
|
||||
if (constraints.hasLimit() || constraints.hasOffset()) {
|
||||
sql += "\nORDER BY DBX_RN";
|
||||
}
|
||||
try (var stmt = requireConnection().prepareStatement(sql)) {
|
||||
try (var stmt = requireConnection().prepareStatement(query.sql)) {
|
||||
bind(stmt, query.args);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String objectType = rs.getString(2);
|
||||
result.add(new ObjectInfo(rs.getString(1),
|
||||
"PACKAGE BODY".equals(objectType) ? "PACKAGE_BODY" : objectType, owner, rs.getString(3)));
|
||||
objectType.replace(' ', '_'), owner, rs.getString(3)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,9 +541,196 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
if (hasTableLikeCompletionKind(request.getObject_kinds())) {
|
||||
return unchecked(() -> completionAssistantTables(request));
|
||||
}
|
||||
if (request.getObject_kinds().stream().anyMatch(kind -> kind == CompletionAssistantObjectKind.ROUTINE
|
||||
|| kind == CompletionAssistantObjectKind.PROCEDURE || kind == CompletionAssistantObjectKind.FUNCTION
|
||||
|| kind == CompletionAssistantObjectKind.SEQUENCE)) {
|
||||
return unchecked(() -> completionAssistantRoutines(request));
|
||||
}
|
||||
return super.completionAssistantSearch(request);
|
||||
}
|
||||
|
||||
private CompletionAssistantResponse completionAssistantRoutines(CompletionAssistantRequest request) throws SQLException {
|
||||
if (firstNonBlank(request.getParent_name()) != null) {
|
||||
return completionAssistantPackageMembers(request);
|
||||
}
|
||||
int limit = boundedCompletionLimit(request.getMax_results());
|
||||
String preferredSchema = preferredCompletionSchema(request);
|
||||
List<CompletionAssistantObjectKind> kinds = request.getObject_kinds();
|
||||
List<Object> args = new ArrayList<>();
|
||||
String types = kinds.contains(CompletionAssistantObjectKind.ROUTINE)
|
||||
|| (kinds.contains(CompletionAssistantObjectKind.PROCEDURE) && kinds.contains(CompletionAssistantObjectKind.FUNCTION))
|
||||
? "'PROCEDURE', 'FUNCTION'"
|
||||
: kinds.contains(CompletionAssistantObjectKind.PROCEDURE) ? "'PROCEDURE'"
|
||||
: kinds.contains(CompletionAssistantObjectKind.FUNCTION) ? "'FUNCTION'" : "";
|
||||
if (kinds.contains(CompletionAssistantObjectKind.SEQUENCE)) types += types.isEmpty() ? "'SEQUENCE'" : ", 'SEQUENCE'";
|
||||
String pattern = completionLikePattern(request.getMask(), request.getMatch_mode());
|
||||
args.add(request.getCase_sensitive() ? pattern : pattern.toUpperCase(Locale.ROOT));
|
||||
String ownerFilter = "";
|
||||
if (!request.getGlobal_search()) {
|
||||
ownerFilter = " AND OWNER = ?";
|
||||
args.add(firstNonBlank(request.getParent_schema(), request.getSchema(), preferredSchema));
|
||||
}
|
||||
args.add(preferredSchema);
|
||||
args.add(limit + 1);
|
||||
String sql = """
|
||||
SELECT OWNER, OBJECT_NAME, OBJECT_TYPE FROM (
|
||||
SELECT OWNER, OBJECT_NAME, OBJECT_TYPE FROM ALL_OBJECTS
|
||||
WHERE OBJECT_TYPE IN (%s) AND %s%s
|
||||
ORDER BY CASE WHEN OWNER = ? THEN 0 ELSE 1 END, OBJECT_NAME, OWNER, OBJECT_TYPE
|
||||
) WHERE ROWNUM <= ?
|
||||
""".formatted(types, completionNamePredicate("OBJECT_NAME", request.getCase_sensitive()), ownerFilter);
|
||||
List<CompletionAssistantCandidate> candidates = new ArrayList<>();
|
||||
boolean incomplete = false;
|
||||
try (PreparedStatement stmt = requireConnection().prepareStatement(sql)) {
|
||||
bindCompletionArgs(stmt, args);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
if (candidates.size() == limit) {
|
||||
incomplete = true;
|
||||
break;
|
||||
}
|
||||
String objectType = rs.getString(3);
|
||||
candidates.add(new CompletionAssistantCandidate(rs.getString(2),
|
||||
"PROCEDURE".equals(objectType) ? CompletionAssistantCandidateKind.PROCEDURE
|
||||
: "SEQUENCE".equals(objectType) ? CompletionAssistantCandidateKind.SEQUENCE : CompletionAssistantCandidateKind.FUNCTION,
|
||||
blankToNull(request.getDatabase()), rs.getString(1), null, null, null, objectType));
|
||||
}
|
||||
}
|
||||
}
|
||||
return new CompletionAssistantResponse(candidates, incomplete, false, true);
|
||||
}
|
||||
|
||||
private CompletionAssistantResponse completionAssistantPackageMembers(CompletionAssistantRequest request) throws SQLException {
|
||||
String owner = firstNonBlank(request.getParent_schema(), request.getSchema(), preferredCompletionSchema(request));
|
||||
String packageName = request.getParent_name();
|
||||
String objectId;
|
||||
try (PreparedStatement stmt = requireConnection().prepareStatement(
|
||||
"SELECT OBJECT_ID FROM ALL_OBJECTS WHERE OWNER = ? AND OBJECT_NAME = ? AND OBJECT_TYPE = 'PACKAGE'")) {
|
||||
bindCompletionArgs(stmt, List.of(owner, packageName));
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (!rs.next()) throw new SQLException("Package is not visible: " + owner + "." + packageName);
|
||||
objectId = rs.getString(1);
|
||||
}
|
||||
}
|
||||
int limit = boundedCompletionLimit(request.getMax_results());
|
||||
String pattern = completionLikePattern(request.getMask(), request.getMatch_mode());
|
||||
List<Object> args = new ArrayList<>(List.of(owner, packageName, objectId,
|
||||
request.getCase_sensitive() ? pattern : pattern.toUpperCase(Locale.ROOT)));
|
||||
String kindFilter = request.getObject_kinds().contains(CompletionAssistantObjectKind.ROUTINE)
|
||||
|| (request.getObject_kinds().contains(CompletionAssistantObjectKind.PROCEDURE)
|
||||
&& request.getObject_kinds().contains(CompletionAssistantObjectKind.FUNCTION))
|
||||
? "" : request.getObject_kinds().contains(CompletionAssistantObjectKind.FUNCTION)
|
||||
? " WHERE ROUTINE_KIND = 'FUNCTION'" : " WHERE ROUTINE_KIND = 'PROCEDURE'";
|
||||
args.add(limit + 1);
|
||||
// Limit subprograms before loading arguments, so the last signature cannot be cut in half.
|
||||
String sql = """
|
||||
SELECT PROCEDURE_NAME, SUBPROGRAM_ID, ROUTINE_KIND FROM (
|
||||
SELECT * FROM (
|
||||
SELECT p.PROCEDURE_NAME, p.SUBPROGRAM_ID,
|
||||
CASE WHEN EXISTS (SELECT 1 FROM ALL_ARGUMENTS a
|
||||
WHERE a.OWNER = p.OWNER AND a.OBJECT_ID = p.OBJECT_ID
|
||||
AND a.SUBPROGRAM_ID = p.SUBPROGRAM_ID AND a.DATA_LEVEL = 0 AND a.POSITION = 0)
|
||||
THEN 'FUNCTION' ELSE 'PROCEDURE' END ROUTINE_KIND
|
||||
FROM ALL_PROCEDURES p
|
||||
WHERE p.OWNER = ? AND p.OBJECT_NAME = ? AND p.OBJECT_ID = ?
|
||||
AND p.OBJECT_TYPE = 'PACKAGE' AND p.PROCEDURE_NAME IS NOT NULL AND %s
|
||||
)%s ORDER BY PROCEDURE_NAME, SUBPROGRAM_ID
|
||||
) WHERE ROWNUM <= ?
|
||||
""".formatted(completionNamePredicate("p.PROCEDURE_NAME", request.getCase_sensitive()), kindFilter);
|
||||
List<PackageCompletionMember> members = new ArrayList<>();
|
||||
boolean incomplete = false;
|
||||
try (PreparedStatement stmt = requireConnection().prepareStatement(sql)) {
|
||||
bindCompletionArgs(stmt, args);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
if (members.size() == limit) { incomplete = true; break; }
|
||||
members.add(new PackageCompletionMember(rs.getString(1), rs.getString(2), "FUNCTION".equals(rs.getString(3))));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!members.isEmpty()) {
|
||||
String placeholders = String.join(",", Collections.nCopies(members.size(), "?"));
|
||||
String argumentSql = """
|
||||
SELECT a.SUBPROGRAM_ID, a.POSITION, a.ARGUMENT_NAME, a.IN_OUT, a.DATA_TYPE,
|
||||
a.TYPE_OWNER, a.TYPE_NAME, a.TYPE_SUBNAME, a.DEFAULTED, type_owner_object.OWNER AS TYPE_SCHEMA
|
||||
FROM ALL_ARGUMENTS a
|
||||
LEFT JOIN ALL_OBJECTS type_owner_object
|
||||
ON type_owner_object.OBJECT_TYPE = 'DATABASE'
|
||||
AND TO_CHAR(type_owner_object.OBJECT_ID) = TRIM(a.TYPE_OWNER)
|
||||
WHERE a.OWNER = ? AND a.OBJECT_ID = ? AND a.DATA_LEVEL = 0 AND a.SUBPROGRAM_ID IN (%s)
|
||||
ORDER BY a.SUBPROGRAM_ID, a.POSITION, a.SEQUENCE
|
||||
""".formatted(placeholders);
|
||||
List<Object> argumentArgs = new ArrayList<>(List.of(owner, objectId));
|
||||
Map<String, PackageCompletionMember> byId = new LinkedHashMap<>();
|
||||
for (PackageCompletionMember member : members) {
|
||||
argumentArgs.add(member.id);
|
||||
byId.put(member.id, member);
|
||||
}
|
||||
try (PreparedStatement stmt = requireConnection().prepareStatement(argumentSql)) {
|
||||
bindCompletionArgs(stmt, argumentArgs);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
PackageCompletionMember member = byId.get(rs.getString(1));
|
||||
if (member == null) continue;
|
||||
String typeName = rs.getString(7);
|
||||
String typeOwner = rs.getString(6);
|
||||
// OB can expose a padded database object ID instead of the schema name.
|
||||
if (typeOwner != null && typeOwner.matches("[0-9]+ +")) typeOwner = rs.getString(10);
|
||||
if (typeName != null && typeOwner == null) {
|
||||
member.signatureKnown = false;
|
||||
continue;
|
||||
}
|
||||
String type = typeName == null ? rs.getString(5)
|
||||
: java.util.stream.Stream.of(typeOwner, typeName, rs.getString(8))
|
||||
.filter(value -> value != null && !value.isBlank())
|
||||
.map(OceanBaseOracleAgent::quotePackageCompletionIdentifier)
|
||||
.collect(java.util.stream.Collectors.joining("."));
|
||||
if (rs.getInt(2) == 0) {
|
||||
member.returnType = type;
|
||||
continue;
|
||||
}
|
||||
String name = rs.getString(3);
|
||||
String direction = rs.getString(4);
|
||||
if (name == null || type == null || direction == null) {
|
||||
member.signatureKnown = false;
|
||||
continue;
|
||||
}
|
||||
member.parameters.add(quotePackageCompletionIdentifier(name) + " " + direction + " " + type
|
||||
+ ("Y".equals(rs.getString(9)) ? " DEFAULT" : ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
List<CompletionAssistantCandidate> candidates = new ArrayList<>();
|
||||
for (PackageCompletionMember member : members) {
|
||||
String identity = owner.length() + ":" + owner + ":" + objectId + ":" + member.id;
|
||||
candidates.add(new CompletionAssistantCandidate(member.name,
|
||||
member.function ? CompletionAssistantCandidateKind.FUNCTION : CompletionAssistantCandidateKind.PROCEDURE,
|
||||
blankToNull(request.getDatabase()), owner, owner, packageName, null, member.returnType,
|
||||
member.signatureKnown ? String.join(", ", member.parameters) : null, identity));
|
||||
}
|
||||
return new CompletionAssistantResponse(candidates, incomplete, false, true);
|
||||
}
|
||||
|
||||
private static String quotePackageCompletionIdentifier(String identifier) {
|
||||
return identifier.matches("[A-Z][A-Z0-9_$#]*") ? identifier : "\"" + identifier.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
private static final class PackageCompletionMember {
|
||||
final String name;
|
||||
final String id;
|
||||
final boolean function;
|
||||
final List<String> parameters = new ArrayList<>();
|
||||
boolean signatureKnown = true;
|
||||
String returnType;
|
||||
|
||||
PackageCompletionMember(String name, String id, boolean function) {
|
||||
this.name = name;
|
||||
this.id = id;
|
||||
this.function = function;
|
||||
}
|
||||
}
|
||||
|
||||
private CompletionAssistantResponse completionAssistantTables(CompletionAssistantRequest request) throws SQLException {
|
||||
int limit = boundedCompletionLimit(request.getMax_results());
|
||||
int scanLimit = Math.min(1000, Math.max(limit * 3, limit + 1));
|
||||
@@ -696,7 +1074,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
sql = "SELECT " + selectList + "\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n"
|
||||
+ sql
|
||||
+ "\n ) DBX_Q\n WHERE ROWNUM <= ?\n)\nWHERE DBX_RN > ?";
|
||||
args.add(offset + constraints.getLimit());
|
||||
args.add((long) offset + constraints.getLimit());
|
||||
args.add(offset);
|
||||
} else if (constraints.hasOffset()) {
|
||||
sql = "SELECT " + selectList + "\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n"
|
||||
@@ -704,6 +1082,9 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
+ "\n ) DBX_Q\n)\nWHERE DBX_RN > ?";
|
||||
args.add(constraints.getOffset());
|
||||
}
|
||||
if (constraints.hasLimit() || constraints.hasOffset()) {
|
||||
sql += "\nORDER BY DBX_RN";
|
||||
}
|
||||
return new MetadataSql(sql, args);
|
||||
}
|
||||
|
||||
@@ -722,7 +1103,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
}
|
||||
|
||||
private static List<String> oceanBaseObjectTypes(MetadataListConstraints constraints) {
|
||||
List<String> supported = List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE BODY", "SEQUENCE", "SYNONYM");
|
||||
List<String> supported = List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE BODY", "SEQUENCE", "SYNONYM", "TYPE", "TYPE BODY");
|
||||
if (!constraints.hasObjectTypes()) {
|
||||
return supported;
|
||||
}
|
||||
@@ -895,6 +1276,8 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
Object arg = args.get(index);
|
||||
if (arg instanceof Integer) {
|
||||
stmt.setInt(index + 1, (Integer) arg);
|
||||
} else if (arg instanceof Long) {
|
||||
stmt.setLong(index + 1, (Long) arg);
|
||||
} else {
|
||||
stmt.setString(index + 1, String.valueOf(arg));
|
||||
}
|
||||
@@ -913,12 +1296,148 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return getColumnsInContext(schema, table, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumnsInContext(String schema, String table, String currentSchema) {
|
||||
return unchecked(() -> {
|
||||
String owner = normalizeSchema(schema == null || schema.isBlank() ? currentSchema : schema);
|
||||
String tableName = normalizeObjectName(table);
|
||||
String initialOwner = owner;
|
||||
String initialName = tableName;
|
||||
boolean publicFallback = false;
|
||||
Set<List<String>> visited = new HashSet<>();
|
||||
for (int depth = 0; depth <= 32; depth++) {
|
||||
if (!visited.add(List.of(owner, tableName))) {
|
||||
throw new SQLException("Synonym cycle detected at " + owner + "." + tableName);
|
||||
}
|
||||
List<ColumnInfo> columns = getObjectColumns(owner, tableName);
|
||||
if (!columns.isEmpty()) {
|
||||
if (depth > 0) {
|
||||
if (publicFallback) verifyPublicColumnTarget(initialOwner, initialName, owner, tableName);
|
||||
String objectType = queryColumnObjectType(owner, tableName);
|
||||
for (ColumnInfo column : columns) {
|
||||
column.setResolved_schema(owner);
|
||||
column.setResolved_table(tableName);
|
||||
column.setResolved_object_type(objectType);
|
||||
}
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
String objectType = queryColumnObjectType(owner, tableName);
|
||||
if (objectType != null && !"SYNONYM".equals(objectType)) {
|
||||
throw new SQLException("Column metadata for " + owner + "." + tableName + " (" + objectType + ") is not accessible or not available");
|
||||
}
|
||||
SynonymTarget target = queryColumnSynonym(owner, tableName);
|
||||
if (target == null && objectType == null && depth == 0 && (schema == null || schema.isBlank())) {
|
||||
target = queryColumnSynonym("PUBLIC", tableName);
|
||||
// OceanBase 4.2.5 exposes public synonyms under its internal dictionary owner.
|
||||
if (target == null) target = queryColumnSynonym("__public", tableName);
|
||||
publicFallback = target != null;
|
||||
}
|
||||
if (target == null) {
|
||||
throw new SQLException("Object " + owner + "." + tableName + " not found or not accessible for column metadata");
|
||||
}
|
||||
if (target.databaseLink() != null && !target.databaseLink().isBlank()) {
|
||||
throw new SQLException("Column metadata for DBLink synonyms is not supported: " + owner + "." + tableName);
|
||||
}
|
||||
if (target.owner() == null || target.owner().isBlank() || target.name() == null || target.name().isBlank()) {
|
||||
throw new SQLException("Incomplete synonym target metadata for " + owner + "." + tableName);
|
||||
}
|
||||
owner = target.owner();
|
||||
tableName = target.name();
|
||||
}
|
||||
throw new SQLException("Synonym resolution exceeded 32 links");
|
||||
});
|
||||
}
|
||||
|
||||
private record SynonymTarget(String owner, String name, String databaseLink) {}
|
||||
|
||||
private void verifyPublicColumnTarget(String owner, String name, String targetOwner, String targetName) throws SQLException {
|
||||
// ALL_OBJECTS can hide a foreign-schema object that still shadows a public synonym.
|
||||
// NAME_RESOLVE stops at DBLinks; no remote resolution is performed here.
|
||||
String originalSchema = currentSchema();
|
||||
if (originalSchema.isBlank()) throw new SQLException("Cannot determine current schema for public synonym resolution");
|
||||
boolean switchSchema = !owner.equals(originalSchema);
|
||||
Exception primaryError = null;
|
||||
try {
|
||||
if (switchSchema) {
|
||||
try (var stmt = requireConnection().createStatement()) {
|
||||
stmt.execute(setSchemaSQL(owner));
|
||||
}
|
||||
}
|
||||
try (var stmt = requireConnection().prepareCall("BEGIN DBMS_UTILITY.NAME_RESOLVE(?, ?, ?, ?, ?, ?, ?, ?); END;")) {
|
||||
stmt.setString(1, quoteIdentifier(name));
|
||||
stmt.setInt(2, 0);
|
||||
for (int index = 3; index <= 6; index++) stmt.registerOutParameter(index, Types.VARCHAR);
|
||||
stmt.registerOutParameter(7, Types.NUMERIC);
|
||||
stmt.registerOutParameter(8, Types.NUMERIC);
|
||||
stmt.execute();
|
||||
String databaseLink = stmt.getString(6);
|
||||
if (databaseLink != null && !databaseLink.isBlank()) {
|
||||
throw new SQLException("Column metadata for DBLink synonyms is not supported: " + owner + "." + name);
|
||||
}
|
||||
if (!targetOwner.equals(stmt.getString(3)) || !targetName.equals(stmt.getString(4))) {
|
||||
throw new SQLException("Public synonym target does not match database name resolution for " + owner + "." + name
|
||||
+ "; a local object may be inaccessible");
|
||||
}
|
||||
}
|
||||
} catch (SQLException | RuntimeException error) {
|
||||
primaryError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
if (switchSchema) {
|
||||
try (var stmt = requireConnection().createStatement()) {
|
||||
stmt.execute(setSchemaSQL(originalSchema));
|
||||
} catch (SQLException restoreError) {
|
||||
if (primaryError == null) throw restoreError;
|
||||
primaryError.addSuppressed(restoreError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SynonymTarget queryColumnSynonym(String owner, String name) throws SQLException {
|
||||
String sql = "SELECT TABLE_OWNER, TABLE_NAME, DB_LINK FROM ALL_SYNONYMS WHERE OWNER = ? AND SYNONYM_NAME = ?";
|
||||
try (var stmt = requireConnection().prepareStatement(sql)) {
|
||||
stmt.setString(1, owner);
|
||||
stmt.setString(2, name);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
return rs.next() ? new SynonymTarget(rs.getString("TABLE_OWNER"), rs.getString("TABLE_NAME"), rs.getString("DB_LINK")) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String queryColumnObjectType(String owner, String name) throws SQLException {
|
||||
String sql = """
|
||||
SELECT OBJECT_TYPE FROM ALL_OBJECTS
|
||||
WHERE OWNER = ? AND OBJECT_NAME = ?
|
||||
AND OBJECT_TYPE IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW', 'SYNONYM',
|
||||
'SEQUENCE', 'PROCEDURE', 'FUNCTION', 'PACKAGE', 'TYPE')
|
||||
""";
|
||||
try (var stmt = requireConnection().prepareStatement(sql)) {
|
||||
stmt.setString(1, owner);
|
||||
stmt.setString(2, name);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
String type = null;
|
||||
while (rs.next()) {
|
||||
String candidate = rs.getString("OBJECT_TYPE");
|
||||
if ("MATERIALIZED VIEW".equals(candidate)) return candidate;
|
||||
if (type == null || "VIEW".equals(candidate)) type = candidate;
|
||||
}
|
||||
return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ColumnInfo> getObjectColumns(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
String owner = normalizeSchema(schema);
|
||||
String tableName = normalizeObjectName(table);
|
||||
String sql = """
|
||||
SELECT c.COLUMN_NAME, c.DATA_TYPE, c.NULLABLE, c.DATA_PRECISION, c.DATA_SCALE,
|
||||
c.DATA_LENGTH, c.CHAR_LENGTH, c.DATA_DEFAULT, cc.COMMENTS,
|
||||
c.DATA_LENGTH, c.CHAR_LENGTH, c.CHAR_USED, c.DATA_DEFAULT, cc.COMMENTS,
|
||||
CASE WHEN pk.COLUMN_NAME IS NULL THEN 0 ELSE 1 END AS IS_PK
|
||||
FROM ALL_TAB_COLUMNS c
|
||||
LEFT JOIN ALL_COL_COMMENTS cc
|
||||
@@ -952,7 +1471,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
Integer charLen = intOrNull(rs, "CHAR_LENGTH");
|
||||
result.add(new ColumnInfo(
|
||||
name,
|
||||
formatDataType(baseType, numPrec, numScale, dataLen, charLen),
|
||||
formatDataType(baseType, numPrec, numScale, dataLen, charLen, rs.getString("CHAR_USED")),
|
||||
"Y".equalsIgnoreCase(rs.getString("NULLABLE")),
|
||||
defaultValue,
|
||||
rs.getInt("IS_PK") == 1,
|
||||
@@ -1532,17 +2051,23 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String formatDataType(String base, Integer numPrec, Integer numScale, Integer dataLen, Integer charLen) {
|
||||
private static String formatDataType(String base, Integer numPrec, Integer numScale, Integer dataLen, Integer charLen, String charUsed) {
|
||||
if (base == null || base.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return switch (base.toUpperCase(Locale.ROOT)) {
|
||||
case "VARCHAR2", "NVARCHAR2", "CHAR", "NCHAR" -> {
|
||||
case "VARCHAR2", "CHAR" -> {
|
||||
if ("B".equalsIgnoreCase(charUsed) && dataLen != null) yield base + "(" + dataLen + " BYTE)";
|
||||
if ("C".equalsIgnoreCase(charUsed) && charLen != null) yield base + "(" + charLen + " CHAR)";
|
||||
Integer len = charLen == null ? dataLen : charLen;
|
||||
yield len == null ? base : base + "(" + len + ")";
|
||||
}
|
||||
case "NVARCHAR2", "NCHAR" -> {
|
||||
Integer len = charLen == null ? dataLen : charLen;
|
||||
yield len == null ? base : base + "(" + len + ")";
|
||||
}
|
||||
case "NUMBER" -> {
|
||||
if (numPrec != null && numScale != null && numScale > 0) {
|
||||
if (numPrec != null && numScale != null && numScale != 0) {
|
||||
yield base + "(" + numPrec + "," + numScale + ")";
|
||||
}
|
||||
if (numPrec != null && numPrec > 0) {
|
||||
@@ -1561,6 +2086,6 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
new MultiSessionJsonRpcServer(OceanBaseOracleAgent::new).run();
|
||||
MultiSessionJsonRpcServer.withBlobBindings(OceanBaseOracleAgent::new).run();
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.oceanbase.jdbc;
|
||||
|
||||
/** Connector/J 2.4.18 payload accounting only; never reads or exposes LOB bytes. */
|
||||
public final class DbxLobResourceBytes {
|
||||
private DbxLobResourceBytes() {}
|
||||
|
||||
public static long retainedBytes(Object value) {
|
||||
if (!(value instanceof Lob lob)) return -1;
|
||||
long bytes = 256; // conservative entry/locator metadata allowance
|
||||
if (lob.data != null) bytes += lob.data.length;
|
||||
if (lob.charData != null) bytes += 2L * lob.charData.length();
|
||||
ObLobLocator locator = lob.getLocator();
|
||||
if (locator != null) {
|
||||
if (locator.binaryData != null) bytes += locator.binaryData.length;
|
||||
if (locator instanceof ObLobLocatorV1 v1) {
|
||||
if (v1.rowId != null) bytes += v1.rowId.length;
|
||||
if (v1.tableId != null) bytes += v1.tableId.length;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Blob;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Types;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
class OceanBaseBlobValuesTest {
|
||||
@Test
|
||||
void preservesAllByteValuesAndLargeRandomContentAcrossBoundedChunks() throws Exception {
|
||||
byte[] original = new byte[128 * 1024 + 7];
|
||||
new Random(11438).nextBytes(original);
|
||||
for (int index = 0; index < 256; index++) original[index] = (byte) index;
|
||||
Fixture fixture = new Fixture();
|
||||
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> {
|
||||
assertSame(fixture.blob, locator);
|
||||
assertTrue(limit <= 4096);
|
||||
int start = (int) Math.min(original.length, offset);
|
||||
int end = Math.min(original.length, start + limit);
|
||||
return new OceanBaseLobValues.Chunk("ok", HexFormat.of().formatHex(original, start, end),
|
||||
offset + end - start, end - start < limit, "binary");
|
||||
});
|
||||
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.BLOB, "BLOB");
|
||||
assertEquals("0x" + HexFormat.of().formatHex(original, 0, 256), preview.text());
|
||||
assertEquals("blob", preview.kind());
|
||||
StringBuilder complete = new StringBuilder();
|
||||
long offset = 0;
|
||||
while (true) {
|
||||
var chunk = values.fetch(fixture.connection, preview.ref(), offset, 4096);
|
||||
assertEquals("binary", chunk.value_kind());
|
||||
complete.append(chunk.data());
|
||||
if (chunk.eof()) break;
|
||||
offset = chunk.next_offset();
|
||||
}
|
||||
byte[] fetched = HexFormat.of().parseHex(complete.toString());
|
||||
assertArrayEquals(original, fetched);
|
||||
assertArrayEquals(java.security.MessageDigest.getInstance("SHA-256").digest(original),
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(fetched));
|
||||
var marked = OceanBaseLobValues.mark(List.of("PAYLOAD"), List.of("BLOB"), List.of(List.of(preview)));
|
||||
assertEquals("__DBX_LARGE_VALUE_BYTES_L_0", marked.columns().get(1));
|
||||
assertEquals(preview.text(), marked.rows().get(0).get(0));
|
||||
values.clear();
|
||||
assertTrue(fixture.freed.get());
|
||||
assertEquals("expired", values.fetch(fixture.connection, preview.ref(), 0, 4096).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinguishesEmptyBinaryZeroByteAndNullWithoutDeferredRefs() throws Exception {
|
||||
for (byte[] bytes : List.of(new byte[0], new byte[]{0}, new byte[]{0, (byte) 255})) {
|
||||
Fixture fixture = new Fixture();
|
||||
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) ->
|
||||
new OceanBaseLobValues.Chunk("ok", HexFormat.of().formatHex(bytes), bytes.length, true, "binary"));
|
||||
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.BLOB, "BLOB");
|
||||
assertEquals("0x" + HexFormat.of().formatHex(bytes), preview.text());
|
||||
assertNull(preview.ref());
|
||||
assertTrue(fixture.freed.get());
|
||||
}
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.nullValue = true;
|
||||
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> { fail("NULL must not be read"); return null; });
|
||||
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.BLOB, "BLOB");
|
||||
assertNull(preview.text());
|
||||
assertNull(preview.ref());
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final AtomicBoolean freed = new AtomicBoolean();
|
||||
boolean nullValue;
|
||||
final Connection connection = proxy(Connection.class, (method, args) -> method.equals("isClosed") ? false : null);
|
||||
final Blob blob = proxy(Blob.class, (method, args) -> {
|
||||
if (method.equals("free")) { freed.set(true); return null; }
|
||||
throw new AssertionError("Unexpected full BLOB API: " + method);
|
||||
});
|
||||
final Statement statement = proxy(Statement.class, (method, args) -> method.equals("getConnection") ? connection : null);
|
||||
final ResultSetMetaData metadata = proxy(ResultSetMetaData.class, (method, args) -> {
|
||||
if (method.equals("getColumnCount")) return 1;
|
||||
if (method.equals("getColumnLabel")) return "PAYLOAD";
|
||||
return null;
|
||||
});
|
||||
final ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if (method.equals("getMetaData")) return metadata;
|
||||
if (method.equals("getStatement")) return statement;
|
||||
if (method.equals("getBlob")) return nullValue ? null : blob;
|
||||
if (method.equals("wasNull")) return nullValue;
|
||||
throw new AssertionError("Unexpected result read: " + method);
|
||||
});
|
||||
}
|
||||
@FunctionalInterface interface Handler { Object call(String method, Object[] args) throws Throwable; }
|
||||
private static <T> T proxy(Class<T> type, Handler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type},
|
||||
(object, method, args) -> handler.call(method.getName(), args)));
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.Types;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Opt-in isolated Oracle tenant only. Run during the unified acceptance phase. */
|
||||
class OceanBaseLobSnapshotIntegrationTest {
|
||||
@Test
|
||||
void capturesOriginalBlobBytesAcrossCursorCloseAndConcurrentUpdate() throws Exception {
|
||||
String url = System.getenv("OB_LOB_INTEGRATION_URL");
|
||||
Assumptions.assumeTrue(url != null && !url.isBlank(), "Isolated OB LOB integration environment not configured");
|
||||
String user = System.getenv("OB_LOB_INTEGRATION_USER");
|
||||
String password = System.getenv("OB_LOB_INTEGRATION_PASSWORD");
|
||||
String table = "DBX_O13_" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase();
|
||||
OceanBaseLobValues values = new OceanBaseLobValues();
|
||||
try (var connection = DriverManager.getConnection(url, user, password);
|
||||
var other = DriverManager.getConnection(url, user, password)) {
|
||||
try (var statement = connection.createStatement(); var tenant = statement.executeQuery("SELECT SYS_CONTEXT('USERENV','CON_NAME') FROM DUAL")) {
|
||||
assertTrue(tenant.next());
|
||||
assertEquals("oracletest", tenant.getString(1).toLowerCase(), "Refuse writes outside the authorized isolated tenant");
|
||||
}
|
||||
boolean created = false;
|
||||
try {
|
||||
try (var statement = connection.createStatement()) {
|
||||
statement.execute("CREATE TABLE " + table + " (ID NUMBER PRIMARY KEY, PAYLOAD BLOB)");
|
||||
created = true;
|
||||
}
|
||||
byte[] original = new byte[1024 * 1024 + 7];
|
||||
new java.util.Random(11438).nextBytes(original);
|
||||
for (int index = 0; index < 256; index++) original[index] = (byte) index;
|
||||
try (var insert = connection.prepareStatement("INSERT INTO " + table + " VALUES (1, ?)")) {
|
||||
insert.setBytes(1, original);
|
||||
insert.executeUpdate();
|
||||
}
|
||||
OceanBaseLobValues.Preview preview;
|
||||
try (var statement = connection.createStatement(); var result = statement.executeQuery("SELECT PAYLOAD FROM " + table + " WHERE ID = 1")) {
|
||||
assertTrue(result.next());
|
||||
preview = (OceanBaseLobValues.Preview) values.preview(result, 1, Types.BLOB, "BLOB");
|
||||
}
|
||||
assertNotNull(preview.ref());
|
||||
assertEquals("0x" + java.util.HexFormat.of().formatHex(original, 0, 256), preview.text());
|
||||
try (var update = other.prepareStatement("UPDATE " + table + " SET PAYLOAD = ? WHERE ID = 1")) {
|
||||
update.setBytes(1, new byte[]{1, 2, 3});
|
||||
assertEquals(1, update.executeUpdate());
|
||||
}
|
||||
StringBuilder complete = new StringBuilder();
|
||||
long offset = 0;
|
||||
while (true) {
|
||||
var chunk = values.fetch(connection, preview.ref(), offset, 4096);
|
||||
assertEquals("binary", chunk.value_kind());
|
||||
complete.append(chunk.data());
|
||||
if (chunk.eof()) break;
|
||||
offset = chunk.next_offset();
|
||||
}
|
||||
assertArrayEquals(original, java.util.HexFormat.of().parseHex(complete.toString()));
|
||||
} finally {
|
||||
values.clear();
|
||||
if (created) try (var statement = connection.createStatement()) { statement.execute("DROP TABLE " + table + " PURGE"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void capturesOriginalClobAcrossCursorCloseAndConcurrentUpdateAndDelete() throws Exception {
|
||||
String url = System.getenv("OB_LOB_INTEGRATION_URL");
|
||||
Assumptions.assumeTrue(url != null && !url.isBlank(), "Isolated OB LOB integration environment not configured");
|
||||
String user = System.getenv("OB_LOB_INTEGRATION_USER");
|
||||
String password = System.getenv("OB_LOB_INTEGRATION_PASSWORD");
|
||||
String table = "DBX_O13_" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase();
|
||||
OceanBaseLobValues values = new OceanBaseLobValues();
|
||||
try (var connection = DriverManager.getConnection(url, user, password);
|
||||
var other = DriverManager.getConnection(url, user, password)) {
|
||||
try (var statement = connection.createStatement(); var tenant = statement.executeQuery("SELECT SYS_CONTEXT('USERENV','CON_NAME') FROM DUAL")) {
|
||||
assertTrue(tenant.next());
|
||||
assertEquals("oracletest", tenant.getString(1).toLowerCase(), "Refuse writes outside the authorized isolated tenant");
|
||||
}
|
||||
boolean created = false;
|
||||
try {
|
||||
try (var statement = connection.createStatement()) {
|
||||
statement.execute("CREATE TABLE " + table + " (ID NUMBER PRIMARY KEY, PAYLOAD CLOB)");
|
||||
created = true;
|
||||
}
|
||||
String original = "中文😀完整原值".repeat(12_000);
|
||||
try (var insert = connection.prepareStatement("INSERT INTO " + table + " VALUES (1, ?)")) {
|
||||
insert.setString(1, original);
|
||||
insert.executeUpdate();
|
||||
}
|
||||
OceanBaseLobValues.Preview preview;
|
||||
try (var statement = connection.createStatement(); var result = statement.executeQuery("SELECT PAYLOAD FROM " + table + " WHERE ID = 1")) {
|
||||
assertTrue(result.next());
|
||||
preview = (OceanBaseLobValues.Preview) values.preview(result, 1, Types.CLOB, "CLOB");
|
||||
}
|
||||
assertNotNull(preview.ref());
|
||||
assertEquals(256, preview.text().codePointCount(0, preview.text().length()));
|
||||
try (var update = other.prepareStatement("UPDATE " + table + " SET PAYLOAD = ? WHERE ID = 1")) {
|
||||
update.setString(1, "已经变更😀".repeat(12_000));
|
||||
assertEquals(1, update.executeUpdate());
|
||||
}
|
||||
try (var statement = other.createStatement()) { assertEquals(1, statement.executeUpdate("DELETE FROM " + table + " WHERE ID = 1")); }
|
||||
StringBuilder complete = new StringBuilder();
|
||||
long offset = 0;
|
||||
while (true) {
|
||||
var chunk = values.fetch(connection, preview.ref(), offset, 4096);
|
||||
assertEquals("ok", chunk.status());
|
||||
complete.append(chunk.data());
|
||||
if (chunk.eof()) break;
|
||||
offset = chunk.next_offset();
|
||||
}
|
||||
assertEquals(original, complete.toString());
|
||||
values.clear();
|
||||
assertEquals("expired", values.fetch(connection, preview.ref(), 0, 4096).status());
|
||||
} finally {
|
||||
values.clear();
|
||||
if (created) try (var statement = connection.createStatement()) { statement.execute("DROP TABLE " + table + " PURGE"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.dbx.agent.BlobBoundExecutor;
|
||||
import com.dbx.agent.BlobBoundStatement;
|
||||
import com.oceanbase.jdbc.OceanBaseConnection;
|
||||
import com.oceanbase.jdbc.OceanBaseStatement;
|
||||
import com.oceanbase.jdbc.internal.protocol.Protocol;
|
||||
import com.oceanbase.jdbc.util.Options;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Blob;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.Clob;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import javax.sql.DataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OceanBaseLobStatementTest {
|
||||
@Test void onlyConfirmedObBusinessErrorIsEligibleForSavepointKeep() {
|
||||
assertTrue(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLTransientConnectionException("ORA-20001: stale", "HY000", 20001)));
|
||||
assertFalse(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLTransientConnectionException("connection lost", "08006", 20001)));
|
||||
assertFalse(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLTransientConnectionException("other error", "HY000", 600)));
|
||||
assertFalse(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLRecoverableException("lost", "HY000", 20001)));
|
||||
assertFalse(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLTimeoutException("timeout", "HY000", 20001)));
|
||||
assertFalse(OceanBaseLobStatements.BINDING.isRollbackConfirmedBusinessError(new java.sql.SQLTransientConnectionException("stale", "HY000", 20001, new SQLException("lost", "08006"))));
|
||||
assertTrue(OceanBaseLobStatements.BINDING.isUnsupportedSavepointRelease(new SQLException("unsupported", "99999", 17023)));
|
||||
assertFalse(OceanBaseLobStatements.BINDING.isUnsupportedSavepointRelease(new java.sql.SQLTransientConnectionException("lost", "08006", 17023)));
|
||||
assertFalse(OceanBaseLobStatements.BINDING.isUnsupportedSavepointRelease(new java.sql.SQLTimeoutException("timeout", "99999", 17023)));
|
||||
}
|
||||
@Test
|
||||
void boundPreparedAndCallableInitializeNativeModeBeforeStreamBindingThroughPool() throws Exception {
|
||||
for (boolean callable : new boolean[]{false, true}) {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Options options = new Options();
|
||||
Protocol protocol = proxy(Protocol.class, (object, method, args) -> {
|
||||
if (method.getName().equals("getOptions")) return options;
|
||||
if (method.getName().equals("getLock")) return new ReentrantLock();
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
OceanBaseStatement vendor = new OceanBaseStatement(new OceanBaseConnection(protocol), 1003, 1007, null);
|
||||
java.lang.reflect.InvocationHandler handler = (object, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "unwrap": return ((Class<?>) args[0]).cast(vendor);
|
||||
case "isWrapperFor": return ((Class<?>) args[0]).isInstance(vendor);
|
||||
case "setBlob":
|
||||
assertTrue(vendor.isInternal(), "native mode must precede the first stream binding");
|
||||
assertEquals(3L, args[2]);
|
||||
assertArrayEquals(new byte[]{0, (byte)255, (byte)128}, ((java.io.InputStream) args[1]).readAllBytes());
|
||||
calls.add("bind"); return null;
|
||||
case "execute": assertTrue(JdbcExecutor.current().hasActiveStatements()); calls.add("execute"); return false;
|
||||
case "getUpdateCount": return 1;
|
||||
case "close": calls.add("close"); return null;
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
};
|
||||
PreparedStatement delegate = callable ? proxy(CallableStatement.class, handler) : proxy(PreparedStatement.class, handler);
|
||||
Connection physical = proxy(Connection.class, (object, method, args) -> {
|
||||
if (method.getName().equals(callable ? "prepareCall" : "prepareStatement")) return delegate;
|
||||
if (method.getName().equals("getMetaData")) return proxy(java.sql.DatabaseMetaData.class, (o,m,a) -> m.getName().equals("supportsTransactions") ? true : defaultValue(m.getReturnType()));
|
||||
if (method.getName().equals("getAutoCommit") || method.getName().equals("isValid")) return true;
|
||||
if (method.getName().equals("getTransactionIsolation")) return Connection.TRANSACTION_READ_COMMITTED;
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
DataSource source = proxy(DataSource.class, (object, method, args) -> method.getName().equals("getConnection") ? physical : defaultValue(method.getReturnType()));
|
||||
String sql = callable ? "BEGIN UPDATE t SET b = ?; END;" : "UPDATE t SET b = ?";
|
||||
try (HikariDataSource pool = new HikariDataSource()) {
|
||||
pool.setDataSource(source); pool.setMaximumPoolSize(1); pool.setMinimumIdle(0);
|
||||
try (Connection connection = pool.getConnection()) {
|
||||
BlobBoundExecutor.execute(connection, List.of("preview"),
|
||||
List.of(new BlobBoundStatement("preview", sql, List.of("00ff80"))),
|
||||
null, ignored -> null, () -> null, 20, false, OceanBaseLobStatements::configure);
|
||||
}
|
||||
}
|
||||
assertEquals(List.of("bind", "execute", "close"), calls);
|
||||
assertFalse(JdbcExecutor.current().hasActiveStatements());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundNativeInitializationFailureClosesEntirePreparedBatchBeforeAnyWrite() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Options options = new Options();
|
||||
Protocol protocol = proxy(Protocol.class, (object, method, args) -> {
|
||||
if (method.getName().equals("getOptions")) return options;
|
||||
if (method.getName().equals("getLock")) return new ReentrantLock();
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
OceanBaseStatement vendor = new OceanBaseStatement(new OceanBaseConnection(protocol), 1003, 1007, null);
|
||||
int[] prepared = {0};
|
||||
Connection connection = proxy(Connection.class, (object, method, args) -> {
|
||||
if (method.getName().equals("getAutoCommit")) return true;
|
||||
if (method.getName().equals("getMetaData")) return proxy(java.sql.DatabaseMetaData.class, (o,m,a) -> m.getName().equals("supportsTransactions") ? true : defaultValue(m.getReturnType()));
|
||||
if (method.getName().equals("prepareStatement")) {
|
||||
int index = ++prepared[0];
|
||||
return proxy(PreparedStatement.class, (o, m, a) -> {
|
||||
if (m.getName().equals("unwrap")) {
|
||||
if (index == 2) throw new SQLException("unsupported native wrapper");
|
||||
return vendor;
|
||||
}
|
||||
if (m.getName().equals("close")) calls.add("close" + index);
|
||||
if (m.getName().equals("execute")) calls.add("write" + index);
|
||||
return defaultValue(m.getReturnType());
|
||||
});
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> BlobBoundExecutor.execute(connection,
|
||||
List.of("first", "second"), List.of(new BlobBoundStatement("first", "UPDATE t SET b = ?", List.of("00")),
|
||||
new BlobBoundStatement("second", "UPDATE t SET b = ?", List.of("01"))),
|
||||
null, ignored -> null, () -> null, 20, false, OceanBaseLobStatements::configure));
|
||||
assertEquals("Unsupported OceanBase LOB statement", error.getCause().getMessage());
|
||||
assertEquals(2, calls.size());
|
||||
assertTrue(calls.containsAll(List.of("close1", "close2")));
|
||||
assertFalse(JdbcExecutor.current().hasActiveStatements());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverLengthMarksExactLastChunkAndPastEndWithoutHidingReadErrors() throws Exception {
|
||||
for (boolean binary : new boolean[]{false, true}) {
|
||||
for (String scenario : List.of("exact", "past-end", "beyond-end", "full-interior", "read-error")) {
|
||||
Options options = new Options();
|
||||
Protocol protocol = proxy(Protocol.class, (object, method, args) -> {
|
||||
if (method.getName().equals("getOptions")) return options;
|
||||
if (method.getName().equals("getLock")) return new ReentrantLock();
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
OceanBaseStatement vendor = new OceanBaseStatement(new OceanBaseConnection(protocol), 1003, 1007, null);
|
||||
// JDBC UTF-16 length deliberately differs from the server's code-point length.
|
||||
Object locator = binary ? proxy(Blob.class, (o, m, a) -> m.getName().equals("length") ? 10L : defaultValue(m.getReturnType()))
|
||||
: proxy(Clob.class, (o, m, a) -> m.getName().equals("length") ? 10L : defaultValue(m.getReturnType()));
|
||||
SQLException failure = new SQLException("ORA-06502: real read failure", "HY000", 6502);
|
||||
CallableStatement call = proxy(CallableStatement.class, (object, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "unwrap": return vendor;
|
||||
case "execute": if (scenario.equals("read-error")) throw failure; return false;
|
||||
case "getInt": return scenario.equals("past-end") || scenario.equals("beyond-end") ? 0 : 3;
|
||||
case "getLong": return scenario.equals("full-interior") ? 9L : 8L;
|
||||
case "getBytes": return scenario.equals("past-end") || scenario.equals("beyond-end") ? null : binary ? new byte[]{0, (byte) 255, 1} : "中😀末".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
Connection connection = proxy(Connection.class, (object, method, args) -> method.getName().equals("prepareCall") ? call : defaultValue(method.getReturnType()));
|
||||
if (scenario.equals("read-error")) {
|
||||
assertSame(failure, assertThrows(SQLException.class, () -> OceanBaseLobValues.read(connection, locator, 5, 3)));
|
||||
} else {
|
||||
var chunk = OceanBaseLobValues.read(connection, locator, scenario.equals("beyond-end") ? 9 : scenario.equals("past-end") ? 8 : 5, 3);
|
||||
assertEquals(scenario.equals("past-end") || scenario.equals("beyond-end") ? "" : binary ? "00ff01" : "中😀末", chunk.data());
|
||||
assertEquals(scenario.equals("beyond-end") ? 9 : 8, chunk.next_offset());
|
||||
assertEquals(!scenario.equals("full-interior"), chunk.eof(), "EOF must use server length, not JDBC UTF-16 length");
|
||||
}
|
||||
assertFalse(JdbcExecutor.current().hasActiveStatements());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyLocatorReturnsEmptyEofWithoutCallingDbmsLobRead() throws Exception {
|
||||
for (boolean binary : new boolean[]{false, true}) {
|
||||
java.lang.reflect.InvocationHandler handler = (object, method, args) -> {
|
||||
if (method.getName().equals("length")) return 0L;
|
||||
throw new AssertionError("Empty LOB must not be materialized");
|
||||
};
|
||||
Object locator = binary ? proxy(Blob.class, handler) : proxy(Clob.class, handler);
|
||||
Connection connection = proxy(Connection.class, (object, method, args) -> {
|
||||
if (method.getName().equals("prepareCall")) throw new SQLException("ORA-06502: no data found", "HY000", 6502);
|
||||
throw new AssertionError("Empty LOB must not execute a statement");
|
||||
});
|
||||
var chunk = OceanBaseLobValues.read(connection, locator, 0, 257);
|
||||
assertEquals("", chunk.data());
|
||||
assertEquals(0, chunk.next_offset());
|
||||
assertTrue(chunk.eof());
|
||||
assertEquals(binary ? "binary" : "text", chunk.value_kind());
|
||||
assertFalse(JdbcExecutor.current().hasActiveStatements());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pooledCallableUnwrapsVendorOnlyForInternalFlagAndKeepsLifecycleOnProxy() throws Exception {
|
||||
for (boolean binary : new boolean[]{false, true}) {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Options options = new Options();
|
||||
Protocol protocol = proxy(Protocol.class, (object, method, args) -> {
|
||||
if (method.getName().equals("getOptions")) return options;
|
||||
if (method.getName().equals("getLock")) return new ReentrantLock();
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
OceanBaseStatement vendor = new OceanBaseStatement(new OceanBaseConnection(protocol), 1003, 1007, null);
|
||||
Object locator = binary ? proxy(Blob.class, (o, m, a) -> m.getName().equals("length") ? 8L : defaultValue(m.getReturnType()))
|
||||
: proxy(Clob.class, (o, m, a) -> m.getName().equals("length") ? 8L : defaultValue(m.getReturnType()));
|
||||
CallableStatement delegate = proxy(CallableStatement.class, (object, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "unwrap": return ((Class<?>) args[0]).cast(vendor);
|
||||
case "isWrapperFor": return ((Class<?>) args[0]).isInstance(vendor);
|
||||
case "setBlob", "setClob": assertSame(locator, args[1]); calls.add(method.getName()); return null;
|
||||
case "setInt": assertEquals(3, args[1]); return null;
|
||||
case "setLong": assertEquals(6L, args[1]); return null;
|
||||
case "setQueryTimeout": assertEquals(20, args[0]); return null;
|
||||
case "registerOutParameter": assertEquals((int) args[0] == 4 ? Types.INTEGER : (int) args[0] == 5 ? (binary ? Types.VARBINARY : Types.VARCHAR) : Types.BIGINT, args[1]); return null;
|
||||
case "execute":
|
||||
assertTrue(vendor.isInternal());
|
||||
assertTrue(JdbcExecutor.current().hasActiveStatements());
|
||||
JdbcExecutor.current().cancelActiveStatements();
|
||||
calls.add("execute"); return false;
|
||||
case "cancel", "close": calls.add(method.getName()); return null;
|
||||
case "getInt": return 2;
|
||||
case "getLong": return 7L;
|
||||
case "getBytes": return binary ? new byte[]{0, (byte) 255} : "中😀".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
Connection physical = proxy(Connection.class, (object, method, args) -> {
|
||||
if (method.getName().equals("prepareCall")) return delegate;
|
||||
if (method.getName().equals("getAutoCommit") || method.getName().equals("isValid")) return true;
|
||||
if (method.getName().equals("getTransactionIsolation")) return Connection.TRANSACTION_READ_COMMITTED;
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
DataSource source = proxy(DataSource.class, (object, method, args) -> method.getName().equals("getConnection") ? physical : defaultValue(method.getReturnType()));
|
||||
try (HikariDataSource pool = new HikariDataSource()) {
|
||||
pool.setDataSource(source);
|
||||
pool.setMaximumPoolSize(1);
|
||||
pool.setMinimumIdle(0);
|
||||
try (Connection connection = pool.getConnection()) {
|
||||
var chunk = OceanBaseLobValues.read(connection, locator, 5, 3);
|
||||
assertEquals(binary ? "00ff" : "中😀", chunk.data());
|
||||
assertEquals(7, chunk.next_offset());
|
||||
assertTrue(chunk.eof());
|
||||
assertEquals(binary ? "binary" : "text", chunk.value_kind());
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(binary ? "setBlob" : "setClob", "cancel", "execute", "close"), calls);
|
||||
assertFalse(JdbcExecutor.current().hasActiveStatements());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonOceanBaseCallableIsRejectedAndClosedBeforeExecuting() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
CallableStatement call = proxy(CallableStatement.class, (object, method, args) -> {
|
||||
if (method.getName().equals("unwrap")) throw new SQLException("Not a vendor wrapper");
|
||||
if (method.getName().equals("close") || method.getName().equals("execute")) calls.add(method.getName());
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
Connection connection = proxy(Connection.class, (object, method, args) -> method.getName().equals("prepareCall") ? call : defaultValue(method.getReturnType()));
|
||||
SQLException error = assertThrows(SQLException.class, () -> OceanBaseLobValues.read(connection, proxy(Clob.class, (o, m, a) -> m.getName().equals("length") ? 1L : null), 0, 1));
|
||||
assertEquals("Unsupported OceanBase LOB statement", error.getMessage());
|
||||
assertEquals(List.of("close"), calls);
|
||||
assertFalse(JdbcExecutor.current().hasActiveStatements());
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == boolean.class) return false;
|
||||
if (type == int.class) return 0;
|
||||
if (type == long.class) return 0L;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static <T> T proxy(Class<T> type, java.lang.reflect.InvocationHandler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import com.dbx.agent.ExecuteQueryOptions;
|
||||
import com.dbx.agent.QueryPageOptions;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Clob;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
class OceanBaseLobValuesTest {
|
||||
@ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"query", "page", "table"})
|
||||
void longCteLiteralReachesJdbcAndRetainsPreviousLocator(String entryPoint) throws Exception {
|
||||
checkFailedStatement(entryPoint, false, "WITH x AS (SELECT '" + "a".repeat(4000) + "' AS s FROM dual) SELECT * FROM x", true);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"query,false", "query,true", "page,false", "page,true", "table,false", "table,true", "cte-query,false", "cte-page,true", "cte-table,true"})
|
||||
void failedReadReleasesOnlyItsNewLocators(String entryPoint, boolean capturePartial) throws Exception {
|
||||
String sql = entryPoint.startsWith("cte-") ? "WITH x AS (SELECT 1 AS n FROM dual) SELECT * FROM x" : "SELECT INVALID FROM DUAL";
|
||||
checkFailedStatement(entryPoint.replace("cte-", ""), capturePartial, sql, true);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(delimiter = '|', quoteCharacter = '`', value = {
|
||||
"WITH x(n) AS (SELECT 1 FROM dual), y AS (SELECT n FROM x) SELECT * FROM y|true",
|
||||
"WITH c AS (SELECT q'[it's (]' txt FROM dual) SELECT txt FROM c|true",
|
||||
"WITH c AS (SELECT q'[it's ;]' txt FROM dual) SELECT txt FROM c|true",
|
||||
"WITH c AS (SELECT Q'!it's ; (!' txt FROM dual) SELECT txt FROM c|true",
|
||||
"WITH \"x\" AS (SELECT ') ; UPDATE' FROM dual /* ) */) SELECT * FROM \"x\"|true",
|
||||
"WITH x AS (SELECT 1 FROM dual) UPDATE target SET n = 1|false",
|
||||
"WITH x AS (DELETE FROM target RETURNING n) SELECT * FROM x|false",
|
||||
"WITH x AS (SELECT 1 FROM dual) SELECT * FROM x; COMMIT|false",
|
||||
"WITH FUNCTION f RETURN NUMBER IS BEGIN RETURN 1; END; SELECT f FROM dual|false",
|
||||
"COMMIT|false",
|
||||
"ROLLBACK|false"
|
||||
})
|
||||
void onlyConfirmedCteQueriesRetainOldLocators(String sql, boolean retain) throws Exception {
|
||||
checkFailedStatement("query", false, sql, retain);
|
||||
}
|
||||
|
||||
private void checkFailedStatement(String entryPoint, boolean capturePartial, String sql, boolean retain) throws Exception {
|
||||
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> chunk("x".repeat(1000), offset, limit));
|
||||
Fixture old = new Fixture();
|
||||
var retained = (OceanBaseLobValues.Preview) values.preview(old.resultSet, 1, Types.CLOB, "CLOB");
|
||||
Fixture partial = new Fixture();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
var field = OceanBaseOracleAgent.class.getDeclaredField("lobValues");
|
||||
field.setAccessible(true);
|
||||
field.set(agent, values);
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
|
||||
if (method.equals("isClosed")) return false;
|
||||
if (method.equals("createStatement")) {
|
||||
if (capturePartial) values.preview(partial.resultSet, 1, Types.CLOB, "CLOB");
|
||||
throw new java.sql.SQLException("failed request", "42000");
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
assertThrows(RuntimeException.class, () -> {
|
||||
switch (entryPoint) {
|
||||
case "query" -> agent.executeQuery(sql, null, new ExecuteQueryOptions(10, null, 5));
|
||||
case "page" -> agent.executeQueryPage(sql, null, new QueryPageOptions(10, null, 10, 5));
|
||||
default -> agent.startTableRead(sql, null, new QueryPageOptions(10, null, 10, 5));
|
||||
}
|
||||
});
|
||||
assertEquals(!retain, old.freed.get());
|
||||
assertEquals(capturePartial, partial.freed.get());
|
||||
if (retain) assertEquals("ok", values.fetch(old.connection, retained.ref(), 0, 10).status());
|
||||
agent.invalidateLargeValues();
|
||||
assertTrue(old.freed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesNewLocatorsWhenRetainedPayloadBudgetIsFullAndRestoresCapacityOnRelease() throws Exception {
|
||||
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> chunk("x".repeat(1000), offset, limit), ignored -> 40L * 1024 * 1024);
|
||||
Fixture first = new Fixture();
|
||||
var preview = (OceanBaseLobValues.Preview) values.preview(first.resultSet, 1, Types.CLOB, "CLOB");
|
||||
Fixture second = new Fixture();
|
||||
assertThrows(java.sql.SQLException.class, () -> values.preview(second.resultSet, 1, Types.CLOB, "CLOB"));
|
||||
assertTrue(second.freed.get());
|
||||
assertTrue(values.release(preview.ref()));
|
||||
Fixture third = new Fixture();
|
||||
assertNotNull(((OceanBaseLobValues.Preview) values.preview(third.resultSet, 1, Types.CLOB, "CLOB")).ref());
|
||||
values.clear();
|
||||
Fixture fourth = new Fixture();
|
||||
assertNotNull(((OceanBaseLobValues.Preview) values.preview(fourth.resultSet, 1, Types.CLOB, "CLOB")).ref());
|
||||
values.clear();
|
||||
}
|
||||
@Test
|
||||
void boundsPreviewAndReadsTheSameCapturedLocatorWithoutMaterializingItsStreams() throws Exception {
|
||||
String original = "中文😀".repeat(1000);
|
||||
List<Integer> amounts = new ArrayList<>();
|
||||
Fixture fixture = new Fixture();
|
||||
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> {
|
||||
assertSame(fixture.connection, connection);
|
||||
assertSame(fixture.clob, locator);
|
||||
amounts.add(limit);
|
||||
return chunk(original, offset, limit);
|
||||
});
|
||||
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.CLOB, "CLOB");
|
||||
assertEquals(256, preview.text().codePointCount(0, preview.text().length()));
|
||||
assertFalse(Character.isHighSurrogate(preview.text().charAt(preview.text().length() - 1)));
|
||||
assertNotNull(preview.ref());
|
||||
assertEquals(List.of(257), amounts);
|
||||
StringBuilder complete = new StringBuilder();
|
||||
long offset = 0;
|
||||
while (true) {
|
||||
var next = values.fetch(fixture.connection, preview.ref(), offset, 31);
|
||||
complete.append(next.data());
|
||||
if (next.eof()) break;
|
||||
assertTrue(next.next_offset() > offset);
|
||||
offset = next.next_offset();
|
||||
}
|
||||
assertEquals(original, complete.toString());
|
||||
assertFalse(fixture.freed.get());
|
||||
assertTrue(values.release(preview.ref()));
|
||||
assertTrue(fixture.freed.get());
|
||||
assertEquals("expired", values.fetch(fixture.connection, preview.ref(), 0, 31).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinguishesNullEmptyShortAndDeferredValues() throws Exception {
|
||||
for (String text : List.of("", "短😀值", "x".repeat(257))) {
|
||||
Fixture fixture = new Fixture();
|
||||
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> chunk(text, offset, limit));
|
||||
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.CLOB, "CLOB");
|
||||
// A value exactly filling the look-ahead chunk remains deferred until EOF is known.
|
||||
if (text.length() < 257) {
|
||||
assertEquals(text, preview.text());
|
||||
assertNull(preview.ref());
|
||||
assertTrue(fixture.freed.get());
|
||||
} else assertNotNull(preview.ref());
|
||||
values.clear();
|
||||
}
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.nullValue = true;
|
||||
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> { fail("NULL must not be read"); return null; });
|
||||
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.CLOB, "CLOB");
|
||||
assertNull(preview.text());
|
||||
assertNull(preview.ref());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongConnectionAndInvalidChunkBoundsAndInvalidatesOnClear() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> chunk("原值".repeat(1000), offset, limit));
|
||||
var preview = (OceanBaseLobValues.Preview) values.preview(fixture.resultSet, 1, Types.CLOB, "CLOB");
|
||||
assertThrows(IllegalArgumentException.class, () -> values.fetch(fixture.connection, preview.ref(), -1, 1));
|
||||
assertThrows(IllegalArgumentException.class, () -> values.fetch(fixture.connection, preview.ref(), 0, 4097));
|
||||
assertEquals("expired", values.fetch(new Fixture().connection, preview.ref(), 0, 1).status());
|
||||
Fixture nextFixture = new Fixture();
|
||||
var nextPreview = (OceanBaseLobValues.Preview) values.preview(nextFixture.resultSet, 1, Types.CLOB, "CLOB");
|
||||
values.clear();
|
||||
assertEquals("expired", values.fetch(nextFixture.connection, nextPreview.ref(), 0, 1).status());
|
||||
assertTrue(fixture.freed.get());
|
||||
assertTrue(nextFixture.freed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void markerCollisionFallsBackBeforeReadingOrTruncatingAndRefsStayOutOfValues() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.label = "__dbx_large_value_bytes_c_0";
|
||||
OceanBaseLobValues values = new OceanBaseLobValues((connection, locator, offset, limit) -> { fail("Collision must keep complete ordinary read"); return null; });
|
||||
assertNull(values.preview(fixture.resultSet, 1, Types.CLOB, "CLOB"));
|
||||
var marked = OceanBaseLobValues.mark(List.of("ID", "Payload"), List.of("NUMBER", "CLOB"),
|
||||
List.of(List.of(1, new OceanBaseLobValues.Preview("原始😀", "opaque-ref")),
|
||||
java.util.Arrays.asList(2, new OceanBaseLobValues.Preview(null, null))));
|
||||
assertEquals(List.of("ID", "Payload", "__DBX_LARGE_VALUE_BYTES_C_1"), marked.columns());
|
||||
assertEquals("原始😀", marked.rows().get(0).get(1));
|
||||
assertEquals("D:1:unknown:opaque-ref", marked.rows().get(0).get(2));
|
||||
assertNull(marked.rows().get(1).get(1));
|
||||
assertNull(marked.rows().get(1).get(2));
|
||||
}
|
||||
|
||||
private static OceanBaseLobValues.Chunk chunk(String text, long offset, int limit) {
|
||||
int count = text.codePointCount(0, text.length());
|
||||
int start = Math.min(count, (int) offset);
|
||||
int end = Math.min(count, start + limit);
|
||||
String data = text.substring(text.offsetByCodePoints(0, start), text.offsetByCodePoints(0, end));
|
||||
return new OceanBaseLobValues.Chunk("ok", data, offset + end - start, end - start < limit, "text");
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final AtomicBoolean freed = new AtomicBoolean();
|
||||
boolean nullValue;
|
||||
String label = "PAYLOAD";
|
||||
final Connection connection = proxy(Connection.class, (method, args) -> method.equals("isClosed") ? false : null);
|
||||
final Clob clob = proxy(Clob.class, (method, args) -> {
|
||||
if (method.equals("free")) { freed.set(true); return null; }
|
||||
throw new AssertionError("Unexpected full LOB API: " + method);
|
||||
});
|
||||
final Statement statement = proxy(Statement.class, (method, args) -> method.equals("getConnection") ? connection : null);
|
||||
final ResultSetMetaData metadata = proxy(ResultSetMetaData.class, (method, args) -> {
|
||||
if (method.equals("getColumnCount")) return 1;
|
||||
if (method.equals("getColumnLabel")) return label;
|
||||
return null;
|
||||
});
|
||||
final ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if (method.equals("getMetaData")) return metadata;
|
||||
if (method.equals("getStatement")) return statement;
|
||||
if (method.equals("getClob")) return nullValue ? null : clob;
|
||||
if (method.equals("wasNull")) return nullValue;
|
||||
throw new AssertionError("Unexpected result read: " + method);
|
||||
});
|
||||
}
|
||||
|
||||
@FunctionalInterface interface Handler { Object call(String method, Object[] args) throws Throwable; }
|
||||
private static <T> T proxy(Class<T> type, Handler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type},
|
||||
(object, method, args) -> handler.call(method.getName(), args)));
|
||||
}
|
||||
}
|
||||
+240
-2
@@ -1,6 +1,7 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.CompletionAssistantCandidateKind;
|
||||
import com.dbx.agent.CompletionAssistantMatchMode;
|
||||
import com.dbx.agent.CompletionAssistantObjectKind;
|
||||
import com.dbx.agent.CompletionAssistantRequest;
|
||||
@@ -485,6 +486,220 @@ class OceanBaseOracleAgentTest {
|
||||
Assertions.assertEquals(List.of("ORD%", "STAGING", "ORD%", "STAGING", "DWD", "ORD", 21), query.args);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionAssistantSearchReturnsBoundedStandaloneRoutines() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
|
||||
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
|
||||
new Object[][]{{"APP", "CALC", "PROCEDURE"}, {"APP", "CALCULATE", "FUNCTION"}, {"OTHER", "CALCULATE", "FUNCTION"}}
|
||||
)));
|
||||
CompletionAssistantRequest request = completionRequest("APP", null, "CAL", true);
|
||||
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
|
||||
setField(request, "max_results", 2);
|
||||
|
||||
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
|
||||
|
||||
Assertions.assertEquals(List.of("CALC", "CALCULATE"), response.getCandidates().stream().map(candidate -> candidate.getName()).toList());
|
||||
Assertions.assertEquals(List.of("procedure", "function"), response.getCandidates().stream().map(candidate -> candidate.getKind().name().toLowerCase(Locale.ROOT)).toList());
|
||||
Assertions.assertEquals("APP", response.getCandidates().get(0).getSchema());
|
||||
Assertions.assertNull(response.getCandidates().get(0).getParent_name());
|
||||
Assertions.assertTrue(response.getIncomplete());
|
||||
Assertions.assertFalse(response.getFallback_used());
|
||||
Assertions.assertTrue(sql.get(0).contains("ROWNUM <= ?"));
|
||||
Assertions.assertTrue(new com.google.gson.Gson().toJsonTree(response).getAsJsonObject().has("routine_search_supported"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedRoutineSearchPreservesRequestedSequences() {
|
||||
for (CompletionAssistantObjectKind routineKind : List.of(CompletionAssistantObjectKind.ROUTINE, CompletionAssistantObjectKind.FUNCTION)) {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
|
||||
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
|
||||
new Object[][]{{"APP", "ORDER_SEQ", "SEQUENCE"}}
|
||||
)));
|
||||
CompletionAssistantRequest request = completionRequest("APP", null, "ORDER", false);
|
||||
setField(request, "object_kinds", List.of(routineKind, CompletionAssistantObjectKind.SEQUENCE));
|
||||
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
|
||||
Assertions.assertTrue(sql.get(0).contains("'SEQUENCE'"));
|
||||
Assertions.assertEquals(CompletionAssistantCandidateKind.SEQUENCE, response.getCandidates().get(0).getKind());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sequenceOnlySearchUsesOceanBaseFilteringWithoutAddingFunctions() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
|
||||
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
|
||||
new Object[][]{{"APP", "ORDER_SEQ", "SEQUENCE"}}
|
||||
)));
|
||||
CompletionAssistantRequest request = completionRequest("APP", null, "ORDER", false);
|
||||
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.SEQUENCE));
|
||||
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
|
||||
Assertions.assertEquals(1, response.getCandidates().size());
|
||||
Assertions.assertEquals(CompletionAssistantCandidateKind.SEQUENCE, response.getCandidates().get(0).getKind());
|
||||
Assertions.assertTrue(sql.get(0).contains("OBJECT_TYPE IN ('SEQUENCE')"));
|
||||
Assertions.assertFalse(sql.get(0).contains("'FUNCTION'"));
|
||||
Assertions.assertFalse(response.getFallback_used());
|
||||
}
|
||||
|
||||
@Test
|
||||
void routineSearchPreservesQuotedOwnersAndEscapesLiteralMasks() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
List<String> params = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(sql, params, resultSet(
|
||||
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"},
|
||||
new Object[][]{{"Mixed.Owner", "中文_%", "PROCEDURE"}}
|
||||
)));
|
||||
CompletionAssistantRequest request = completionRequest("APP", "Mixed.Owner", "中文_%", false);
|
||||
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.PROCEDURE));
|
||||
setField(request, "case_sensitive", true);
|
||||
setField(request, "match_mode", CompletionAssistantMatchMode.CONTAINS);
|
||||
setField(request, "max_results", 1);
|
||||
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
|
||||
Assertions.assertEquals(List.of("%中文\\_\\%%", "Mixed.Owner", "APP"), params);
|
||||
Assertions.assertTrue(sql.get(0).contains("OBJECT_TYPE IN ('PROCEDURE')"));
|
||||
Assertions.assertTrue(sql.get(0).contains("OWNER = ?"));
|
||||
Assertions.assertFalse(sql.get(0).contains("UPPER(OBJECT_NAME)"));
|
||||
Assertions.assertEquals("中文_%", response.getCandidates().get(0).getName());
|
||||
Assertions.assertFalse(response.getIncomplete(), "Exactly the requested count is complete");
|
||||
}
|
||||
|
||||
@Test
|
||||
void routineSearchDoesNotReturnStandaloneCandidatesForAPackageRequest() {
|
||||
CompletionAssistantRequest request = completionRequest("APP", null, "P", false);
|
||||
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
|
||||
setField(request, "parent_name", "PKG");
|
||||
Assertions.assertThrows(RuntimeException.class, () -> new OceanBaseOracleAgent().completionAssistantSearch(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void packageCompletionKeepsNoArgumentMembersAndBoundedOverloadsWithCompleteSignatures() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
List<String> params = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(sql, params,
|
||||
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
|
||||
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"},
|
||||
new Object[][]{{"RUN", 1, "PROCEDURE"}, {"RUN", 2, "FUNCTION"}, {"RUN", 3, "FUNCTION"}}),
|
||||
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED"},
|
||||
new Object[][]{
|
||||
{2, 0, null, "OUT", "NUMBER", null, null, null, "N"},
|
||||
{2, 1, "INPUT", "IN", "NUMBER", null, null, null, "Y"},
|
||||
{2, 2, "RESULT", "IN/OUT", "PL/SQL RECORD", "Mixed.Owner", "Types", "Payload", "N"}
|
||||
})
|
||||
));
|
||||
CompletionAssistantRequest request = completionRequest("APP", "Mixed.Owner", "Ru_%", false);
|
||||
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
|
||||
setField(request, "parent_name", "Mixed.Package");
|
||||
setField(request, "case_sensitive", true);
|
||||
setField(request, "max_results", 2);
|
||||
CompletionAssistantResponse response = agent.completionAssistantSearch(request);
|
||||
|
||||
Assertions.assertTrue(response.getIncomplete());
|
||||
Assertions.assertEquals(2, response.getCandidates().size());
|
||||
var procedure = response.getCandidates().get(0);
|
||||
var function = response.getCandidates().get(1);
|
||||
Assertions.assertEquals("", procedure.getSignature());
|
||||
Assertions.assertEquals("INPUT IN NUMBER DEFAULT, RESULT IN/OUT \"Mixed.Owner\".\"Types\".\"Payload\"", function.getSignature());
|
||||
Assertions.assertEquals("NUMBER", function.getData_type());
|
||||
Assertions.assertEquals("Mixed.Owner", function.getParent_schema());
|
||||
Assertions.assertEquals("Mixed.Package", function.getParent_name());
|
||||
Assertions.assertNotEquals(procedure.getRoutine_id(), function.getRoutine_id());
|
||||
Assertions.assertTrue(sql.get(0).contains("OBJECT_TYPE = 'PACKAGE'"));
|
||||
Assertions.assertTrue(sql.get(1).contains("p.OBJECT_ID = ?"));
|
||||
Assertions.assertTrue(sql.get(1).contains("ROWNUM <= ?"));
|
||||
Assertions.assertTrue(sql.get(2).contains("DATA_LEVEL = 0"));
|
||||
Assertions.assertTrue(sql.get(2).contains("SUBPROGRAM_ID IN (?,?)"));
|
||||
Assertions.assertFalse(sql.get(2).contains("ROWNUM"));
|
||||
Assertions.assertTrue(params.contains("Ru\\_\\%%"));
|
||||
var wire = new com.google.gson.Gson().toJsonTree(response).getAsJsonObject().getAsJsonArray("candidates");
|
||||
Assertions.assertEquals("", wire.get(0).getAsJsonObject().get("signature").getAsString());
|
||||
Assertions.assertEquals(function.getRoutine_id(), wire.get(1).getAsJsonObject().get("routine_id").getAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void packageCompletionResolvesInternalTypeOwnerThroughDatabaseIdentity() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(sql,
|
||||
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
|
||||
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"OBJ", 6, "PROCEDURE"}}),
|
||||
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED", "TYPE_SCHEMA"},
|
||||
new Object[][]{{6, 1, "X", "IN", "EXT", "514099 ", "Base.Type", null, "N", " Actual.Owner "}})
|
||||
));
|
||||
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
|
||||
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
|
||||
setField(request, "parent_name", "PKG");
|
||||
Assertions.assertEquals("X IN \" Actual.Owner \".\"Base.Type\"", agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
|
||||
Assertions.assertTrue(sql.get(2).contains("type_owner_object.OBJECT_TYPE = 'DATABASE'"));
|
||||
Assertions.assertTrue(sql.get(2).contains("TO_CHAR(type_owner_object.OBJECT_ID) = TRIM(a.TYPE_OWNER)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void packageCompletionKeepsLiteralNumericTypeOwner() {
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
|
||||
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
|
||||
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"OBJ", 6, "PROCEDURE"}}),
|
||||
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED"},
|
||||
new Object[][]{{6, 1, "X", "IN", "EXT", "514099", "Base.Type", null, "N"}})
|
||||
));
|
||||
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
|
||||
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
|
||||
setField(request, "parent_name", "PKG");
|
||||
Assertions.assertEquals("X IN \"514099\".\"Base.Type\"", agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
|
||||
}
|
||||
|
||||
@Test
|
||||
void packageCompletionDoesNotInventUnresolvedInternalTypeOwner() {
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
|
||||
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
|
||||
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"OBJ", 6, "PROCEDURE"}}),
|
||||
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED", "TYPE_SCHEMA"},
|
||||
new Object[][]{{6, 1, "X", "IN", "EXT", "514099 ", "Base.Type", null, "N", null}})
|
||||
));
|
||||
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
|
||||
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
|
||||
setField(request, "parent_name", "PKG");
|
||||
Assertions.assertNull(agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
|
||||
}
|
||||
|
||||
@Test
|
||||
void packageCompletionDoesNotInventMissingParameterMetadata() {
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
|
||||
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
|
||||
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{{"RUN", 1, "PROCEDURE"}}),
|
||||
resultSet(new String[]{"SUBPROGRAM_ID", "POSITION", "ARGUMENT_NAME", "IN_OUT", "DATA_TYPE", "TYPE_OWNER", "TYPE_NAME", "TYPE_SUBNAME", "DEFAULTED"},
|
||||
new Object[][]{{1, 1, "P", "IN", null, null, null, null, "N"}})
|
||||
));
|
||||
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
|
||||
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
|
||||
setField(request, "parent_name", "PKG");
|
||||
Assertions.assertNull(agent.completionAssistantSearch(request).getCandidates().get(0).getSignature());
|
||||
}
|
||||
|
||||
@Test
|
||||
void packageCompletionDistinguishesVisibleEmptyPackageFromInvisiblePackage() {
|
||||
CompletionAssistantRequest request = completionRequest("APP", "APP", "", false);
|
||||
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE));
|
||||
setField(request, "parent_name", "PKG");
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
|
||||
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{{101}}),
|
||||
resultSet(new String[]{"PROCEDURE_NAME", "SUBPROGRAM_ID", "ROUTINE_KIND"}, new Object[][]{})));
|
||||
Assertions.assertTrue(agent.completionAssistantSearch(request).getCandidates().isEmpty());
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(),
|
||||
resultSet(new String[]{"OBJECT_ID"}, new Object[][]{})));
|
||||
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> agent.completionAssistantSearch(request));
|
||||
Assertions.assertTrue(error.toString().contains("Package is not visible"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionAssistantSearchReturnsGlobalTableCandidates() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
@@ -785,6 +1000,28 @@ class OceanBaseOracleAgentTest {
|
||||
Assertions.assertTrue(sql.get(0).contains("c.DATA_DEFAULT"), sql.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getColumnsPreservesCharacterUnitsAndNegativeScale() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(sql, columnResultSet(new Object[][]{
|
||||
{"VB", "VARCHAR2", "Y", null, null, 8, 8, null, null, 0, "B"},
|
||||
{"VC", "VARCHAR2", "Y", null, null, 32, 8, null, null, 0, "C"},
|
||||
{"NC", "NVARCHAR2", "Y", null, null, 32, 8, null, null, 0, "C"},
|
||||
{"CB", "CHAR", "Y", null, null, 12, 4, null, null, 0, "B"},
|
||||
{"CC", "CHAR", "Y", null, null, 32, 8, null, null, 0, "C"},
|
||||
{"NR", "NUMBER", "Y", 10, -2, 22, 0, null, null, 0, null},
|
||||
{"NZ", "NUMBER", "Y", 10, 0, 22, 0, null, null, 0, null},
|
||||
{"NP", "NUMBER", "Y", 10, 2, 22, 0, null, null, 0, null},
|
||||
{"NU", "NUMBER", "Y", null, null, 22, 0, null, null, 0, null},
|
||||
{"VU", "VARCHAR2", "Y", null, null, 32, 8, null, null, 0, null}
|
||||
})));
|
||||
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
|
||||
Assertions.assertEquals(List.of("VARCHAR2(8 BYTE)", "VARCHAR2(8 CHAR)", "NVARCHAR2(8)", "CHAR(12 BYTE)", "CHAR(8 CHAR)", "NUMBER(10,-2)", "NUMBER(10)", "NUMBER(10,2)", "NUMBER", "VARCHAR2(8)"),
|
||||
columns.stream().map(ColumnInfo::getData_type).toList());
|
||||
Assertions.assertTrue(sql.get(0).contains("c.CHAR_USED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getColumnsResolvesTheCurrentSchemaWhenSchemaIsEmpty() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
@@ -1054,9 +1291,10 @@ class OceanBaseOracleAgentTest {
|
||||
"CHAR_LENGTH",
|
||||
"DATA_DEFAULT",
|
||||
"COMMENTS",
|
||||
"IS_PK"
|
||||
"IS_PK",
|
||||
"CHAR_USED"
|
||||
},
|
||||
rows
|
||||
java.util.Arrays.stream(rows).map(row -> java.util.Arrays.copyOf(row, 11)).toArray(Object[][]::new)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+39
-7
@@ -57,7 +57,7 @@ class OceanBaseOracleObjectListTest {
|
||||
case "_" -> "%\\_%";
|
||||
default -> "%\\\\%";
|
||||
};
|
||||
assertEquals(List.of("Mixed.Owner", "TABLE", pattern, pattern, 3, 2), call.args);
|
||||
assertEquals(List.of("Mixed.Owner", "TABLE", pattern, pattern, 3L, 2), call.args);
|
||||
call.assertClosed();
|
||||
}
|
||||
|
||||
@@ -78,13 +78,13 @@ class OceanBaseOracleObjectListTest {
|
||||
};
|
||||
|
||||
assertEquals(List.of("APP", "TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE",
|
||||
"PACKAGE BODY", "SEQUENCE", "SYNONYM"), call.args);
|
||||
"PACKAGE BODY", "SEQUENCE", "SYNONYM", "TYPE", "TYPE BODY"), call.args);
|
||||
assertEquals(List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE",
|
||||
"PACKAGE_BODY", "SEQUENCE", "SYNONYM"), objects.stream().map(ObjectInfo::getObject_type).toList());
|
||||
assertEquals(8, objects.size());
|
||||
assertTrue(objects.stream().allMatch(object -> "APP".equals(object.getSchema())));
|
||||
assertTrue(objects.stream().allMatch(object -> object.getValid() == null));
|
||||
assertTrue(call.sql.contains("OBJECT_TYPE IN (?, ?, ?, ?, ?, ?, ?, ?)"), call.sql);
|
||||
assertTrue(call.sql.contains("OBJECT_TYPE IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), call.sql);
|
||||
assertFalse(call.sql.contains("ROWNUM"), call.sql);
|
||||
assertObjectOrder(call.sql);
|
||||
call.assertClosed();
|
||||
@@ -134,6 +134,17 @@ class OceanBaseOracleObjectListTest {
|
||||
call.assertClosed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void typeFilterRequestsSpecificationsAndBodiesBeforePaging() {
|
||||
JdbcFixture jdbc = new JdbcFixture();
|
||||
JdbcCall call = jdbc.rows(row("CUSTOM_TYPE", "TYPE"), row("CUSTOM_TYPE", "TYPE BODY"));
|
||||
assertEquals(List.of(new ObjectInfo("CUSTOM_TYPE", "TYPE", "APP", null),
|
||||
new ObjectInfo("CUSTOM_TYPE", "TYPE_BODY", "APP", null)),
|
||||
jdbc.agent.listObjects("APP", constraints(null, 3, 0, "TYPE", "TYPE_BODY")));
|
||||
assertEquals(List.of("APP", "TYPE", "TYPE BODY", 3L, 0), call.args);
|
||||
call.assertClosed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void filteredPagesKeepSameNameSpecAndBodyAndDoNotApplyOffsetTwice() {
|
||||
JdbcFixture jdbc = new JdbcFixture();
|
||||
@@ -143,7 +154,7 @@ class OceanBaseOracleObjectListTest {
|
||||
pages.addAll(jdbc.agent.listObjects("APP",
|
||||
constraints("k_'%", 1, offset, "PACKAGE", "PACKAGE_BODY")));
|
||||
|
||||
assertEquals(List.of("APP", "PACKAGE", "PACKAGE BODY", "%K%\\_%'%\\%%", "%K%\\_%'%\\%%", offset + 1, offset), call.args);
|
||||
assertEquals(List.of("APP", "PACKAGE", "PACKAGE BODY", "%K%\\_%'%\\%%", "%K%\\_%'%\\%%", offset + 1L, offset), call.args);
|
||||
assertTrue(call.sql.contains("UPPER(OBJECT_NAME) LIKE ? ESCAPE '\\'"), call.sql);
|
||||
assertTrue(call.sql.contains("ROWNUM <= ?"), call.sql);
|
||||
assertTrue(call.sql.contains("WHERE DBX_RN > ?"), call.sql);
|
||||
@@ -169,6 +180,27 @@ class OceanBaseOracleObjectListTest {
|
||||
call.assertClosed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tablePagesKeepTheOuterOrder() {
|
||||
JdbcFixture jdbc = new JdbcFixture();
|
||||
JdbcCall call = jdbc.rows(row("Z_LATE", "TABLE", "订单"));
|
||||
|
||||
assertEquals("Z_LATE", jdbc.agent.listTables("APP", constraints("订单", 1, 2, "TABLE")).get(0).getName());
|
||||
assertTrue(call.sql.endsWith("ORDER BY DBX_RN"), call.sql);
|
||||
assertTrue(call.sql.contains("ORDER BY OBJECT_NAME, o.OBJECT_ID"), call.sql);
|
||||
call.assertClosed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageUpperBoundDoesNotOverflowTheProtocolIntegerRange() {
|
||||
JdbcFixture jdbc = new JdbcFixture();
|
||||
JdbcCall call = jdbc.rows();
|
||||
|
||||
assertEquals(List.of(), jdbc.agent.listObjects("APP", constraints(null, 2, Integer.MAX_VALUE, "TABLE")));
|
||||
assertEquals(List.of("APP", "TABLE", 2147483649L, Integer.MAX_VALUE), call.args);
|
||||
call.assertClosed();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"TABLE", "VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM"})
|
||||
void unrelatedTypeRequestsKeepTheirParametersAndMapping(String type) {
|
||||
@@ -185,7 +217,7 @@ class OceanBaseOracleObjectListTest {
|
||||
void unsupportedTypesDoNotQueryTheDatabase() {
|
||||
JdbcFixture jdbc = new JdbcFixture();
|
||||
|
||||
assertEquals(List.of(), jdbc.agent.listObjects("APP", constraints(null, null, null, "TRIGGER", "TYPE_BODY")));
|
||||
assertEquals(List.of(), jdbc.agent.listObjects("APP", constraints(null, null, null, "TRIGGER", "EVENT")));
|
||||
assertEquals(0, jdbc.prepared);
|
||||
}
|
||||
|
||||
@@ -318,7 +350,7 @@ class OceanBaseOracleObjectListTest {
|
||||
for (int rank = 0; rank < types.size(); rank++) {
|
||||
assertTrue(sql.contains("WHEN '" + types.get(rank) + "' THEN " + rank), sql);
|
||||
}
|
||||
assertTrue(sql.contains("ELSE 7\nEND, OBJECT_NAME"), sql);
|
||||
assertTrue(sql.contains("ELSE 7\nEND, OBJECT_NAME, o.OBJECT_ID"), sql);
|
||||
}
|
||||
|
||||
private static final class JdbcFixture {
|
||||
@@ -363,7 +395,7 @@ class OceanBaseOracleObjectListTest {
|
||||
|
||||
PreparedStatement statement() {
|
||||
return proxy(PreparedStatement.class, (statement, method, values) -> switch (method.getName()) {
|
||||
case "setString", "setInt", "setObject" -> {
|
||||
case "setString", "setInt", "setLong", "setObject" -> {
|
||||
assertEquals(args.size() + 1, values[0]);
|
||||
args.add(values[1]);
|
||||
yield null;
|
||||
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OceanBaseOracleSynonymColumnsTest {
|
||||
@Test
|
||||
void privateSynonymReturnsTargetColumnMetadataAcrossSchemas() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
List<ColumnInfo> columns = catalog.agent.getColumns("APP", "ORDER_ALIAS");
|
||||
|
||||
assertEquals(1, columns.size());
|
||||
ColumnInfo column = columns.getFirst();
|
||||
assertEquals("AMOUNT", column.getName());
|
||||
assertEquals("NUMBER(19,4)", column.getData_type());
|
||||
assertEquals(19, column.getNumeric_precision());
|
||||
assertEquals(4, column.getNumeric_scale());
|
||||
assertFalse(column.getIs_nullable());
|
||||
assertTrue(column.getIs_primary_key());
|
||||
assertEquals("0", column.getColumn_default());
|
||||
assertEquals("Order amount", column.getComment());
|
||||
}
|
||||
|
||||
private static Map<String, Object> amountColumn() {
|
||||
return Map.of("COLUMN_NAME", "AMOUNT", "DATA_TYPE", "NUMBER", "NULLABLE", "N",
|
||||
"DATA_PRECISION", 19, "DATA_SCALE", 4, "DATA_LENGTH", 22,
|
||||
"DATA_DEFAULT", "0", "COMMENTS", "Order amount", "IS_PK", 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void synonymChainRetainsQuotedTargetNamesAndVerifiedViewIdentity() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "Bridge.Owner", "Next.Alias", null);
|
||||
catalog.synonym("Bridge.Owner", "Next.Alias", "Sales.Owner", "Order\"View", null);
|
||||
catalog.object("Sales.Owner", "Order\"View", "VIEW");
|
||||
catalog.columns("Sales.Owner", "Order\"View", amountColumn());
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("APP", "ORDER_ALIAS").getFirst();
|
||||
|
||||
assertEquals("Sales.Owner", column.getResolved_schema());
|
||||
assertEquals("Order\"View", column.getResolved_table());
|
||||
assertEquals("VIEW", column.getResolved_object_type());
|
||||
assertEquals("NUMBER(19,4)", column.getData_type());
|
||||
assertFalse(column.getIs_nullable());
|
||||
assertTrue(column.getIs_primary_key());
|
||||
}
|
||||
|
||||
private record Name(String owner, String name) {}
|
||||
|
||||
@Test
|
||||
void publicSynonymResolvesOnlyWhenSchemaIsNotExplicit() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("PUBLIC", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("APP", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("", "PUBLIC_ORDERS").getFirst();
|
||||
assertEquals("SALES", column.getResolved_schema());
|
||||
assertEquals("ORDERS", column.getResolved_table());
|
||||
assertEquals("TABLE", column.getResolved_object_type());
|
||||
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.startsWith("ALTER SESSION")));
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumns("APP", "PUBLIC_ORDERS"));
|
||||
assertTrue(error.getMessage().contains("not found or not accessible"), error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicSynonymSupportsTheOceanBaseDictionaryPublicOwner() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("APP", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("", "PUBLIC_ORDERS").getFirst();
|
||||
|
||||
assertEquals("SALES", column.getResolved_schema());
|
||||
assertEquals("ORDERS", column.getResolved_table());
|
||||
assertEquals("TABLE", column.getResolved_object_type());
|
||||
assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "PUBLIC_ORDERS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectedSchemaContextResolvesLocalNamesAndStillAllowsPublicFallback() {
|
||||
var catalog = new Catalog();
|
||||
catalog.columns("Reporting", "LOCAL_ORDERS", amountColumn());
|
||||
catalog.synonym("PUBLIC", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
|
||||
assertEquals("AMOUNT", catalog.agent.getColumnsInContext("", "LOCAL_ORDERS", "Reporting").getFirst().getName());
|
||||
assertEquals("SALES", catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting").getFirst().getResolved_schema());
|
||||
assertEquals("APP", catalog.currentSchema);
|
||||
assertThrows(RuntimeException.class, () -> catalog.agent.getColumnsInContext("Reporting", "PUBLIC_ORDERS", "APP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void localNamespaceObjectsShadowPublicSynonymsEvenWhenTheyHaveNoColumns() {
|
||||
for (String type : List.of("TABLE", "VIEW", "SEQUENCE", "PROCEDURE", "FUNCTION", "PACKAGE", "TYPE")) {
|
||||
var catalog = new Catalog();
|
||||
catalog.object("APP", "ORDERS", type);
|
||||
catalog.synonym("PUBLIC", "ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("", "ORDERS"), type);
|
||||
assertFalse(catalog.columnReads.contains(new Name("SALES", "ORDERS")), type);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void remoteSynonymIsRejectedBeforeAnyTargetQuery() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "REMOTE_ORDERS", "SALES", "ORDERS", "REMOTE.EXAMPLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumns("APP", "REMOTE_ORDERS"));
|
||||
|
||||
assertTrue(error.getMessage().contains("DBLink"), error.getMessage());
|
||||
assertTrue(error.getMessage().contains("not supported"), error.getMessage());
|
||||
assertEquals(List.of(new Name("APP", "REMOTE_ORDERS")), catalog.columnReads);
|
||||
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.contains("@")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cyclicSynonymsFailWithAnExplicitCycleError() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "A", "OTHER", "B", null);
|
||||
catalog.synonym("OTHER", "B", "APP", "A", null);
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "A"));
|
||||
|
||||
assertTrue(error.getMessage().contains("cycle"), error.getMessage());
|
||||
assertEquals(List.of(new Name("APP", "A"), new Name("OTHER", "B")), catalog.columnReads);
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteSynonymTargetMetadataNeverFallsBackToTheSessionOwner() {
|
||||
for (String[] target : new String[][]{{null, "ORDERS"}, {"SALES", null}, {"", "ORDERS"}, {"SALES", ""}}) {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "BROKEN", target[0], target[1], null);
|
||||
catalog.columns("APP", "ORDERS", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "BROKEN"));
|
||||
|
||||
assertTrue(error.getMessage() != null && error.getMessage().contains("Incomplete synonym target metadata"), String.valueOf(error));
|
||||
assertEquals(List.of(new Name("APP", "BROKEN")), catalog.columnReads);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void materializedViewIdentityTakesPrecedenceOverItsTableEntry() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "MV_ALIAS", "SALES", "ORDER_TOTALS", null);
|
||||
catalog.objects.put(new Name("SALES", "ORDER_TOTALS"), List.of(
|
||||
Map.of("OBJECT_TYPE", "TABLE"), Map.of("OBJECT_TYPE", "MATERIALIZED VIEW")));
|
||||
catalog.columns("SALES", "ORDER_TOTALS", amountColumn());
|
||||
|
||||
assertEquals("MATERIALIZED VIEW", catalog.agent.getColumns("APP", "MV_ALIAS").getFirst().getResolved_object_type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownTargetTypeIsNotPromotedToTable() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "SALES", "ORDERS", null);
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
|
||||
ColumnInfo column = catalog.agent.getColumns("APP", "ORDER_ALIAS").getFirst();
|
||||
|
||||
assertEquals("SALES", column.getResolved_schema());
|
||||
assertEquals("ORDERS", column.getResolved_table());
|
||||
assertNull(column.getResolved_object_type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void localTableAndPrivateSynonymTakePrecedenceOverPublicSynonym() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("PUBLIC", "ORDERS", "OTHER", "ORDERS", null);
|
||||
catalog.synonym("PUBLIC", "ORDER_ALIAS", "OTHER", "ORDERS", null);
|
||||
catalog.synonym("APP", "ORDER_ALIAS", "APP", "ORDERS", null);
|
||||
catalog.object("APP", "ORDERS", "TABLE");
|
||||
catalog.columns("APP", "ORDERS", amountColumn());
|
||||
|
||||
assertEquals("AMOUNT", catalog.agent.getColumns("", "ORDERS").getFirst().getName());
|
||||
assertEquals("APP", catalog.agent.getColumns("", "ORDER_ALIAS").getFirst().getResolved_schema());
|
||||
assertFalse(catalog.columnReads.contains(new Name("OTHER", "ORDERS")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void brokenPrivateTargetDoesNotFallThroughToPublicSynonyms() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "ORDERS", "SALES", "MISSING", null);
|
||||
catalog.synonym("PUBLIC", "ORDERS", "OTHER", "ORDERS", null);
|
||||
catalog.synonym("PUBLIC", "MISSING", "OTHER", "ORDERS", null);
|
||||
catalog.columns("OTHER", "ORDERS", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("", "ORDERS"));
|
||||
|
||||
assertTrue(error.getMessage().contains("SALES.MISSING not found or not accessible"), error.getMessage());
|
||||
assertFalse(catalog.columnReads.contains(new Name("OTHER", "ORDERS")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAndInaccessibleTargetsAreNotSuccessfulEmptyColumns() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("APP", "INACCESSIBLE", "SALES", "HIDDEN", null);
|
||||
|
||||
for (String name : List.of("MISSING", "INACCESSIBLE")) {
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", name));
|
||||
assertTrue(error.getMessage().contains("not found or not accessible"), error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dictionaryFailuresPreserveTheOriginalSqlException() {
|
||||
for (String view : List.of("ALL_TAB_COLUMNS", "ALL_OBJECTS", "ALL_SYNONYMS")) {
|
||||
var catalog = new Catalog();
|
||||
catalog.failure = new SQLException("insufficient privileges", "42000", 1031);
|
||||
catalog.failureView = view;
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "ORDERS"));
|
||||
|
||||
assertSame(catalog.failure, error.getCause(), view);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void longAcyclicChainHasABoundedResolutionLimit() {
|
||||
var catalog = new Catalog();
|
||||
for (int index = 0; index < 40; index++) {
|
||||
catalog.synonym("APP", "S" + index, "APP", "S" + (index + 1), null);
|
||||
}
|
||||
catalog.columns("APP", "S40", amountColumn());
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> catalog.agent.getColumns("APP", "S0"));
|
||||
|
||||
assertTrue(error.getMessage().contains("exceeded 32 links"), error.getMessage());
|
||||
assertTrue(catalog.columnReads.size() <= 33);
|
||||
assertFalse(catalog.columnReads.contains(new Name("APP", "S40")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invisibleForeignLocalObjectCannotBeReplacedByAPublicTarget() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "Reporting", "PUBLIC_ORDERS", null);
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertTrue(error.getMessage().contains("does not match"), error.getMessage());
|
||||
assertEquals("APP", catalog.currentSchema);
|
||||
}
|
||||
|
||||
private record NativeTarget(String owner, String name, String databaseLink) {}
|
||||
|
||||
@Test
|
||||
void nativePublicResolutionFailureRestoresTheExactOriginalSchema() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.currentSchema = "Original.Owner";
|
||||
catalog.nativeFailure = new SQLException("NAME_RESOLVE not available", "42000", 6550);
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertSame(catalog.nativeFailure, error.getCause());
|
||||
assertEquals("Original.Owner", catalog.currentSchema);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativePublicResolutionRejectsRemoteTargetsWithoutRemoteQueries() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", "REMOTE.EXAMPLE");
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertTrue(error.getMessage().contains("DBLink"), error.getMessage());
|
||||
assertEquals("APP", catalog.currentSchema);
|
||||
assertTrue(catalog.sql.stream().noneMatch(sql -> sql.contains("@")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaRestoreFailureCannotReturnSuccessfulColumns() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.restoreFailure = new SQLException("restore failed");
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertSame(catalog.restoreFailure, error.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaRestoreFailureIsSuppressedOnTheOriginalResolutionError() {
|
||||
var catalog = publicCatalog();
|
||||
catalog.nativeFailure = new SQLException("resolution failed");
|
||||
catalog.restoreFailure = new SQLException("restore failed");
|
||||
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> catalog.agent.getColumnsInContext("", "PUBLIC_ORDERS", "Reporting"));
|
||||
|
||||
assertSame(catalog.nativeFailure, error.getCause());
|
||||
assertArrayEquals(new Throwable[]{catalog.restoreFailure}, error.getCause().getSuppressed());
|
||||
}
|
||||
|
||||
private static Catalog publicCatalog() {
|
||||
var catalog = new Catalog();
|
||||
catalog.synonym("__public", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
catalog.object("SALES", "ORDERS", "TABLE");
|
||||
catalog.columns("SALES", "ORDERS", amountColumn());
|
||||
catalog.nativeResult("Reporting", "PUBLIC_ORDERS", "SALES", "ORDERS", null);
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private static final class Catalog {
|
||||
final OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
final Map<Name, List<Map<String, Object>>> columns = new HashMap<>();
|
||||
final Map<Name, List<Map<String, Object>>> synonyms = new HashMap<>();
|
||||
final Map<Name, List<Map<String, Object>>> objects = new HashMap<>();
|
||||
final Map<Name, NativeTarget> nativeTargets = new HashMap<>();
|
||||
final List<String> sql = new ArrayList<>();
|
||||
final List<Name> columnReads = new ArrayList<>();
|
||||
String currentSchema = "APP";
|
||||
SQLException failure;
|
||||
SQLException nativeFailure;
|
||||
SQLException restoreFailure;
|
||||
String failureView;
|
||||
|
||||
Catalog() {
|
||||
Connection connection = proxy(Connection.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("prepareStatement")) return statement((String) args[0]);
|
||||
if (method.getName().equals("prepareCall")) return nativeStatement((String) args[0]);
|
||||
if (method.getName().equals("createStatement")) {
|
||||
return proxy(Statement.class, (stmt, call, params) -> {
|
||||
if (call.getName().equals("executeQuery")) {
|
||||
sql.add((String) params[0]);
|
||||
return rows(List.of(Map.of("CURRENT_SCHEMA", currentSchema)));
|
||||
}
|
||||
if (call.getName().equals("execute")) {
|
||||
String query = (String) params[0];
|
||||
sql.add(query);
|
||||
String prefix = "ALTER SESSION SET CURRENT_SCHEMA = ";
|
||||
assertTrue(query.startsWith(prefix), query);
|
||||
String identifier = query.substring(prefix.length());
|
||||
if (restoreFailure != null && identifier.equals("\"APP\"")) throw restoreFailure;
|
||||
currentSchema = identifier.substring(1, identifier.length() - 1).replace("\"\"", "\"");
|
||||
return false;
|
||||
}
|
||||
return defaultValue(call.getReturnType());
|
||||
});
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
TestSupport.setPrivateConnection(agent, connection);
|
||||
}
|
||||
|
||||
void synonym(String owner, String name, String targetOwner, String targetName, String link) {
|
||||
var row = new HashMap<String, Object>();
|
||||
row.put("TABLE_OWNER", targetOwner);
|
||||
row.put("TABLE_NAME", targetName);
|
||||
row.put("DB_LINK", link);
|
||||
synonyms.put(new Name(owner, name), List.of(row));
|
||||
object(owner, name, "SYNONYM");
|
||||
}
|
||||
|
||||
void object(String owner, String name, String type) {
|
||||
objects.put(new Name(owner, name), List.of(Map.of("OBJECT_TYPE", type)));
|
||||
}
|
||||
|
||||
void columns(String owner, String name, Map<String, Object> column) {
|
||||
columns.put(new Name(owner, name), List.of(column));
|
||||
}
|
||||
|
||||
void nativeResult(String owner, String name, String targetOwner, String targetName, String link) {
|
||||
nativeTargets.put(new Name(owner, name), new NativeTarget(targetOwner, targetName, link));
|
||||
}
|
||||
|
||||
private CallableStatement nativeStatement(String query) {
|
||||
sql.add(query);
|
||||
Map<Integer, Object> parameters = new HashMap<>();
|
||||
NativeTarget[] result = {null};
|
||||
return proxy(CallableStatement.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("setString") || method.getName().equals("setInt")) {
|
||||
parameters.put((Integer) args[0], args[1]);
|
||||
return null;
|
||||
}
|
||||
if (method.getName().equals("execute")) {
|
||||
assertEquals(0, parameters.get(2), "OceanBase table resolution requires context 0");
|
||||
if (nativeFailure != null) throw nativeFailure;
|
||||
String identifier = (String) parameters.get(1);
|
||||
assertTrue(identifier.startsWith("\"") && identifier.endsWith("\""), identifier);
|
||||
String name = identifier.substring(1, identifier.length() - 1).replace("\"\"", "\"");
|
||||
result[0] = nativeTargets.get(new Name(currentSchema, name));
|
||||
assertNotNull(result[0], "Unexpected native resolution for " + currentSchema + "." + name);
|
||||
return false;
|
||||
}
|
||||
if (method.getName().equals("getString")) {
|
||||
return switch ((Integer) args[0]) {
|
||||
case 3 -> result[0].owner();
|
||||
case 4 -> result[0].name();
|
||||
case 6 -> result[0].databaseLink();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private PreparedStatement statement(String query) {
|
||||
sql.add(query);
|
||||
Map<Integer, String> parameters = new HashMap<>();
|
||||
return proxy(PreparedStatement.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("setString")) {
|
||||
parameters.put((Integer) args[0], (String) args[1]);
|
||||
return null;
|
||||
}
|
||||
if (method.getName().equals("executeQuery")) {
|
||||
if (failure != null && query.contains(failureView)) throw failure;
|
||||
Name name = new Name(parameters.get(1), parameters.get(2));
|
||||
if (query.contains("FROM ALL_TAB_COLUMNS")) {
|
||||
assertEquals(name, new Name(parameters.get(3), parameters.get(4)), "PK and column owners must agree");
|
||||
columnReads.add(name);
|
||||
return rows(columns.getOrDefault(name, List.of()));
|
||||
}
|
||||
if (query.contains("FROM ALL_SYNONYMS")) return rows(synonyms.getOrDefault(name, List.of()));
|
||||
if (query.contains("FROM ALL_OBJECTS")) return rows(objects.getOrDefault(name, List.of()));
|
||||
throw new AssertionError("Unexpected metadata query: " + query);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static ResultSet rows(List<Map<String, Object>> data) {
|
||||
int[] index = {-1};
|
||||
boolean[] wasNull = {false};
|
||||
return proxy(ResultSet.class, (ignored, method, args) -> {
|
||||
if (method.getName().equals("next")) return ++index[0] < data.size();
|
||||
if (method.getName().equals("wasNull")) return wasNull[0];
|
||||
if (method.getName().equals("getString") || method.getName().equals("getInt") || method.getName().equals("getObject")) {
|
||||
Object value = args[0] instanceof Integer
|
||||
? data.get(index[0]).values().iterator().next() : data.get(index[0]).get(args[0]);
|
||||
wasNull[0] = value == null;
|
||||
if (method.getName().equals("getObject")) return value;
|
||||
if (method.getName().equals("getInt")) return value == null ? 0 : ((Number) value).intValue();
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
|
||||
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler);
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == boolean.class) return false;
|
||||
if (type == int.class) return 0;
|
||||
if (type == long.class) return 0L;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.oceanbase.jdbc;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DbxLobResourceBytesTest {
|
||||
@Test
|
||||
void countsExistingDriverPayloadAndLocatorWithoutReadingTheServer() {
|
||||
Clob clob = new Clob("中文🙂".repeat(100), null);
|
||||
ObLobLocatorV1 locator = new ObLobLocatorV1();
|
||||
locator.binaryData = new byte[1200];
|
||||
locator.rowId = new byte[20];
|
||||
locator.tableId = new byte[8];
|
||||
clob.locator = locator;
|
||||
assertEquals(256 + 800 + 1200 + 20 + 8, DbxLobResourceBytes.retainedBytes(clob));
|
||||
locator.binaryData = new byte[96];
|
||||
clob.charData = null;
|
||||
clob.data = null;
|
||||
locator.payloadSize = 1024L * 1024 * 1024; // out-row length is not retained payload
|
||||
assertEquals(256 + 96 + 20 + 8, DbxLobResourceBytes.retainedBytes(clob));
|
||||
assertEquals(-1, DbxLobResourceBytes.retainedBytes(new Object()));
|
||||
}
|
||||
}
|
||||
@@ -1698,12 +1698,23 @@ func oracleListSQLWithVisibleSchemas(baseSQL string, visibleSchemas []string) (s
|
||||
}
|
||||
|
||||
func (s *server) currentSchema() (string, error) {
|
||||
const sqlText = "SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"
|
||||
var schema string
|
||||
// CURRENT_SCHEMA is per Oracle session. A manual transaction sets it only on
|
||||
// its pinned connection and skips per-statement setSchema, so a pooled
|
||||
// session would report the login user's schema and resolve unqualified
|
||||
// tables against the wrong owner (#11258).
|
||||
if s.manualTx != nil {
|
||||
if err := s.manualTx.QueryRow(sqlText).Scan(&schema); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToUpper(schema), nil
|
||||
}
|
||||
db, err := s.requireDB()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var schema string
|
||||
if err := db.QueryRow("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL").Scan(&schema); err != nil {
|
||||
if err := db.QueryRow(sqlText).Scan(&schema); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToUpper(schema), nil
|
||||
@@ -1993,7 +2004,7 @@ func completionRequestHasTableLikeKind(kinds []string) bool {
|
||||
func completionRequestHasRoutineKind(kinds []string) bool {
|
||||
for _, kind := range kinds {
|
||||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||||
case "routine", "procedure", "function":
|
||||
case "routine", "procedure", "function", "sequence":
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -2439,13 +2450,54 @@ func oracleCompletionSynonymTargetsQuery(targets []oracleCompletionSynonymTarget
|
||||
}
|
||||
}
|
||||
|
||||
func oracleCompletionRoutineObjectTypes(kinds []string) []string {
|
||||
if len(kinds) == 0 {
|
||||
return []string{"'FUNCTION'", "'PROCEDURE'", "'PACKAGE'", "'SEQUENCE'"}
|
||||
}
|
||||
hasRoutine := false
|
||||
hasProc := false
|
||||
hasFunc := false
|
||||
hasSeq := false
|
||||
for _, kind := range kinds {
|
||||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||||
case "routine":
|
||||
hasRoutine = true
|
||||
case "procedure":
|
||||
hasProc = true
|
||||
case "function":
|
||||
hasFunc = true
|
||||
case "sequence":
|
||||
hasSeq = true
|
||||
}
|
||||
}
|
||||
types := make([]string, 0, 4)
|
||||
if hasRoutine {
|
||||
types = append(types, "'FUNCTION'", "'PROCEDURE'", "'PACKAGE'")
|
||||
} else {
|
||||
if hasProc {
|
||||
types = append(types, "'PROCEDURE'")
|
||||
}
|
||||
if hasFunc {
|
||||
types = append(types, "'FUNCTION'")
|
||||
}
|
||||
}
|
||||
if hasSeq {
|
||||
types = append(types, "'SEQUENCE'")
|
||||
}
|
||||
if len(types) == 0 {
|
||||
return []string{"'FUNCTION'", "'PROCEDURE'", "'PACKAGE'"}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func oracleCompletionRoutinesQuery(request completionAssistantRequest, preferredSchema string, limit int) oracleMetadataListQuery {
|
||||
pattern := oracleCompletionLikePattern(request.Mask, request.MatchMode)
|
||||
args := make([]any, 0, 5)
|
||||
baseSQL := `
|
||||
objectTypes := oracleCompletionRoutineObjectTypes(request.ObjectKinds)
|
||||
baseSQL := fmt.Sprintf(`
|
||||
SELECT o.OWNER, o.OBJECT_NAME, o.OBJECT_TYPE, CAST(NULL AS VARCHAR2(128)) AS PARENT_NAME
|
||||
FROM ALL_OBJECTS o
|
||||
WHERE o.OBJECT_TYPE IN ('FUNCTION', 'PROCEDURE', 'PACKAGE')`
|
||||
WHERE o.OBJECT_TYPE IN (%s)`, strings.Join(objectTypes, ", "))
|
||||
args = append(args, pattern)
|
||||
nameParam := len(args)
|
||||
|
||||
|
||||
@@ -2347,6 +2347,30 @@ func TestOracleCompletionRoutinesQueryUsesPublicPackageMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOracleCompletionRoutinesQueryIncludesSequences(t *testing.T) {
|
||||
query := oracleCompletionRoutinesQuery(completionAssistantRequest{
|
||||
Schema: "HR",
|
||||
ObjectKinds: []string{"routine", "sequence"},
|
||||
Mask: "SEQ",
|
||||
GlobalSearch: false,
|
||||
}, "HR", 100)
|
||||
sqlText := strings.ToUpper(query.SQL)
|
||||
if !strings.Contains(sqlText, "'SEQUENCE'") {
|
||||
t.Fatalf("routines query should include SEQUENCE when requested: %s", query.SQL)
|
||||
}
|
||||
|
||||
seqOnlyQuery := oracleCompletionRoutinesQuery(completionAssistantRequest{
|
||||
Schema: "HR",
|
||||
ObjectKinds: []string{"sequence"},
|
||||
Mask: "SEQ",
|
||||
GlobalSearch: false,
|
||||
}, "HR", 100)
|
||||
seqSQL := strings.ToUpper(seqOnlyQuery.SQL)
|
||||
if !strings.Contains(seqSQL, "IN ('SEQUENCE')") {
|
||||
t.Fatalf("sequence-only query should only include SEQUENCE: %s", seqOnlyQuery.SQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOracleCompletionPackageCandidatesPreserveOverloadsAndReturnTypes(t *testing.T) {
|
||||
rows := []oraclePackageRoutineRow{
|
||||
{
|
||||
@@ -3901,6 +3925,29 @@ func TestManualTransactionSkipsPerStatementSetSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualTransactionCurrentSchemaUsesPinnedSession(t *testing.T) {
|
||||
db, _ := openOracleManualTxTestDB(t)
|
||||
// A second pooled session that never ran ALTER SESSION SET CURRENT_SCHEMA
|
||||
// still reports the login user's schema.
|
||||
db.SetMaxOpenConns(2)
|
||||
s := newServer()
|
||||
s.db = db
|
||||
|
||||
if err := s.beginManualTransaction("APP_TEST"); err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
schema, err := s.normalizeSchema("")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeSchema: %v", err)
|
||||
}
|
||||
if schema != "APP_TEST" {
|
||||
t.Fatalf("normalizeSchema(\"\") during manual tx = %q, want APP_TEST", schema)
|
||||
}
|
||||
if err := s.rollbackManualTransaction(); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualTransactionDisconnectRollsBack(t *testing.T) {
|
||||
db, driver := openOracleManualTxTestDB(t)
|
||||
s := newServer()
|
||||
@@ -4003,6 +4050,7 @@ func (d *oracleManualTxDriver) Open(string) (driver.Conn, error) {
|
||||
type oracleManualTxConn struct {
|
||||
driver *oracleManualTxDriver
|
||||
closed bool
|
||||
schema string
|
||||
}
|
||||
|
||||
func (c *oracleManualTxConn) Prepare(string) (driver.Stmt, error) {
|
||||
@@ -4036,6 +4084,9 @@ func (c *oracleManualTxConn) ExecContext(_ context.Context, query string, _ []dr
|
||||
c.driver.mu.Lock()
|
||||
defer c.driver.mu.Unlock()
|
||||
c.driver.execs = append(c.driver.execs, query)
|
||||
if schema, ok := strings.CutPrefix(query, "ALTER SESSION SET CURRENT_SCHEMA = "); ok {
|
||||
c.schema = strings.Trim(schema, `"`)
|
||||
}
|
||||
return driver.RowsAffected(1), nil
|
||||
}
|
||||
|
||||
@@ -4043,6 +4094,16 @@ func (c *oracleManualTxConn) QueryContext(_ context.Context, query string, _ []d
|
||||
c.driver.mu.Lock()
|
||||
defer c.driver.mu.Unlock()
|
||||
c.driver.queries = append(c.driver.queries, query)
|
||||
if strings.Contains(query, "'CURRENT_SCHEMA'") {
|
||||
schema := c.schema
|
||||
if schema == "" {
|
||||
schema = "LOGIN_USER"
|
||||
}
|
||||
return &oracleManualTxRows{
|
||||
columns: []string{"SCHEMA"},
|
||||
values: [][]driver.Value{{schema}},
|
||||
}, nil
|
||||
}
|
||||
return &oracleManualTxRows{
|
||||
columns: []string{"A"},
|
||||
values: [][]driver.Value{{int64(1)}},
|
||||
|
||||
+18
-3
@@ -468,13 +468,17 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
String resolvedSchema = metadataSchema(schema, table);
|
||||
List<ColumnInfo> columns = super.getColumns(resolvedSchema, table);
|
||||
if (!sqlServer2000Mode || columns.isEmpty()) {
|
||||
if (columns.isEmpty()) {
|
||||
return columns;
|
||||
}
|
||||
try {
|
||||
// JDBC REMARKS from sp_columns is always NULL on SQL Server, so the
|
||||
// MS_Description comments must be read from the catalog directly.
|
||||
return mergeSqlServer2000ColumnComments(
|
||||
columns,
|
||||
readSqlServer2000ColumnComments(resolvedSchema, table)
|
||||
sqlServer2000Mode
|
||||
? readSqlServer2000ColumnComments(resolvedSchema, table)
|
||||
: readColumnCommentsFromQuery(columnCommentsSql(), resolvedSchema, table)
|
||||
);
|
||||
} catch (SQLException | RuntimeException error) {
|
||||
// Comments are optional metadata. Keep the table usable when the
|
||||
@@ -482,7 +486,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
// Legacy drivers can also throw runtime errors from their catalog
|
||||
// code, mirroring the RuntimeException guards in getTableDdl.
|
||||
System.err.println(
|
||||
"[sqlserver-legacy] SQL Server 2000 column comments unavailable: "
|
||||
"[sqlserver-legacy] SQL Server column comments unavailable: "
|
||||
+ error.getClass().getName()
|
||||
+ ": "
|
||||
+ error.getMessage()
|
||||
@@ -547,6 +551,17 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
return columns;
|
||||
}
|
||||
|
||||
static String columnCommentsSql() {
|
||||
return "SELECT c.name AS column_name, CAST(ep.value AS nvarchar(max)) AS column_comment, "
|
||||
+ "ep.name AS property_name "
|
||||
+ "FROM sys.columns c "
|
||||
+ "JOIN sys.objects o ON o.object_id = c.object_id "
|
||||
+ "JOIN sys.schemas s ON s.schema_id = o.schema_id "
|
||||
+ "JOIN sys.extended_properties ep ON ep.class = 1 AND ep.major_id = c.object_id "
|
||||
+ "AND ep.minor_id = c.column_id AND ep.name = N'MS_Description' "
|
||||
+ "WHERE s.name = ? AND o.name = ? AND o.type IN ('U', 'V')";
|
||||
}
|
||||
|
||||
static String sqlServer2000ColumnCommentsSql() {
|
||||
return "SELECT c.name AS column_name, p.value AS column_comment, p.name AS property_name "
|
||||
+ "FROM sysobjects o JOIN sysusers u ON o.uid = u.uid "
|
||||
|
||||
+32
@@ -444,6 +444,27 @@ class SqlServerLegacyAgentTest {
|
||||
Assertions.assertEquals("JDBC remark", columns.get(2).getComment());
|
||||
}
|
||||
|
||||
@Test
|
||||
void modernSqlServerColumnCommentsComeFromExtendedProperties() {
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
TestSupport.setPrivateConnection(agent, sqlServer2000CommentConnection(
|
||||
null,
|
||||
Arrays.asList(
|
||||
Arrays.asList("NAME", "Display name", "MS_Description")
|
||||
),
|
||||
null
|
||||
));
|
||||
setSqlServer2000Mode(agent, false);
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("dbo", "USERS");
|
||||
|
||||
// mssql-jdbc reports NULL REMARKS for every SQL Server column, so the
|
||||
// MS_Description written by the structure editor must be merged in.
|
||||
Assertions.assertEquals("Display name", columns.get(1).getComment());
|
||||
Assertions.assertNull(columns.get(0).getComment());
|
||||
Assertions.assertEquals("JDBC remark", columns.get(2).getComment());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServer2000ColumnCommentsFailSoftWhenCatalogThrowsRuntimeError() {
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
@@ -727,6 +748,17 @@ class SqlServerLegacyAgentTest {
|
||||
Assertions.assertTrue(sql.contains("s.name = ? AND t.name = ?"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void columnCommentQueryReadsColumnExtendedPropertiesOnly() {
|
||||
String sql = SqlServerLegacyAgent.columnCommentsSql();
|
||||
|
||||
Assertions.assertTrue(sql.contains("sys.extended_properties"));
|
||||
Assertions.assertTrue(sql.contains("ep.class = 1"), "index comments share major_id/minor_id with columns");
|
||||
Assertions.assertTrue(sql.contains("ep.minor_id = c.column_id"));
|
||||
Assertions.assertTrue(sql.contains("ep.name = N'MS_Description'"));
|
||||
Assertions.assertTrue(sql.contains("s.name = ? AND o.name = ?"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableCommentDdlUsesExtendedPropertyAndPreservesWhitespace() {
|
||||
String ddl = SqlServerLegacyAgent.appendTableCommentDdl(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.teradata.jdbc:terajdbc:20.00.00.56'
|
||||
implementation 'com.teradata.jdbc:terajdbc:20.00.00.60'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
@@ -1868,6 +1868,14 @@ func (s *server) getTableDDL(schema, table string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
if visibleColumns, fallbackErr := s.getColumns("", table); fallbackErr == nil && len(visibleColumns) > 0 {
|
||||
columns = visibleColumns
|
||||
if columns[0].ResolvedSchema != nil && *columns[0].ResolvedSchema != "" {
|
||||
effective = *columns[0].ResolvedSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
tableComment, _ := s.getTableComment(effective, table)
|
||||
ddl := renderTableDDL(effective, table, columns, tableComment)
|
||||
ddl, err = s.appendTableIndexDDL(effective, table, ddl)
|
||||
|
||||
@@ -50,6 +50,7 @@ type Config struct {
|
||||
BrowserResponseTimeout time.Duration
|
||||
BrowserDisableSSLCheck bool
|
||||
WaitForNonQueryCompletion bool
|
||||
IdentifierQuote string
|
||||
}
|
||||
|
||||
var _ driver.Connector = (*HiveConnector)(nil)
|
||||
@@ -112,6 +113,7 @@ func (c *HiveConnector) Connect(ctx context.Context) (driver.Conn, error) {
|
||||
connCfg.BrowserResponseTimeout = c.cfg.BrowserResponseTimeout
|
||||
connCfg.BrowserDisableSSLCheck = c.cfg.BrowserDisableSSLCheck
|
||||
connCfg.WaitForNonQueryCompletion = c.cfg.WaitForNonQueryCompletion
|
||||
connCfg.IdentifierQuote = c.cfg.IdentifierQuote
|
||||
|
||||
// Fallback: build TLS config from cert/key files if TLSConfig not provided directly
|
||||
if connCfg.TLSConfig == nil {
|
||||
|
||||
@@ -272,11 +272,28 @@ type connectConfiguration struct {
|
||||
BrowserResponseTimeout time.Duration
|
||||
BrowserDisableSSLCheck bool
|
||||
WaitForNonQueryCompletion bool
|
||||
IdentifierQuote string
|
||||
// Maximum length of the data in bytes. Used for SASL.
|
||||
MaxSize uint32
|
||||
MaxMessageSize int32
|
||||
}
|
||||
|
||||
func (c *connectConfiguration) identifierQuote() string {
|
||||
if c != nil && c.IdentifierQuote != "" {
|
||||
return c.IdentifierQuote
|
||||
}
|
||||
if c != nil && c.HiveConfiguration != nil {
|
||||
for k, v := range c.HiveConfiguration {
|
||||
cleanKey := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(k), "set:hivevar:"), "set:hiveconf:")
|
||||
cleanKey = strings.TrimPrefix(cleanKey, "#")
|
||||
if strings.EqualFold(cleanKey, "kyuubi.engine.type") && strings.EqualFold(strings.TrimSpace(v), "TRINO") {
|
||||
return "\""
|
||||
}
|
||||
}
|
||||
}
|
||||
return "`"
|
||||
}
|
||||
|
||||
// newConnectConfiguration returns a connect configuration, all with empty fields
|
||||
func newConnectConfiguration() *connectConfiguration {
|
||||
return &connectConfiguration{
|
||||
@@ -652,7 +669,7 @@ func innerConnect(ctx context.Context, host string, port int, auth string,
|
||||
if configuration.Database != "" {
|
||||
cursor := conn.cursor()
|
||||
defer cursor.close(ctx)
|
||||
cursor.exec(ctx, "USE "+quoteHiveIdentifier(configuration.Database))
|
||||
cursor.exec(ctx, "USE "+quoteIdentifier(configuration.Database, configuration.identifierQuote()))
|
||||
if cursor.Err != nil {
|
||||
return nil, cursor.Err
|
||||
}
|
||||
@@ -1693,7 +1710,14 @@ func usesHTTPBasicAuth(auth string) bool {
|
||||
}
|
||||
|
||||
func quoteHiveIdentifier(value string) string {
|
||||
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
|
||||
return quoteIdentifier(value, "`")
|
||||
}
|
||||
|
||||
func quoteIdentifier(value, quote string) string {
|
||||
if quote == "" {
|
||||
quote = "`"
|
||||
}
|
||||
return quote + strings.ReplaceAll(value, quote, quote+quote) + quote
|
||||
}
|
||||
|
||||
var DEFAULT_SQL_STATE = ""
|
||||
|
||||
@@ -386,3 +386,32 @@ func TestQuoteHiveIdentifierEscapesBackticks(t *testing.T) {
|
||||
t.Fatalf("unexpected quoted identifier: %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteIdentifierWithCustomQuote(t *testing.T) {
|
||||
if value := quoteIdentifier(`analytics"prod`, `"`); value != `"analytics""prod"` {
|
||||
t.Fatalf("unexpected quoted identifier: %q", value)
|
||||
}
|
||||
if value := quoteIdentifier("analytics`prod", ""); value != "`analytics``prod`" {
|
||||
t.Fatalf("unexpected default quoted identifier: %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectConfigurationIdentifierQuote(t *testing.T) {
|
||||
cfg := &connectConfiguration{IdentifierQuote: `"`}
|
||||
if q := cfg.identifierQuote(); q != `"` {
|
||||
t.Fatalf("expected quote %q, got %q", `"`, q)
|
||||
}
|
||||
cfg = &connectConfiguration{
|
||||
HiveConfiguration: map[string]string{"set:hivevar:kyuubi.engine.type": "TRINO"},
|
||||
}
|
||||
if q := cfg.identifierQuote(); q != `"` {
|
||||
t.Fatalf("expected auto-detected Trino quote %q, got %q", `"`, q)
|
||||
}
|
||||
cfg = &connectConfiguration{
|
||||
HiveConfiguration: map[string]string{"set:hivevar:kyuubi.engine.type": "SPARK_SQL"},
|
||||
}
|
||||
if q := cfg.identifierQuote(); q != "`" {
|
||||
t.Fatalf("expected default quote %q, got %q", "`", q)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ cassandra-go shared-fallback native-go Uses cassandra-gocql-driver schema metada
|
||||
dameng native-pushdown java-sql Uses ALL_OBJECTS with type, filter, stable order, and LIMIT/OFFSET with legacy fallback.
|
||||
databend native-pushdown java-sql Uses system procedures for routine pushdown and shared table metadata constraints for table-like objects.
|
||||
db2 native-pushdown java-sql Uses SYSCAT metadata with type, filter, stable order, and OFFSET/FETCH.
|
||||
firebird shared-fallback java-jdbc-metadata Delegates constrained listings to shared JDBC metadata and only applies explicitly configured legacy text decoding to comments.
|
||||
firebird native-pushdown java-sql Lists objects natively from RDB$ system tables; constrained listings only apply explicitly configured legacy text decoding to comments.
|
||||
gbase8a native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET.
|
||||
gbase8s native-pushdown java-sql Uses systables with type, filter, stable order, and SKIP/FIRST.
|
||||
goldendb native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET.
|
||||
|
||||
|
+43
-43
@@ -1,57 +1,57 @@
|
||||
{
|
||||
"access": "0.1.73",
|
||||
"bigquery": "0.1.77",
|
||||
"cache": "0.1.22",
|
||||
"access": "0.1.76",
|
||||
"bigquery": "0.1.80",
|
||||
"cache": "0.1.25",
|
||||
"cassandra": "0.1.53",
|
||||
"dameng": "0.1.87",
|
||||
"databend": "0.1.64",
|
||||
"databricks": "0.1.71",
|
||||
"db2": "0.1.77",
|
||||
"duckdb": "0.1.29",
|
||||
"exasol": "0.1.71",
|
||||
"firebird": "0.1.73",
|
||||
"gbase8a": "0.1.71",
|
||||
"gbase8s": "0.1.66",
|
||||
"goldendb": "0.1.77",
|
||||
"h2": "0.1.77",
|
||||
"h2-legacy": "0.1.46",
|
||||
"hive": "0.1.58",
|
||||
"argo": "0.1.2",
|
||||
"ignite": "0.1.29",
|
||||
"ignite3": "0.1.29",
|
||||
"spark": "0.1.48",
|
||||
"informix": "0.1.77",
|
||||
"kingbase": "0.1.61",
|
||||
"kylin": "0.1.76",
|
||||
"dameng": "0.1.90",
|
||||
"databend": "0.1.67",
|
||||
"databricks": "0.1.74",
|
||||
"db2": "0.1.80",
|
||||
"duckdb": "0.1.31",
|
||||
"exasol": "0.1.74",
|
||||
"firebird": "0.1.77",
|
||||
"gbase8a": "0.1.74",
|
||||
"gbase8s": "0.1.69",
|
||||
"goldendb": "0.1.80",
|
||||
"h2": "0.1.80",
|
||||
"h2-legacy": "0.1.49",
|
||||
"hive": "0.1.59",
|
||||
"argo": "0.1.3",
|
||||
"ignite": "0.1.32",
|
||||
"ignite3": "0.1.32",
|
||||
"spark": "0.1.51",
|
||||
"informix": "0.1.80",
|
||||
"kingbase": "0.1.63",
|
||||
"kylin": "0.1.79",
|
||||
"neo4j": "0.1.52",
|
||||
"nebula": "0.1.2",
|
||||
"oceanbase-oracle": "0.1.73",
|
||||
"oracle": "0.1.67",
|
||||
"oracle-oci": "0.1.0",
|
||||
"snowflake": "0.1.76",
|
||||
"oceanbase-oracle": "0.1.76",
|
||||
"oracle": "0.1.68",
|
||||
"oracle-oci": "0.1.1",
|
||||
"snowflake": "0.1.79",
|
||||
"sqlite-worker": "0.1.6",
|
||||
"spanner": "0.1.29",
|
||||
"saphana": "0.1.72",
|
||||
"sundb": "0.1.77",
|
||||
"teradata": "0.1.71",
|
||||
"spanner": "0.1.32",
|
||||
"saphana": "0.1.75",
|
||||
"sundb": "0.1.80",
|
||||
"teradata": "0.1.74",
|
||||
"tdengine": "0.1.52",
|
||||
"trino": "0.1.76",
|
||||
"transwarp": "0.1.7",
|
||||
"vastbase": "0.1.53",
|
||||
"vertica": "0.1.71",
|
||||
"mongodb": "0.1.80",
|
||||
"highgo": "0.1.74",
|
||||
"uxdb": "0.1.43",
|
||||
"yashandb": "0.1.72",
|
||||
"oscar": "0.1.51",
|
||||
"trino": "0.1.79",
|
||||
"transwarp": "0.1.10",
|
||||
"vastbase": "0.1.54",
|
||||
"vertica": "0.1.74",
|
||||
"mongodb": "0.1.83",
|
||||
"highgo": "0.1.77",
|
||||
"uxdb": "0.1.46",
|
||||
"yashandb": "0.1.75",
|
||||
"oscar": "0.1.54",
|
||||
"xugu": "0.1.49",
|
||||
"iris": "0.1.70",
|
||||
"iris": "0.1.73",
|
||||
"iotdb": "0.1.45",
|
||||
"etcd": "0.1.45",
|
||||
"etcd2": "0.1.3",
|
||||
"zookeeper": "0.1.33",
|
||||
"kafka": "0.1.16",
|
||||
"kafka": "0.1.17",
|
||||
"rocketmq": "0.1.15",
|
||||
"rabbitmq": "0.1.14",
|
||||
"sqlserver-legacy": "0.1.50"
|
||||
"sqlserver-legacy": "0.1.53"
|
||||
}
|
||||
|
||||
@@ -23,6 +23,39 @@
|
||||
background-color: rgb(19 20 22);
|
||||
}
|
||||
|
||||
/* Linux floating frame (see create_linux_main_window). Lives here, not in the app
|
||||
* bundle, so the margin, rounded corners and shadow exist from the very first paint.
|
||||
* #root is the frame: `contain: layout` makes it the containing block of the
|
||||
* position:fixed startup/app layers. Keep the 20px in sync with LinuxResizeHandles. */
|
||||
html.dbx-linux-floating,
|
||||
html.dbx-linux-floating body {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
html.dbx-linux-floating #root {
|
||||
position: fixed;
|
||||
inset: 20px;
|
||||
/* globals.css sets html, body, #root to 100%, which would override inset */
|
||||
width: auto;
|
||||
height: auto;
|
||||
contain: layout;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(0 0 0 / 0.14),
|
||||
0 3px 12px rgb(0 0 0 / 0.16);
|
||||
}
|
||||
|
||||
html.dark.dbx-linux-floating #root {
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 255 255 / 0.1),
|
||||
0 3px 12px rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
html.dbx-linux-floating #startup-loading {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
#startup-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -69,6 +102,10 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Injected by the Rust side only for the main window when a compositor is running.
|
||||
if (window.__DBX_LINUX_FLOATING__) document.documentElement.classList.add("dbx-linux-floating");
|
||||
</script>
|
||||
<script data-dbx-startup-theme>
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
+146
-26
@@ -10,6 +10,7 @@ import { useI18n } from "vue-i18n";
|
||||
import { FileText, FolderPlus } from "@lucide/vue";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import AppToolbar from "@/components/layout/AppToolbar.vue";
|
||||
import LinuxResizeHandles from "@/components/layout/LinuxResizeHandles.vue";
|
||||
import AppTabBar from "@/components/layout/AppTabBar.vue";
|
||||
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "@/components/layout/groupTabBarPortal";
|
||||
import PluginShortcutBar from "@/components/plugins/PluginShortcutBar.vue";
|
||||
@@ -18,6 +19,7 @@ import AppSidebar from "@/components/layout/AppSidebar.vue";
|
||||
import SqlEditorWorkspace from "@/components/layout/SqlEditorWorkspace.vue";
|
||||
import { EDITOR_TOOLBAR_ACTIONS } from "@/components/layout/editorToolbarActions";
|
||||
import AppDialogs from "@/components/layout/AppDialogs.vue";
|
||||
import McpSqlApprovalDialog from "@/components/mcp/McpSqlApprovalDialog.vue";
|
||||
import DetachedTabHeader from "@/components/layout/DetachedTabHeader.vue";
|
||||
import WelcomeScreen from "@/components/layout/WelcomeScreen.vue";
|
||||
import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue";
|
||||
@@ -73,7 +75,7 @@ import { useExternalSqlFileChanges } from "@/composables/useExternalSqlFileChang
|
||||
import { useWebDavAutoUpload } from "@/composables/useWebDavAutoUpload";
|
||||
import { readSyncMethod, readWebDavAutoUploadConfig, readWebDavBackupSelection } from "@/lib/webdav/webdavAutoUploadConfig";
|
||||
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
|
||||
import { shouldDrawDesktopWindowFrame } from "@/composables/useWindowControls";
|
||||
import { shouldDrawDesktopWindowFrame, shouldDrawLinuxFloatingFrame, useWindowControls } from "@/composables/useWindowControls";
|
||||
import { createOpenTabsRestorationBarrier, initializeDesktopOpenTabs, initializeOpenTabs, type OpenTabsRestorationBarrier } from "@/lib/app/openTabsStartup";
|
||||
import { finishAppCloseWithRequiredPersist } from "@/lib/app/appClosePersistence";
|
||||
import { useSaveSqlFolderSelection } from "@/composables/useSaveSqlFolderSelection";
|
||||
@@ -86,7 +88,7 @@ import { OBJECT_BROWSER_SEARCH_FOCUS_EVENT, objectBrowserSearchFocusTabId } from
|
||||
import { parseRecentConnectionIds, rankRecentConnections, RECENT_CONNECTION_IDS_STORAGE_KEY, recordRecentConnection } from "@/lib/connection/recentConnections";
|
||||
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace";
|
||||
import { findTreeNodeById, resolveNewQueryTarget, resolveNewQueryInitialSql } from "@/lib/sql/newQueryContext";
|
||||
import { findTreeNodeById, resolveNewQueryTarget, resolveNewQueryInitialSql, type NewQueryContextSource } from "@/lib/sql/newQueryContext";
|
||||
import { isSqlObjectNavigationRoutineType, normalizeOracleNavigationTarget, sqlObjectNavigationSourceKind, sqlObjectNavigationSourceName, sqlObjectNavigationSourceSchema, sqlObjectNavigationTableType, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
||||
import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
|
||||
import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad";
|
||||
@@ -94,7 +96,7 @@ import { schemaAfterConnectionSwitch } from "@/lib/schema/connectionSchemaInitia
|
||||
import { resolveHistorySqlRestoreTarget } from "@/lib/history/historyRestoreTarget";
|
||||
import { resolveExecutableSql, resolveExecutableSqlWithBackend, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { isMacOS, isWindows } from "@/lib/backend/platform";
|
||||
import { getPlatform, isMacOS, isWindows } from "@/lib/backend/platform";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
|
||||
import { activeTabExternalSqlFileTarget, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTab, unassociatedExternalSqlFileTarget, type ExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
@@ -115,6 +117,7 @@ import { restoreGlobalNavigationEntry } from "@/lib/navigation/globalNavigationE
|
||||
import {
|
||||
isBrowserReloadShortcut,
|
||||
isBrowserTaskManagerShortcut,
|
||||
isCloseAllTabsShortcut,
|
||||
isCloseOtherTabsShortcut,
|
||||
isCloseTabShortcut,
|
||||
isCloseWindowShortcut,
|
||||
@@ -149,6 +152,7 @@ import {
|
||||
} from "@/lib/editor/keyboardShortcuts";
|
||||
import { canSaveSqlTab } from "@/lib/tabs/sqlTabSaveTarget";
|
||||
import { initialTabSwitcherSelection, moveTabSwitcherSelection, tabSwitcherOrder } from "@/lib/tabs/tabSwitcher";
|
||||
import { createModifierDoubleTapShortcutController, isModifierDoubleTapShortcut, matchesModifierDoubleTapShortcut } from "@/lib/editor/modifierDoubleTapShortcut";
|
||||
import { createTabSwitcherKeyboardController } from "@/lib/tabs/tabSwitcherKeyboard";
|
||||
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
|
||||
import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities";
|
||||
@@ -163,7 +167,7 @@ import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDri
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { webPath } from "@/lib/common/webPath";
|
||||
import { eventTargetAllowsNativeClipboard, shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
|
||||
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts";
|
||||
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, EDITOR_FONT_FAMILY_CSS_VAR, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts";
|
||||
import { DATA_GRID_TYPE_COLOR_KEYS, dataGridTypeColorCssVar, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
|
||||
import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory";
|
||||
import { useUiFontFamilyPreview } from "@/composables/useUiFontFamilyPreview";
|
||||
@@ -172,6 +176,7 @@ import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
|
||||
import { savedSqlDefaultTargetForWrite } from "@/lib/savedSql/savedSqlExecutionTarget";
|
||||
import { countActiveUpdateBlockingTasks } from "@/lib/app/appUpdateTaskGuard";
|
||||
import { initSavedSqlEditorPositions } from "@/lib/app/savedSqlEditorPosition";
|
||||
import { dismissOpenModalSurface } from "@/lib/ui/modalSurface";
|
||||
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
|
||||
import { objectBrowserTablesToAiTreeNodes } from "@/lib/ai/objectBrowserToAiTargets";
|
||||
import { aiTargetFromTab, type AiConversationBinding } from "@/lib/ai/aiConversationBinding";
|
||||
@@ -373,11 +378,12 @@ const activeAiRunCount = computed(() => (isDesktop ? activeDesktopAiRuns().lengt
|
||||
/** Runs waiting for a write confirmation — the panel-entry badge shows these
|
||||
* with a higher-priority indicator (parent PRD §4 line 71 / §9). */
|
||||
const awaitingAiRunCount = computed(() => (isDesktop ? activeDesktopAiRuns().filter((run) => run.status === "awaiting_write_confirmation").length : 0));
|
||||
const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged, applyMcpStatus } = useMcpUpdateBadge({
|
||||
const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged, applyMcpStatus, invalidateMcpUpdateStatus } = useMcpUpdateBadge({
|
||||
isDesktop,
|
||||
// Update availability remains visible when every auto-update switch is off;
|
||||
// the switches control installation, not whether the user can be reminded.
|
||||
updateNotificationsEnabled: () => true,
|
||||
shouldDeferRefresh: () => componentUpdates.updating.value,
|
||||
});
|
||||
const drawDesktopWindowFrame = shouldDrawDesktopWindowFrame(isMacOS(), isDesktop, isWindows());
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
@@ -385,6 +391,22 @@ let updateCheckTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const needsAuth = ref(!isDesktop && (startupProps.startupAuthentication?.required ?? true));
|
||||
const authenticated = ref(isDesktop || (startupProps.startupAuthentication?.authenticated ?? false));
|
||||
const setupRequired = ref(!isDesktop && (startupProps.startupAuthentication?.setup_required ?? false));
|
||||
const { isMaximized: windowMaximized, isFullscreen: windowFullscreen } = useWindowControls();
|
||||
// The Rust side injects this flag into the main window only when a compositing manager is
|
||||
// running (see create_linux_main_window); detached-tab and plugin windows never get it.
|
||||
const linuxCompositing = (window as unknown as { __DBX_LINUX_FLOATING__?: boolean }).__DBX_LINUX_FLOATING__ === true;
|
||||
const drawLinuxFloatingFrame = computed(() =>
|
||||
shouldDrawLinuxFloatingFrame({
|
||||
isLinux: getPlatform() === "linux",
|
||||
isDesktop,
|
||||
isMainWindow: windowContext.kind === "main",
|
||||
compositing: linuxCompositing,
|
||||
showingAuthPage: setupRequired.value || (needsAuth.value && !authenticated.value),
|
||||
isMaximized: windowMaximized.value,
|
||||
isFullscreen: windowFullscreen.value,
|
||||
}),
|
||||
);
|
||||
watch(drawLinuxFloatingFrame, (enabled) => document.documentElement.classList.toggle("dbx-linux-floating", enabled), { immediate: true });
|
||||
// Mirrors the template gate above the app shell. The backend liveness stream is registered
|
||||
// against it so the web runtime only opens an authenticated subscription.
|
||||
const appReady = computed(() => !setupRequired.value && (!needsAuth.value || authenticated.value));
|
||||
@@ -464,6 +486,8 @@ const cursorPos = ref(0);
|
||||
const previewChangesAvailable = ref(false);
|
||||
const formatSqlRequest = ref<{ id: number; tabId: string } | null>(null);
|
||||
const compressSqlRequest = ref<{ id: number; tabId: string } | null>(null);
|
||||
/** Toolbar `a`/`A`: keyword-only re-case for the acting editor (issue #11828). */
|
||||
const keywordCaseRequest = ref<{ id: number; tabId: string; mode: "upper" | "lower" } | null>(null);
|
||||
const activeOutputView = computed<TabOutputView>({
|
||||
get: () => activeTab.value?.uiState?.activeOutputView ?? "result",
|
||||
set: (view) => {
|
||||
@@ -471,7 +495,6 @@ const activeOutputView = computed<TabOutputView>({
|
||||
if (tab) queryStore.updateTabUiState(tab.id, { activeOutputView: view });
|
||||
},
|
||||
});
|
||||
const newQueryContextSource = ref<"tab" | "sidebar">("tab");
|
||||
const queryEditorDdlTarget = ref<{ connectionId: string; database: string; catalog?: string; schema?: string; tableName: string; objectType?: ObjectSourceKind } | null>(null);
|
||||
const queryEditorObjectSourceTarget = ref<{
|
||||
connectionId: string;
|
||||
@@ -1091,9 +1114,9 @@ provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(isSpecialPageActive));
|
||||
provide(EDITOR_TOOLBAR_ACTIONS, {
|
||||
canNewQuery: canCreateNewQuery,
|
||||
newQuery: (groupId: string) => {
|
||||
lastFocusedSidebarSurface.value = false;
|
||||
queryStore.focusGroup(groupId);
|
||||
newQueryContextSource.value = "tab";
|
||||
void newQuery();
|
||||
void newQuery({ preferredSource: "tab" });
|
||||
},
|
||||
explainMode,
|
||||
blockDangerousRedisCommands,
|
||||
@@ -1285,6 +1308,7 @@ function activateMainContentSurface(surface: MainContentSurface) {
|
||||
settingsStore.settingsPageActive = surface === "settings";
|
||||
driverStoreActive.value = surface === "driverStore";
|
||||
pluginCenterActive.value = surface === "pluginCenter";
|
||||
lastFocusedSidebarSurface.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -1439,6 +1463,8 @@ function handleToolbarUpdateClick() {
|
||||
function syncToolbarComponentUpdateState() {
|
||||
agentDriverUpdateCount.value = componentUpdates.driverUpdateCount.value;
|
||||
applyMcpStatus(componentUpdates.mcpUpdateAvailable.value);
|
||||
// 组件更新是权威来源;丢弃轮询期间发出的旧快照,避免其晚返回后重新点亮更新入口。
|
||||
invalidateMcpUpdateStatus();
|
||||
}
|
||||
|
||||
function handleComponentUpdatesChanged() {
|
||||
@@ -1489,6 +1515,8 @@ async function consumePendingComponentUpdatesAfterRestart() {
|
||||
if (currentVersion && !appVersion.value) appVersion.value = currentVersion;
|
||||
const pending = takePendingComponentUpdatesAfterAppRestart(currentVersion);
|
||||
if (!pending) return;
|
||||
// 丢弃重启过程中发出的后台 MCP 轮询:它们可能读到升级前快照,晚返回后会覆盖权威结果。
|
||||
invalidateMcpUpdateStatus();
|
||||
reportComponentUpdateResult(await runPendingComponentUpdatePlan(pending, componentUpdates));
|
||||
}
|
||||
|
||||
@@ -1682,7 +1710,9 @@ function applyUiFontFamily(fontFamily: string, options?: { debouncePluginBumpMs?
|
||||
// Mirrors the editor font onto the global mono token so host mono surfaces and
|
||||
// plugin sandboxes (via the plugin bridge's token push) follow the same setting.
|
||||
function applyMonoFontFamily(fontFamily: string) {
|
||||
writeRootToken(FONT_MONO_CSS_VAR, fontFamily || DEFAULT_MONO_FONT_FAMILY);
|
||||
const value = fontFamily || DEFAULT_MONO_FONT_FAMILY;
|
||||
writeRootToken(FONT_MONO_CSS_VAR, value);
|
||||
writeRootToken(EDITOR_FONT_FAMILY_CSS_VAR, value);
|
||||
}
|
||||
|
||||
function applyDataGridFontFamily(fontFamily: string) {
|
||||
@@ -1732,7 +1762,6 @@ watch(
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (id) newQueryContextSource.value = "tab";
|
||||
if (id) activateQuerySurface();
|
||||
else if (previousId) activateOpenSpecialPageFallback();
|
||||
if (id && pluginCenterActive.value) pluginCenterActive.value = false;
|
||||
@@ -1832,13 +1861,6 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => connectionStore.selectedTreeNodeId,
|
||||
(id) => {
|
||||
if (id) newQueryContextSource.value = "sidebar";
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => settingsStore.editorSettings.uiScale,
|
||||
(scale) => {
|
||||
@@ -2125,14 +2147,30 @@ function compressActiveSql(tabId?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function toggleSqlKeywordCase() {
|
||||
function toggleSqlKeywordCase(tabId: string) {
|
||||
const sqlFormatter = settingsStore.editorSettings.sqlFormatter;
|
||||
const keywordCase = sqlFormatter.keywordCase === "lower" ? "upper" : "lower";
|
||||
// Clicking the button and formatting must agree, so the preference flips and
|
||||
// the editor re-cases the current document (or selection) to match — the
|
||||
// button is labeled "使用大写/小写 SQL 关键字", and issue #11828 reported it as
|
||||
// doing nothing because only the preference moved.
|
||||
settingsStore.updateEditorSettings({
|
||||
sqlFormatter: {
|
||||
...sqlFormatter,
|
||||
keywordCase: sqlFormatter.keywordCase === "lower" ? "upper" : "lower",
|
||||
keywordCase,
|
||||
},
|
||||
});
|
||||
|
||||
const tab = resolveToolbarTab(tabId);
|
||||
if (!tab || tab.mode !== "query") return;
|
||||
const connection = connectionStore.getConfig(tab.connectionId);
|
||||
const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type;
|
||||
if (!canFormatSqlForDatabaseType(databaseType)) return;
|
||||
keywordCaseRequest.value = {
|
||||
id: (keywordCaseRequest.value?.id ?? 0) + 1,
|
||||
tabId: tab.id,
|
||||
mode: keywordCase,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultSavedSqlName(title: string) {
|
||||
@@ -3074,15 +3112,27 @@ function openConnectionSettings(connectionId: string, initialTab: ConfigTab = "c
|
||||
showConnectionDialog.value = true;
|
||||
}
|
||||
|
||||
async function newQuery() {
|
||||
interface NewQueryOptions {
|
||||
preferredSource?: NewQueryContextSource;
|
||||
}
|
||||
|
||||
async function newQuery(options: NewQueryOptions = {}) {
|
||||
const sqlConnections = connectionStore.connections.filter((connection) => quickConnectionOpenTarget(connection).kind === "query" && supportsGenericNewQuery(connection));
|
||||
const connectedSqlConnectionIds = new Set(sqlConnections.filter((connection) => connectionStore.connectedIds.has(connection.id)).map((connection) => connection.id));
|
||||
let target = resolveNewQueryTarget({
|
||||
activeTab: activeTab.value,
|
||||
selectedTreeNode: findTreeNodeById(connectionStore.treeNodes, connectionStore.selectedTreeNodeId),
|
||||
activeConnectionId: connectionStore.activeConnectionId,
|
||||
connections: connectionStore.connections,
|
||||
preferredSource: newQueryContextSource.value,
|
||||
connectedSqlConnectionIds,
|
||||
preferredSource: options.preferredSource,
|
||||
});
|
||||
if (!target) return;
|
||||
if (!target) {
|
||||
// No active editor/sidebar choice and no unique online SQL connection:
|
||||
// leave the connection unset so the user can choose in the editor toolbar.
|
||||
if (sqlConnections.length > 0) queryStore.createTab("", "", undefined, "query");
|
||||
return;
|
||||
}
|
||||
let conn = connectionStore.getConfig(target.connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
@@ -3132,7 +3182,7 @@ async function newQuery() {
|
||||
const initialSql = resolveNewQueryInitialSql({
|
||||
activeTab: activeTab.value,
|
||||
selectedTreeNode: findTreeNodeById(connectionStore.treeNodes, connectionStore.selectedTreeNodeId),
|
||||
preferredSource: newQueryContextSource.value,
|
||||
preferredSource: options.preferredSource,
|
||||
prefillEnabled: settingsStore.editorSettings.prefillNewQueryWithSelect,
|
||||
targetConnectionId: target.connectionId,
|
||||
targetDatabase: target.database,
|
||||
@@ -4007,7 +4057,35 @@ function closeTabSwitcher(commit: boolean) {
|
||||
if (tab) activateQueryTab(tab.id);
|
||||
}
|
||||
|
||||
const modifierDoubleTapSearch = createModifierDoubleTapShortcutController();
|
||||
watch(
|
||||
() => [settingsStore.editorSettings.shortcuts.quickOpen, settingsStore.editorSettings.shortcuts.globalSearch],
|
||||
() => modifierDoubleTapSearch.reset(),
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
function canCaptureModifierDoubleTapSearch(e: KeyboardEvent): boolean {
|
||||
const target = e.target instanceof Element ? e.target : document.activeElement;
|
||||
const shortcuts = settingsStore.editorSettings.shortcuts;
|
||||
const enabled = isModifierDoubleTapShortcut(shortcuts.quickOpen) || isModifierDoubleTapShortcut(shortcuts.globalSearch);
|
||||
// Capture runs before the settings input can stop propagation.
|
||||
if (e.defaultPrevented || showTabSwitcher.value || !enabled || target?.closest("[data-shortcut-input], [data-sql-shortcut-input]")) {
|
||||
modifierDoubleTapSearch.reset();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleKeyup(e: KeyboardEvent) {
|
||||
const shortcut = canCaptureModifierDoubleTapSearch(e) ? modifierDoubleTapSearch.handleKeyup(e) : null;
|
||||
const shortcuts = settingsStore.editorSettings.shortcuts;
|
||||
const quickOpenMatched = !!shortcut && matchesModifierDoubleTapShortcut(shortcut, shortcuts.quickOpen);
|
||||
if (shortcut && (quickOpenMatched || matchesModifierDoubleTapShortcut(shortcut, shortcuts.globalSearch))) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
quickOpenForceContent.value = !quickOpenMatched;
|
||||
showQuickOpen.value = true;
|
||||
}
|
||||
tabSwitcherKeyboard.handleKeyup(e);
|
||||
}
|
||||
|
||||
@@ -4016,6 +4094,9 @@ function handleTabSwitcherKeydownCapture(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
function handleGlobalSearchKeydownCapture(e: KeyboardEvent) {
|
||||
if (canCaptureModifierDoubleTapSearch(e)) modifierDoubleTapSearch.handleKeydown(e);
|
||||
const target = e.target instanceof Element ? e.target : document.activeElement;
|
||||
if (target?.closest("[data-shortcut-input], [data-sql-shortcut-input]")) return;
|
||||
if (e.defaultPrevented || !isGlobalSearchShortcut(e, settingsStore.editorSettings.shortcuts)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -4032,10 +4113,12 @@ function handleAuxiliarySearchKeydownCapture(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
function handleTabSwitcherWindowBlur() {
|
||||
modifierDoubleTapSearch.reset();
|
||||
tabSwitcherKeyboard.handleWindowBlur();
|
||||
}
|
||||
|
||||
function handleTabSwitcherVisibilityChange() {
|
||||
modifierDoubleTapSearch.reset();
|
||||
tabSwitcherKeyboard.handleVisibilityChange(document.visibilityState);
|
||||
}
|
||||
|
||||
@@ -4121,6 +4204,10 @@ function rememberSidebarSearchSurface() {
|
||||
lastFocusedSidebarSurface.value = true;
|
||||
}
|
||||
|
||||
function rememberEditorContentSurface() {
|
||||
lastFocusedSidebarSurface.value = false;
|
||||
}
|
||||
|
||||
function setPluginWorkbenchTabRef(tabId: string, element: unknown) {
|
||||
if (element && typeof element === "object" && "refresh" in element && typeof element.refresh === "function") {
|
||||
pluginWorkbenchTabRefs.set(tabId, element as PluginWorkbenchTabHandle);
|
||||
@@ -4150,6 +4237,10 @@ function refreshPluginWorkbenches(pluginId: string): void {
|
||||
}
|
||||
|
||||
async function closeActiveSurface() {
|
||||
// Cmd+W (and the native macOS "Close Tab" menu item) must not reach the surface behind
|
||||
// an open modal dialog. While a dialog owns the window the keystroke dismisses that
|
||||
// dialog instead, exactly like Esc, so the shortcut never goes dead on the user.
|
||||
if (dismissOpenModalSurface()) return;
|
||||
if (showSettingsPage.value) {
|
||||
closeSettingsPage();
|
||||
} else if (showPluginCenter.value) {
|
||||
@@ -4255,7 +4346,10 @@ async function handleKeydown(e: KeyboardEvent) {
|
||||
if (isNewQueryShortcut(e, shortcuts)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void newQuery();
|
||||
const target = e.target instanceof Element ? e.target : null;
|
||||
const targetIsDocument = !target || target === document.body || target === document.documentElement;
|
||||
const sidebarFocused = target?.closest("[data-app-sidebar]") || (targetIsDocument && lastFocusedSidebarSurface.value);
|
||||
void newQuery({ preferredSource: sidebarFocused ? "sidebar" : undefined });
|
||||
return;
|
||||
}
|
||||
if (isToggleSidebarShortcut(e, shortcuts)) {
|
||||
@@ -4302,6 +4396,12 @@ async function handleKeydown(e: KeyboardEvent) {
|
||||
appTabBarRef.value?.closeOtherActiveTabs();
|
||||
return;
|
||||
}
|
||||
if (isCloseAllTabsShortcut(e, shortcuts)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
appTabBarRef.value?.closeAllTabs();
|
||||
return;
|
||||
}
|
||||
if (isDisconnectAllActiveConnectionsShortcut(e, shortcuts)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -4618,6 +4718,9 @@ onMounted(async () => {
|
||||
});
|
||||
applyTheme();
|
||||
void applyUiScale(settingsStore.editorSettings.uiScale);
|
||||
window.addEventListener("pointerdown", modifierDoubleTapSearch.cancel, true);
|
||||
window.addEventListener("focusin", modifierDoubleTapSearch.cancel, true);
|
||||
window.addEventListener("compositionstart", modifierDoubleTapSearch.cancel, true);
|
||||
window.addEventListener("keydown", handleGlobalSearchKeydownCapture, true);
|
||||
window.addEventListener("keydown", handleTabSwitcherKeydownCapture, true);
|
||||
window.addEventListener("keydown", handleAuxiliarySearchKeydownCapture, true);
|
||||
@@ -4710,6 +4813,10 @@ onUnmounted(() => {
|
||||
clearInterval(updateCheckTimer);
|
||||
}
|
||||
window.removeEventListener("keydown", handleNativeSelectAll, true);
|
||||
modifierDoubleTapSearch.reset();
|
||||
window.removeEventListener("pointerdown", modifierDoubleTapSearch.cancel, true);
|
||||
window.removeEventListener("focusin", modifierDoubleTapSearch.cancel, true);
|
||||
window.removeEventListener("compositionstart", modifierDoubleTapSearch.cancel, true);
|
||||
window.removeEventListener("keydown", handleGlobalSearchKeydownCapture, true);
|
||||
window.removeEventListener("keydown", handleTabSwitcherKeydownCapture, true);
|
||||
window.removeEventListener("keydown", handleAuxiliarySearchKeydownCapture, true);
|
||||
@@ -4731,12 +4838,13 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<LoginPage v-if="setupRequired || (needsAuth && !authenticated)" :setup-mode="setupRequired" @authenticated="onLoginSuccess" />
|
||||
<div v-show="!setupRequired && (!needsAuth || authenticated)" class="fixed inset-0 h-screen w-screen overflow-hidden">
|
||||
<div v-show="!setupRequired && (!needsAuth || authenticated)" class="dbx-app-root fixed inset-0 h-screen w-screen overflow-hidden">
|
||||
<div v-if="appBackgroundActive && appBackgroundObjectUrl" data-app-background class="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||
<div class="h-full w-full" :style="appBackgroundImageStyle"></div>
|
||||
</div>
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<SidebarDangerDialogHost />
|
||||
<LinuxResizeHandles v-if="drawLinuxFloatingFrame" />
|
||||
<div data-app-shell class="h-screen w-screen max-w-full min-w-[760px] min-h-[600px] flex flex-col bg-background text-foreground overflow-hidden" :class="{ 'dbx-desktop-window-frame': drawDesktopWindowFrame }" :style="appUiFontFamilyStyle">
|
||||
<AppToolbar
|
||||
v-if="!isDetachedWindowContext"
|
||||
@@ -4811,6 +4919,7 @@ onUnmounted(() => {
|
||||
data-editor-content
|
||||
v-show="isSpecialPageActive || (!isAiPanelMaximized && !isHistoryPanelMaximized) || isZenMode"
|
||||
:class="isDetachedWindowContext ? 'flex-1 min-w-0 overflow-hidden bg-background' : isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'"
|
||||
@mousedown="rememberEditorContentSurface"
|
||||
>
|
||||
<div class="h-full flex min-h-0 min-w-0 flex-col">
|
||||
<AppTabBar
|
||||
@@ -4850,7 +4959,16 @@ onUnmounted(() => {
|
||||
:focus-target="driverStoreFocus"
|
||||
@update-count-change="updateAgentDriverUpdateCount"
|
||||
/>
|
||||
<PluginCenterPage v-if="pluginCenterTabOpen" v-show="pluginCenterActive" class="flex-1 min-h-0" :focus-target="pluginCenterFocus" :install-url-request="pluginCenterInstallRequest" @new-connection="openPluginConnectionDialog" @plugin-runtime-replaced="refreshPluginWorkbenches" />
|
||||
<PluginCenterPage
|
||||
v-if="pluginCenterTabOpen"
|
||||
v-show="pluginCenterActive"
|
||||
class="flex-1 min-h-0"
|
||||
:focus-target="pluginCenterFocus"
|
||||
:install-url-request="pluginCenterInstallRequest"
|
||||
@opened="activateMainContentSurface('query')"
|
||||
@new-connection="openPluginConnectionDialog"
|
||||
@plugin-runtime-replaced="refreshPluginWorkbenches"
|
||||
/>
|
||||
<EditorSettingsPage
|
||||
v-if="settingsPageTabOpen"
|
||||
v-show="settingsStore.settingsPageActive"
|
||||
@@ -4938,6 +5056,7 @@ onUnmounted(() => {
|
||||
:active-output-view="activeOutputView"
|
||||
:format-sql-request="formatSqlRequest"
|
||||
:compress-sql-request="compressSqlRequest"
|
||||
:keyword-case-request="keywordCaseRequest"
|
||||
:selected-sql="selectedSql"
|
||||
:cursor-pos="cursorPos"
|
||||
:block-dangerous-redis-commands="blockDangerousRedisCommands"
|
||||
@@ -5389,6 +5508,7 @@ onUnmounted(() => {
|
||||
:database-type="queryEditorDdlDatabaseType"
|
||||
:dialect="queryEditorDdlDialect"
|
||||
/>
|
||||
<McpSqlApprovalDialog v-if="isDesktop && !isDetachedWindowContext" />
|
||||
<QueryEditorObjectSourceDialog
|
||||
v-if="queryEditorObjectSourceTarget"
|
||||
v-model:open="showQueryEditorObjectSourceDialog"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user